How to Create an X Adapter (and pair it with X Components)

When creating a search experience, Interface X offers you a handy library of standalone building blocks to make the development part swift and easy, the X Components. But there are many other useful tools you can find in the Interface X repository.

The X Adapter is one of the most powerful and versatile tools in it, serving as a decoupled communication layer between an endpoint and your components. Combine it with the X Components, and you'll have half the work done already!

But how does the adapter accomplish this? Essentially, the adapter acts as a middleman between the website and the endpoint by handling requests. When creating and configuring an adapter, you typically need to specify an endpoint. However, to actually "adapt" the data, you need more than just an endpoint. This is where mappers come into play.

RequestMapper and ResponseMapper

The mappers are at the core of the usefulness of an X Adapter. The RequestMapper of the adapter is used to build the endpoint correctly before making the request, and the ResponseMapper takes care of the response to that request.

We can define the mappers as dictionaries that help the adapter translate from what we have to what we want. This translation is defined using Schemas, which specify how the source object should be transformed into the target object.

For example, let’s say that our components name the search query as query, but the endpoint expects it to be called just q. The adapter will need a RequestMapper that tells it to translate query into q when creating the request. Here's an example of how to define a RequestMapper that does just that:

const requestMapper = schemaMapperFactory({
	q: 'query',
})

Easy enough! But what if the endpoint requires numerous fields? How can you keep track of all the different fields needed for the request to work while building the mapper? Fortunately, the schemaMapperFactory implements generic typing using Typescript. This allows you to specify the type of the source and target request parameter objects, which enables your IDE to give you hints about the available fields.

type SourceParams = { query: string };
type TargetParams = { q: string };

const requestMapper = schemaMapperFactory<SourceParams, TargetParams>({
  q: 'query',
})

Let’s cut to the chase

Mapping a couple of 1:1 parameters is rarely enough to handle real-life situations. Now that we've covered the basics of mappers, let's take a closer look at how they can handle more complex object structures, such as nested objects and calculations.

Returning to the previous example, we essentially tell the mapper that it will receive an object with an entry called query. When it finds that entry, it should put its value in a new object's entry, q. But what if query is not at the root level of the object? What if it's wrapped inside something else?

type SourceParams = {
    searchParams: {
        query: string
    }
};

The good news is that the solution is quite simple: specify the full path to the property, and the mapper will find it. The even better news is that Typescript hints will still work and suggest the correct full path!

type SourceParams = {
    searchParams: {
        query: string
    }
};
type TargetParams = { q: string };

const requestMapper = schemaMapperFactory<SourceParams, TargetParams>({
	q: 'searchParams.query',
})

Indicating the path to the property like in these examples will cover a lot of the situations you might encounter, but what if there’s some extra complexity in what you want to do? What if you require doing some calculations before passing the value to the target property?

Consider a scenario where you have to handle pagination for a request. The source object has the current page (page) and the number of elements per page  (pageSize), but the endpoint requires the index of the first element to return  (startIndex).

type SourceParams = {
    searchParams: {
        query: string, 
        page: number, 
        pageSize: number
    }
};
type TargetParams = {
    q: string, 
    startIndex: number
};

To assign the correct value to pageIndex, you must first multiply page and pageSize. Rather than indicating the path to a parameter, you can pass a function that receives the entire source object and performs this calculation.

const requestMapper = schemaMapperFactory<SourceParams, TargetParams>({
  q: 'searchParams.query',
	pageIndex: ({ searchParams }) => searchParams.page * searchParams.pageSize
})

What about lists?

Moving on to responses, the tools we have are the same, but the problems we'll face are likely to be more complex. When dealing with endpoint responses, we have to handle more complex structures and arrays of elements. The X Adapter was created with the idea of mapping search responses, so it will need a way of handling the mapping of lists of elements.

Taking the following endpoint response as an example:

{
	response: {
		items: [{
            name: 'example',
            image: 'image.url.example'
        },
        ...]
	}
}

We define the types for the SourceResponse and the TargetResponse:

type SourceResponse = {
	response: {
		items: {
			identifier: number;
			name: string;
			image: string;
		}[];
	};
}

type TargetResponse = {
	results: {
		id: string;
		description: string;
		images: string[];
	}[];
}

We see here that response.items should be mapped to results and, for each item in the response, id is now an identifier string, description is now name and image is now an array in images. Let’s see how it’s done:

const responseMapper = schemaMapperFactory<SourceResponse, TargetResponse>({
  results: {
		$path: 'response.items',
		$subSchema: {
			identifier: ({ id }) => id.toString(),
			name: 'description',
			images: ({ image }) => [image]
		}
	}
})

So, what's happening here? We're passing an object in the schema for the result property. This object has two elements: $path and $subSchema. $path is the path to the property from the source response object that will be iterated to populate the list. $subSchema is the schema that will be applied to each of the elements found there.

X Components and the X Adapter

Now that we have an adapter that can translate between our components and an endpoint, we just need the components. This is where the X Components come in handy. They offer multiple components to easily and quickly create an engaging search experience from the ground up. You can check out this video lesson for guidance on how to create a project with X Components or delve deeper into the documentation.

Using both the X Adapter and X Components, you only need to provide the endpoints. The components are already prepared to receive the information to display the search through the X Adapter.

The X Components require an adapter that bundles together different adapters for different endpoints to cater to the necessities of the components, such as Related Tags, Next Queries, and tagging. To make the development process easier, it provides types for each adapter's expected request and response. Additionally, there's a pre-built adapter, the X Adapter-Platform, that you can plug directly into the X Components setup and communicate with Empathy.co endpoints right away. However, that's not always the case.

If you want to create a search experience with the X Component using your own endpoint, you'll most likely start by showing results for the search. To do that, you'll need to create an adapter for the search endpoint.

const myAdapter = {
	search: searchEndpointAdapter
}

Your searchEndpointAdapter will use your endpoint and the mappers to adapt the endpoint requests and responses to what the X Components understand, similar to the previous examples:

import { SearchRequest, SearchResponse } from "@empathyco/x-types";
import { YourSearchRequest, YourSearchResponse } from "your-types";

const requestMapper = schemaMapperFactory<SearchRequest, YourSearchRequest>({
  q: 'query',
  pageIndex: ({ rows, start }) => (rows && start ? rows * start : 0)
})

const responseMapper = schemaMapperFactory<YourSearchResponse, SearchResponse>({
  results: {
		$path: 'response.items',
		$subSchema: {
			id: ({ id }) => id.toString(),
			name: 'name',
			images: ({ image }) => [image],
			modelName: () => 'Result'
		}
	},
  totalResults: 'response.total'
})

const searchEndpointAdapter = endpointAdapterFactory<SearchRequest, SearchResponse>({
  endpoint: '<https://your.endpoint>',
  requestMapper,
  responseMapper
});

After you’re finished with the adapter, pass it in the options object to the X Components and the search adapter will start providing the results from your search to the components that need them!

new XInstaller({
	searchEndpointAdapter,
	...
}).init();

Creating a great search experience for your customers doesn't have to be daunting. Empathy.co's X Components and X Adapter make it easy for developers to quickly build a reliable, adaptable search system that caters to the needs of their users. The X Adapter simplifies the process of translating between components and endpoints, while the X Components offer a range of pre-built components that can be customized to fit specific requirements. By utilizing these tools, you can provide a seamless and engaging search experience that keeps your customers coming back for more.