Rest client SDK for API for Javascript usage.
This client tries to avoid the complexity of implementing a custom SDK for every API you have. You just have to implements your model and a little configuration and it will hide the complexity for you.
npm install rest-client-sdk
import { Mapping, Attribute, Relation, ClassMetadata } from 'rest-client-sdk';
const mapping = new Mapping('/v1');
const productMetadata = new ClassMetadata(
'products', // key: mandatory, will be passed in your serializer
'my_products', // pathRoot: optional, the endpoint of your API: will be added to the mapping prefix ('/v1' here)
SomeRepositoryClass // repositoryClass: optional, See "Overriding repository" for more detail
);
const idAttr = new Attribute(
'@id', // serializedKey: mandatory, the key returned from your API
'id', // attributeName: optional, the name in your entity, default to the `serializedKey` attribute
'string', // type: optional, default to `string`
true // isIdentifier: optional, default to `false`
);
const name = new Attribute('name');
productMetadata.setAttributeList([idAttr, name]);
productMetadata.setRelationList([
new Relation(
Relation.ONE_TO_MANY, // type: Relation.ONE_TO_MANY, Relation.MANY_TO_ONE or Relation.MANY_TO_MANY
'categories', // targetMetadataKey: must match the first argument of `ClassMetadata` constructor of the target entity
'category_list', // serializedKey: the key returned from your API
'categoryList' // attributeName: optional, the name in your entity, default to the `serializedKey` attribute
),
]);
const categoryMetadata = new ClassMetadata('categories');
categoryMetadata.setAttributeList([
new Attribute('id', 'id', 'string', true),
new Attribute('name'),
]);
categoryMetadata.setRelationList([
new Relation(Relation.MANY_TO_ONE, 'product', 'product'),
]);
mapping.setMapping([productMetadata, categoryMetadata]);
Using TypeScript ? You need to configure the mapping to benefits from TypeScript types detection.
type Product = {
@id: string;
name: string;
categoryList: Category[];
};
type Category = {
@id: string;
name: string;
product: Product;
};
type TSMetadata = {
// first value is the entity object, second one is the listing type, it can be any Iterable<Entity>
products: {
entity: Product;
list: Array<Product>;
};
categories: {
entity: Category;
list: Array<Category>;
};
};
import { TokenStorage } from 'rest-client-sdk';
const tokenGeneratorConfig = { path: 'oauth.me', foo: 'bar' };
const tokenGenerator = new SomeTokenGenerator(tokenGeneratorConfig); // Some token generators are defined in `src/TokenGenerator/`
const storage = AsyncStorage; // create a storage instance if you are not on RN. In browser and node, localforage works fine
const tokenStorage = new TokenStorage(tokenGenerator, storage);
The token generator is a class implementing generateToken
and refreshToken
.
Those methods must return an array containing an access_token
key.
The storage needs to be a class implementing setItem(key, value)
,
getItem(key)
and removeItem(key)
. Those functions must return a promise.
At Mapado we use localforage in a browser environment and React Native AsyncStorage for React Native.
import RestClientSdk from 'rest-client-sdk';
const config = {
path: 'api.me',
scheme: 'https',
port: 443,
segment: '/my-api',
authorizationType: 'Bearer', // default to "Bearer", but can be "Basic" or anything
useDefaultParameters: true,
unitOfWorkEnabled: true, // if key is missing, UnitOfWork will be disabled by default
onRefreshTokenFailure: () => {
// do something when the refresh token fails
},
}; // path and scheme are mandatory
const sdk = new RestClientSdk(tokenStorage, config, mapping);
Using TypeScript ? You should now pass the TSMetadata that you defined.
import RestClientSdk, { Token } from 'rest-client-sdk';
const sdk = new RestClientSdk<TSMetadata>(tokenStorage, config, mapping);
Adding the key unitOfWorkEnabled
to the config
object passed to the RestClientSdk
constructor will enable or disable the UnitOfWork
The UnitOfWork keeps track of the changes made to entity props so as to only send dirty fields when updating that entity
const productRepo = sdk.getRepository('products');
let product = await productRepo.find(1);
/*
considering the json body of the response was
{
"category": "book",
"name": "Tom Sawyer"
}
*/
product = product.set('name', 'Huckleberry Finn');
productRepo.update(product);
/*
The PUT call produced will be made with the json body : { "name": "Huckleberry Finn" }
It will not include other unchanged props like "category" which could overwrite existing values
(if not fetched and initialized with a default null value for example)
*/
When dealing with large collections of objects, the UnitOfWork can add a considerable memory overhead. If you do not plan to do updates, it is advised to leave it disabled
You can deactivate unit of work for some calls to avoid registering objects you don't want:
const productRepo = sdk.getRepository('products');
// imagine a call with a backend returning all product fields
productRepo.find(1, { fields: ALL_PROPERTIES }); // will register the product with all it's properties.
// then for some reason, we make a call that will return only the id, and no properties
// you do not want this entity to be registered
productRepo.withUnitOfWork(false).find(1, { fields: ONLY_ID });
The withUnitOfWork
can only be used for find* calls. See #94 for more informations.
You can not activate the unit of work if it has not been enabled globally.
You can now call the clients this way:
sdk.getRepository('products').find(8); // will find the entity with id 8. ie. /v2/my_products/8
sdk.getRepository('products').findAll(); // will find all entities. ie. /v2/my_products
sdk.getRepository('products').findBy({ foo: 'bar' }); // will find all entities for the request: /v2/my_products?foo=bar
All these methods returns promises.
find
returns a Promise<Entity>
, findBy
and findAll
returns Promise<Iterable<Entity>>
sdk.getRepository('products').create(entity);
sdk.getRepository('products').update(entity);
sdk.getRepository('products').delete(entity);
All these methods returns promises.
create
and update
returns a Promise<Entity>
with the new entity.
delete
returns Promise<void>
.
You can override the default repository
import { AbstractClient } from 'rest-client-sdk';
class SomeEntityClient extends AbstractClient {
getPathBase(pathParameters) {
return '/v2/some_entities'; // you need to return the full query string for the collection GET query
}
getEntityURI(entity) {
return `${this.getPathBase()}/${entity.id}`; // this will be the URI used by update / delete script
}
}
export default SomeEntityClient;
Typescript users:
import { SdkMetadata } from 'rest-client-sdk';
class SomeEntityClient extends AbstractClient<TSMetadata['some_entities']> {
getPathBase(pathParameters: object): string {
return '/v2/some_entities'; // you need to return the full query string for the collection GET query
}
getEntityURI(entity: SomeEntity): string {
return `${this.getPathBase()}/${entity.id}`; // this will be the URI used by update / delete script
}
findThisPost(params): Post {
// do stuff
}
}
TODO : For the moment, if you want to call a custom repository method, you have to cast it. (TODO : Find a way to get it from the mapping).
const repo = sdk.getRepository('posts') as PostRepository<TSMetadata, Token>;
repo.findThisPost();
The serializer is the object in charge of converting strings to object and vice-versa.
It deserializes strings from the API in two phases (for both items and lists) :
- converts a string to a plain object (decode)
- optionnally converts this object to a model object (denormalize), if you want to work with something different than plain JS object (like immutable Record or a custom model class).
and on the other way serializes in two phase two:
- optionnaly converts the model object to a plain JavaScript object (normalize)
- converts this plain JavaScript object to a string that will be sent to the API (encode)
It has been greatly inspired by PHP Symfony's serializer.
The default serializer implementations deserializes JSON to plain JavaScript object and serializes plain JavaScript object to JSON.
You can create and inject a custom serializer to the SDK. The serializer must extends the base Serializer
class and implement the following methods:
-
normalizeItem(entity: object, classMetadata: ClassMetadata): object
: convert an entity to a plain javascript object -
encodeItem(object: object, classMetadata: ClassMetadata): string
: convert a plain javascript object to string -
decodeItem(rawData: string, classMetadata: ClassMetadata, response: Response): object
: convert a string containing an object to a plain javascript object -
denormalizeItem(object: object, classMetadata: ClassMetadata, response: Response): object
: convert a plain object to an entity -
decodeList(rawListData: string, classMetadata: ClassMetadata, response: Response): object | object[]
: convert a string containing a list of objects to a list of plain javascript objects -
denormalizeList(objectList: object | object[], classMetadata: ClassMetadata, response: Response): object[]
: convert a plain object list to an entity list
classMetadata
is the instance of ClassMetadata you configured. response
is the HTTP response object.
All text response from GET / PUT / POST request will be send to decodeItem + denormalizeItem
or decodeList + denormalizeList
. All content fom update
and create
call will be send to encodeItem + normalizeItem
.
import { Serializer } from 'rest-client-sdk';
class JsSerializer extends Serializer {
normalizeItem(entity, classMetadata) {
return entity; // we don't have model object here, so return the plain JS object
}
encodeItem(object, classMetadata) {
return JSON.stringify(object);
}
decodeItem(rawData, classMetadata, response) {
return JSON.parse(rawData);
}
denormalizeItem(object, classMetadata, response) {
return object; // we don't have any model object here, so return the plain JS object
}
decodeList(rawListData, classMetadata, response) {
return JSON.parse(rawListData);
}
denormalizeList(objectList, classMetadata, response) {
return objectList; // we don't have any model object here, so return the plain JS object
}
}
const serializer = new JsSerializer();
const sdk = new RestClientSdk(tokenStorage, config, clients, serializer);
Typescript users:
import { Serializer, ClassMetadata } from 'rest-client-sdk';
class JsSerializer extends Serializer {
normalizeItem(entity: object, classMetadata: ClassMetadata): object {
return entity; // we don't have model object here, so return the plain JS object
}
encodeItem(object: object, classMetadata: ClassMetadata): string {
return JSON.stringify(object);
}
decodeItem(
rawData: string,
classMetadata: ClassMetadata,
response: Response
): object {
return JSON.parse(rawData);
}
denormalizeItem(
object: object,
classMetadata: ClassMetadata,
response: Response
): object {
return object; // we don't have any model object here, so return the plain JS object
}
decodeList(
rawListData: string,
classMetadata: ClassMetadata,
response: Response
): object | object[] {
return JSON.parse(rawListData);
}
denormalizeList(
objectList: object | object[],
classMetadata: ClassMetadata,
response: Response
): object | object[] {
return objectList; // we don't have any model object here, so return the plain JS object
}
}
const serializer = new JsSerializer();
const sdk = new RestClientSdk<TSMetadata>(
tokenStorage,
config,
clients,
serializer
);
This sdk uses Object.fromEntries function. We discovered that it is not always available (very old browsers or even in some versions of the IOS JS engine when using the lib with react-native). In this case you need to add a polyfill to the root of your project (or at least before any call is made by the sdk) like so:
function fromEntries(arr) {
return arr.reduce((acc, curr) => {
acc[curr[0]] = curr[1];
return acc;
}, {});
}
Object.fromEntries = Object.fromEntries || fromEntries;