x-adapter
Empathy Adapter is a library of utils to ease the communication with any API.
Some features that it provides:
- Create an API request function based on a simple configuration.
- Allow to configure several endpoints by extending the initial configuration.
- Allow to configure the response/request mapping.
- Create mapping functions based on Schemas.
Tech Stack
Installation
# or pnpm or yarn
npm install @empathyco/x-adapter
If you use this package together with
x-components, you should
additionally install the
@empathyco/x-types package and
take the advantage of it in your project development.
Configuration & Usage
An API Adapter
is a collection of EndpointAdapters
, one for each endpoint of the API you want to
consume. Each EndpointAdapter
is an asynchronous function that performs a request with the given
data, and returns a response promise with the requested data. Internally, it usually has to
transform the request data so the API can understand it, and the response data so your app
understands it as well.
Implement your own adapter
To create an EndpointAdapter
you can use the endpointAdapterFactory
function. This function will
receive an EndpointAdapterOptions
object containing all the needed data to perform and map a
request, and return a function that when invoked will trigger the request. The options that can be
configured are:
endpoint
: The URL that the httpClient
uses. It can be either a string or a mapper function
that dynamically generates the URL string using the request data.httpClient
: A function that will receive the endpoint and request options such as the parameters
and will perform the request, returning a promise with the unprocessed response data.defaultRequestOptions
: Default values for the endpoint configuration. You can use it to define
if a request is cancelable, a unique id to identify it, anything but the endpoint
can be set.
Check
EndpointAdapterOptions
to see the available options.requestMapper
: A function to transform the unprocessed request into parameters the API can
understand.responseMapper
: A function to transform the API response into data that your project can
understand.
Basic adapter implementation
In this example we have a simple request mapper that will add a q
parameter to the endpoint's url
to perform the request. If you check the function call below, you will see the query parameter
passed.
Types definition
interface ApiRequest {
q?: string;
id?: number;
}
interface ApiSearchResponse {
products: ApiProduct[];
total: number;
}
interface ApiProduct {
id: number;
title: string;
price: number;
}
interface AppSearchRequest {
query: string;
}
interface AppSearchResponse {
products: AppProduct[];
total: number;
}
interface AppProduct {
id: string;
name: string;
price: number;
}
Adapter's factory function implementation
import { endpointAdapterFactory } from '@empathyco/x-adapter';
export const searchProducts = endpointAdapterFactory({
endpoint: 'https://dummyjson.com/products/search',
requestMapper({ query }: Readonly<AppSearchRequest>): ApiRequest {
return {
q: query
};
},
responseMapper({ products, total }: Readonly<ApiSearchResponse>): AppSearchResponse {
return {
products: products.map(product => {
return {
id: product.id.toString(),
name: product.title,
price: product.price
};
}),
total: total
};
}
});
async function searchOnClick() {
const response = await searchProducts({ query: 'phone' });
console.log('products', response.products);
}
Using a dynamic endpoint
If you need to generate an endpoint url dynamically, you can add parameters inside curly brackets to
the endpoint string. By default, these parameters will be replaced using the request data. If a
parameter is not found inside the request, an empty string will be used.
export const getItemById = endpointAdapterFactory({
endpoint: 'https://dummyjson.com/{section}/{id}'
});
getItemById({ section: 'products', id: 1 });
getItemById({ section: 'quotes', id: 3 });
getItemById({ section: 'quotes' });
For more complex use cases, you can use a mapper function. This function receives the request, and
must return the URL string.
export const getProductById = endpointAdapterFactory({
endpoint: ({ id }: GetProductByIdRequest) => 'https://dummyjson.com/products/' + id
});
Additionally, you can also overwrite your adapter's endpoint definition using the
RequestOptions.endpoint
parameter in the function call. Take into account that your
responseMapper
definition should be agnostic enough to support the change:
export const getItemById = endpointAdapterFactory({
endpoint: 'https://dummyjson.com/quotes/{id}',
});
getItemById({ id: 1 }, { endpoint: 'https://dummyjson.com/products/{id}');
Using the httpClient function
Every adapter created using endpointAdapterFactory
uses the Fetch API
by default to perform the
requests. But you can use your own HttpClient
as part of the configurable
EndpointAdapterOptions
. A HttpClient
is a function that accepts two parameters: the endpoint
string, and an additional
options
object to make the request with.
const axiosHttpClient: HttpClient = (endpoint, options) =>
axios.get(endpoint, { params: options?.parameters }).then(response => response.data);
const customRequestMapper: Mapper<AppSearchRequest, ApiRequest> = ({ query }) => {
return {
q: query
};
};
const customResponseMapper: Mapper<ApiSearchResponse, AppSearchResponse> = ({
products,
total
}) => {
return {
products: products.map(product => {
return {
id: product.id.toString(),
name: product.title,
price: product.price
};
}),
total: total
};
};
export const searchProducts = endpointAdapterFactory({
endpoint: 'https://dummyjson.com/products/search',
httpClient: axiosHttpClient,
requestMapper: customRequestMapper,
responseMapper: customResponseMapper
});
Implement your own adapter using schemas
Sometimes the transformations you will need to do in the mappers are just renaming parameters. What
the API calls q
might be called query
in your request. To ease this transformations,
@empathyco/x-adapter
allows to create mappers using schemas.
A schema is just a dictionary where the key is the desired parameter name, and the value is the path
of the source object that has the desired value or a simple mapper function if you need to transform
the value somehow.
Types definition
interface ApiUserRequest {
q: string;
}
interface ApiUserResponse {
users: ApiUser[];
total: number;
}
interface ApiUser {
id: number;
firstName: string;
}
interface AppUserRequest {
query: string;
}
interface AppUserResponse {
people: AppUser[];
total: number;
}
interface AppUser {
id: string;
name: string;
}
Schema's mapper factory function implementation
const userSearchRequestMapper = schemaMapperFactory<AppUserRequest, ApiUserRequest>({
q: 'query'
});
const userSearchResponseMapper = schemaMapperFactory<ApiUserResponse, AppUserResponse>({
people: ({ users }) =>
users.map(user => {
return {
id: user.id.toString(),
name: user.firstName
};
}),
total: 'total'
});
export const searchUsers = endpointAdapterFactory({
endpoint: 'https://dummyjson.com/users/search',
requestMapper: userSearchRequestMapper,
responseMapper: userSearchResponseMapper
});
Create more complex models with SubSchemas
When you are creating adapters for different APIs you might find the case that you have to map the
same model in different places. To help you with that, schemas allows to use SubSchemas
. To use
them you just have to provide with the Path
of the data to map, and the Schema
to apply to it.
Types definition
interface ApiRequest {
q: string;
}
interface ApiResponse {
users: ApiUser[];
total: number;
}
interface ApiUser {
id: number;
email: string;
phone: string;
address: ApiAddress;
company: ApiAddress;
}
interface ApiAddress {
address: string;
city: string;
postalCode: string;
}
interface AppRequest {
query: string;
}
interface AppResponse {
people: AppUser[];
count: number;
}
interface AppUser {
id: number;
contact: {
email: string;
phone: string;
homeAddress: AppAddress;
companyAddress: AppAddress;
};
}
interface AppAddress {
displayName: string;
postalCode: number;
city: string;
}
Schemas and SubSchemas implementation
const addressSchema: Schema<ApiAddress, AppUserAddress> = {
displayName: source => `${source.address}, ${source.postalCode} - ${source.city}`,
city: 'city',
postalCode: source => parseInt(source.postalCode)
};
const userSchema: Schema<ApiUser, AppUser> = {
id: 'id',
contact: {
email: source => source.email.toLowerCase(),
phone: 'phone',
homeAddress: {
$subSchema: addressSchema,
$path: 'address'
},
companyAddress: {
$subSchema: addressSchema,
$path: 'address'
}
}
};
Schema's mapper factory function implementation with subSchemas
const responseMapper = schemaMapperFactory<ApiSearchUsersResponse, AppSearchUsersResponse>({
people: {
$subSchema: userSchema,
$path: 'users'
},
count: 'total'
});
const requestMapper = schemaMapperFactory<SearchUsersRequest, ApiSearchUsersRequest>({
q: 'query'
});
export const searchUsersWithContactInfo = endpointAdapterFactory({
endpoint: 'https://dummyjson.com/users/search',
requestMapper,
responseMapper
});
Using a mutable schema
This feature lets you have some default schemas, and modify or extend them for some concrete
implementations. To do so, you can use the createMutableSchema
function, passing a Source
and
Target
type parameters to map your models. This function will return a MutableSchema
that apart
from the mapping information will also contain some methods to create new schemas or modify the
current one.
In the example below we will create a MutableSchema
to have a default object that will be reused
for different endpoint calls.
Types definition and MutableSchema
export interface ApiBaseObject {
id: number;
body: string;
}
export interface AppBaseObject {
id: string;
text: string;
}
export const baseObjectSchema = createMutableSchema<ApiBaseObject, AppBaseObject>({
id: ({ id }) => id.toString(),
text: 'body'
});
Once we have the MutableSchema
, we can use the following methods to fit our different APIs needs:
$extends
: Creates a new MutableSchema
based on the original one. The original remains
unchanged. This can be useful if we need to create a new EndpointAdapter
with models based on
another API.$override
: Merges/modifies the original MutableSchema
partially, so the change will affect to
all the EndpointAdapter
(s) that are using it. It can be used to change the structure of our
request/response mappers, or to add them new fields. Useful for clients with few differences in
their APIs. For example, you can create a library with a default adapter and use this library from
the customer projects overriding only the needed field (e.g. retrieve the images from pictures
instead of images
in a products API).$replace
: Replaces completely the original MutableSchema
by a new one, it won't exist anymore.
The change will affect to all the EndpointAdapter
(s) that were using it. Useful for clients with
a completely different API/response to the standard you have been working with.
Extend a MutableSchema to reuse it in two different endpoints with more fields
import { ApiBaseObject, AppBaseObject, baseObjectSchema } from '@/base-types';
interface ApiPost extends ApiBaseObject {
title: string;
}
interface ApiPostsResponse {
posts: ApiPost[];
}
interface ApiComment extends ApiBaseObject {
postId: number;
}
interface ApiCommentsResponse {
comments: ApiComment[];
}
interface AppPost extends AppBaseObject {
postTitle: string;
}
interface AppPostsResponse {
posts: AppPost[];
}
interface AppComment extends AppBaseObject {
postId: number;
}
interface AppCommentsResponse {
comments: AppComment[];
}
const postSchema = baseObjectSchema.$extends<ApiPost, AppPost>({
postTitle: 'title'
});
const postsResponse = schemaMapperFactory<ApiPostsResponse, AppPostsResponse>({
posts: {
$subSchema: postSchema,
$path: 'posts'
}
});
export const searchPosts = endpointAdapterFactory({
endpoint: 'https://dummyjson.com/posts',
responseMapper: postsResponse
});
const commentSchema = baseObjectSchema.$extends<ApiComment, AppComment>({
postId: 'postId'
});
const commentsResponse = schemaMapperFactory<ApiCommentsResponse, AppCommentsResponse>({
comments: {
$subSchema: commentSchema,
$path: 'comments'
}
});
export const searchComments = endpointAdapterFactory({
endpoint: 'https://dummyjson.com/comments',
responseMapper: commentsResponse
});
Override a MutableSchema to add more fields
As said above, the suitable context for using the override
method would be a project with an API
that doesn't differ too much against the one used in our "base project". That means we can reuse
most of the types and schemas definitions, so we would only add a few new fields from the new API.
import { ApiBaseObject, AppBaseObject, baseObjectSchema } from '@/base-types';
interface ApiTodo {
completed: boolean;
todo: string;
userId: number;
}
interface ApiTodosResponse {
todos: ApiBaseObject[];
}
interface AppTodo {
completed: boolean;
text: string;
userId: string;
}
interface AppTodosResponse {
todos: AppBaseObject[];
}
const todosResponse = schemaMapperFactory<ApiTodosResponse, AppTodosResponse>({
todos: {
$subSchema: baseObjectSchema,
$path: 'todos'
}
});
export const searchTodos = endpointAdapterFactory({
endpoint: 'https://dummyjson.com/todos',
responseMapper: todosResponse
});
baseObjectSchema.$override<ApiTodo, AppTodo>({
completed: 'completed',
text: 'todo',
userId: ({ userId }) => userId.toString()
});
Replace a MutableSchema to completely change it
In this case we are facing too many differences between API responses. We don't need to write a
whole adapter from scratch, as there are other parts of the API that aren't changing so much, but we
should replace some endpointAdapter
's schemas.
import { ApiBaseObject, AppBaseObject, baseObjectSchema } from '@/base-types';
interface ApiQuote {
id: number;
quote: string;
author: string;
}
interface ApiQuotesResponse {
quotes: ApiBaseObject[];
}
interface AppQuote {
quoteId: string;
quote: string;
author: string;
}
interface AppQuotesResponse {
quotes: AppBaseObject[];
}
const quotesResponse = schemaMapperFactory<ApiQuotesResponse, AppQuotesResponse>({
quotes: {
$subSchema: baseObjectSchema,
$path: 'quotes'
}
});
export const searchQuotes = endpointAdapterFactory({
endpoint: 'https://dummyjson.com/quotes',
responseMapper: quotesResponse
});
baseObjectSchema.$replace<ApiQuote, AppQuote>({
quoteId: ({ id }) => id.toString(),
quote: 'quote',
author: 'author'
});
Extend an adapter that uses schemas
Imagine you have a new setup and that you can reuse most of the stuff you have developed. Probably
you have built an adapter instance as a configuration object that contains all of your
EndpointAdapter
calls, so you only need to extend the endpoint you need to change.
export const adapter = {
searchItem: getItemById,
searchList: searchComments
};
adapter.searchList = searchComments.extends({
endpoint: 'https://dummyjson.com/comments/',
defaultRequestOptions: {
},
defaultRequestOptions: {
parameters: {
limit: 10,
skip: 10
}
}
});
For further detail, you can check the
x-platform-adapter package.
It is a whole adapter implementation using this x-adapter
library to suit the
Search Platform API
needs.
Test
Empathy Adapter features are tested using Jest. You will find a
__tests__
folder inside each of the project's sections.
npm run test
Changelog
Changelog summary
Contributing
To start contributing to the project, please take a look at our
Contributing Guide. Take in
account that x-adapter
is developed using Typescript, so we
recommend you to check it out.
License
empathyco/x License