Javascript Service Model
Core library for handling REST service requests with caching, aggregation and model definitions.
Framework integrations:
Features
- Define models and easily handle REST service requests
- Pass model data to REST service requests and retrieve model data from them
- Aggregation for multiple parallel requests to the same url to avoid redundant requests. See aggregation
- Caches response from services
- Uses axios for service request
- … more later
Content
- Installation
- Example
- Usage
- Future
- Contribution
- License
Installation
npm install js-service-model
Example
Definition of a simple ServiceModel
without using fields. https://jsonplaceholder.typicode.com/albums/ is being used as an example REST JSON service.
import {ServiceModel} from 'js-service-model'
class Album extends ServiceModel {
// Define service url
static urls = {
BASE: 'https://jsonplaceholder.typicode.com/albums/'
}
}
Retrieve data from a service. objects.detail()
will return a model instance. objects.list()
will return a list of model instances.
// Retrieve all albums from /albums/
const allAlbums = await Album.objects.list()
// Retrieve specific album from /albums/1/
const album = await Album.objects.detail(1)
// Retrieve filtered list from /albums/?userId=1
const userAlbums = await Album.objects.list({filter: {userId: 1}})
// Create new album
await Album.objects.create({title: 'New album'})
// Update an album
await Album.objects.update(1, {title: 'Updated album'})
// Delete specific album
await Album.objects.delete(1)
You can easily access the data from a model instance or define model fields.
album.data
Usage
BaseModel
A BaseModel
can be used to handle data from any source by passing the data when instantiating the model.
import {BaseModel, Field} from 'js-service-model'
class MyModel extends BaseModel {
// Definition of model fields (optional)
static fieldsDef = {
title: new Field()
}
}
const obj = new MyModel({title: 'My title'})
Retrieve data of the model instance.
obj.data // output: {title: 'My title'}
Retrieve a list of all fields.
obj.fields
Retrieve value from a single field.
// Retrieve value for field 'title'
await obj.val.title // output: My title
Set value of a field
obj.val.title = 'New title'
Retrieve field instance of given field name
obj.getField('title')
ServiceModel
A ServiceModel
extends from BaseModel
and adds the ModelManager
with a caching
store to keep track of aggregation of running requests and optionally caching the result of the services.
import {ServiceModel, Field} from 'js-service-model'
class Album extends ServiceModel {
static urls = {
BASE: 'https://jsonplaceholder.typicode.com/albums/'
}
// Duration to cache requested data in seconds. 0: no cache. null: Cache forever. Default is 30 seconds
static cacheDuration = 5
static fieldsDef = {
id: new Field(),
title: new Field()
}
}
Urls
Urls are currently divided into 2 different types. LIST
and DETAIL
(same like in Django REST framework).
LIST
: (e.g./albums/
) used forobjects.list()
DETAIL
: (e.g./albums/1/
) used forobjects.detail(1)
The simplest way to define the urls is to set the static property urls.BASE
in your ServiceModel
.
static urls = {
BASE: 'https://jsonplaceholder.typicode.com/albums/'
}
When performing a detail request your key will be automatically appended to the end of the BASE
url.
You can also define the LIST
and DETAIL
url separately:
static urls = {
LIST: 'https://jsonplaceholder.typicode.com/albums/',
// {pk} will be replaced with your value you provide to objects.detail()
DETAIL: 'https://jsonplaceholder.typicode.com/albums/{pk}/'
}
There are currently 3 ways to define your url with the following priority
- Overwrite
getListUrl
orgetDetailUrl
method and a return aPromise
which will resolve the url as a string - Set the
LIST
orDETAIL
url in your modelstatic urls = { LIST: <...>, DETAIL: <...> }
- Set the
BASE
url in your modelstatic urls = { BASE: <...> }
If you got a nested RESTful service structure (e.g. /albums/1/photos/
) have a look at parents.
Aggregation
When you start to request data from a service, for example Album.objects.detail('1')
, then the Promise
of the request will
be saved as long as the request has not been completed. So when requesting Album.objects.detail('1')
again (e.g from another component)
this request will be attached to the first request which has not been completed yet and the request of the service will only made once.
In case you want to avoid the request aggregation for a specific request see noRequestAggregation
in ModelManager RetrieveInterfaceParams.
Cache
With the static property cacheDuration
it is possible to set the duration (in seconds) of how long the result of a response
should be cached. The default value is 30 seconds. Currently the expired data will only be removed by requesting the same data again.
- null: cache will not be removed
- 0: no caching
You can manually clear the complete cache including aggregation by calling model.store.clear()
.
In case you want to set cache options for a specific request see ModelManager RetrieveInterfaceParams.
Parents
When using a nested RESTful service more information is necessary to uniquely identify a resource. You need to define parents
in your ServiceModel
.
class Photo extends ServiceModel {
[...]
// Define name of parents
static parents = ['album']
static urls = {
// Add placeholder for parent in your url
BASE: 'https://jsonplaceholder.typicode.com/albums/{album}/photos/'
}
}
// Retrieve all photos from album 1: /albums/1/photos/
const photos = await Photo.objects.list({parents: {album: 1}})
// Retrieve photo 2 from album 1: /albums/1/photos/2/
const photo = await Photo.objects.detail(2, {parents: {album: 1}})
It is necessary to set exact parents otherwise a warning will be printed to the console. You can also add some custom
validation of the parents by extending the checkServiceParents
method of your ServiceModel
. This will be called on default ModelManager
interfaces and when retrieving the service url from getListUrl
or getDetailUrl
.
Fields
Fields will be one of the main features of this library.
Field definition (fieldsDef
)
You can set your model fields with the static property fieldsDef
with a plain object with your fieldname as key and the field instance as value.
Nested fieldsDef
is currently not supported.
class MyModel extends BaseModel {
[...]
static fieldsDef = {
first_name: new Field(),
last_name: new Field()
}
}
const myObj = new MyModel({
first_name: 'Joe',
last_name: 'Bloggs'
})
await myObj.val.first_name // output: Joe
await myObj.val.last_name // output: Bloggs
Attribute name (attributeName
)
By default the key of your field in your fieldsDef
(e.g. first_name
) will be used to retrieve the value from the model data.
You can also set the attributeName
when instantiating the field. It is also possible to access nested data when using dot-notation in attributeName
.
If you need a more specific way to retrieve the value of a field from your data then have a look at Custom/Computed fields.
class MyModel extends BaseModel {
[...]
static fieldsDef = {
name: new Field({attributeName: 'username'}),
address_city: new Field({attributeName: 'address.city'}),
address_street: new Field({attributeName: 'address.street.name'})
}
}
const myObj = new MyModel({
username: 'joe_bloggs',
address: {
city: 'New York',
street: {
name: 'Fifth Avenue'
}
}
})
await myObj.val.name // output: joe_bloggs
await myObj.val.address_city // output: New York
await myObj.val.address_street // output: Fifth Avenue
Field label and hint (label
, hint
)
With the label
property you can set a descriptive name for your field. hint
is used to provide a detail description of your field. label
and hint
can either be a string
or a function
which should return a string
or a Promise
.
You can access your label/hint with the label
/hint
property of your field instance which will always return a Promise
.
class MyModel extends BaseModel {
[...]
static fieldsDef = {
first_name: new Field({
label: 'First name',
hint: () => 'First name of the employee'
})
}
}
[...]
const firstNameField = myObj.getField('first_name')
await firstNameField.label // output: First name
await firstNameField.hint // output: First name of the employee
Field API
class Field {
// Constructor takes field definition
constructor (def: FieldDef)
// Clone field instance
public clone (): Field
// Returns field name (Which has been set as key at fieldsDef.
// Will throw FieldNotBoundException in case field has not been bound to a model
public get name (): string
// Returns either attributeName from fieldsDef or field name
public get attributeName (): string
// Returns field definition
public get definition (): FieldDef
// Assigned model
// Will throw FieldNotBoundException in case field has not been bound to a model
public get model (): BaseModel
// Returns async field value from data by calling valueGetter with data of assigned model
public get value (): any
// Sets field value to model data by calling valueSetter with data of assigned model
public set value (value: any)
// Returns async field label from field definition
public get label (): Promise<string>
// Returns async field hint from field definition
public get hint (): Promise<string>
// Retrieve value from data structure according to attributeName
// Uses nested syntax from attributeName (e.g. "address.city" -> {address: {city: 'New York'}})
// Will return null if value is not available
public valueGetter (data: any): any
// Set value to data by using attributeName
// Will create nested structure from attributeName (e.g. "address.city" -> {address: {city: 'New York'}})
public valueSetter (value: any, data: Dictionary<any>): void
}
Custom/Computed fields
In case you want to define your own field class you just need to extend from Field
. By overwriting the valueGetter
method you are able to map the field value by yourself and create computed values.
class FullNameField extends Field {
valueGetter (data) {
return data ? data.first_name + ' ' + data.last_name : null
}
}
class MyModel extends BaseModel {
[...]
static fieldsDef = {
full_name: new FullNameField()
}
}
const myObj = new MyModel({
first_name: 'Joe',
last_name: 'Bloggs'
})
await myObj.val.full_name // output: Joe Bloggs
Field types
Different field types will be added with future releases.
ModelManager (objects
)
The ModelManager
provides the interface to perform the api requests. At the moment there are 2 default interface methods.
Retrieve list of data (objects.list()
)
objects.list()
is used to request a list of data (e.g. /albums/
) and will return a list of model instances.
You can optionally set RetrieveInterfaceParams
as only argument.
The method will use getListUrl
, sendListRequest
and mapListResponseBeforeCache
which can be overwritten for customization.
Examples:
Album.objects.list() // Request: GET /albums/
Photo.objects.list({parents: {album: 1}}) // Request: GET /albums/1/photos/
Album.objects.list({filter: {userId: 1}}) // Request: GET /albums/?userId=1
Retrieve single entry of data (objects.detail()
)
objects.detail()
is used to request a single entry (e.g. /albums/1/
) and will return a model instance.
The first argument is the primary key which can either be a string
or number
. You can optionally set RetrieveInterfaceParams
as second argument.
The method will use getDetailUrl
, sendDetailRequest
and mapDetailResponseBeforeCache
which can be overwritten for customization.
Examples:
Album.objects.detail(1) // Request: GET /albums/1/
Photo.objects.detail(5, {parents: {album: 1}}) // Request: GET /albums/1/photos/5/
Create single entry (objects.create()
)
objects.create()
is used to create a single entry under (e.g. /albums/
) by sending a request with method POST
.
You can provide your data you want to send with post as first argument. The method will use getListUrl
and sendCreateRequest
.
Examples:
Album.objects.create({title: 'New Album'}) // Request: POST /albums/
Photo.objects.create({title: 'New Photo'}, {parents: {album: 1}}) // Request: POST /albums/1/photos/
Update single entry (objects.update()
)
objects.update()
is used to update a single entry under (e.g. /albums/1/
) by sending a request with method PUT
.
The first argument is the primary key which can either be a string
or number
. You can provide your data you want to send with put as first argument.
The method will use getDetailUrl
and sendUpdateRequest
.
Examples:
Album.objects.update(1, {title: 'Updated Album'}) // Request: PUT /albums/1/
Photo.objects.update(5, {title: 'Updated Photo'}, {parents: {album: 1}}) // Request: PUT /albums/1/photos/5/
Delete single entry (objects.delete()
)
objects.delete()
is used to delete a single entry under (e.g. /albums/1/
) by sending a request with method DELETE
.
The method will use getDetailUrl
and sendDeleteRequest
.
Examples:
Album.objects.delete(1) // Request: DELETE /albums/1/
Photo.objects.delete(5, {parents: {album: 1}}) // Request: DELETE /albums/1/photos/5/
RetrieveInterfaceParams
With RetrieveInterfaceParams
you can provide additional parameters for objects.list()
and objects.detail()
e.g. for using query parameters or parents.
Full structure example:
{
// Optional service parents to handle nested RESTful services
parents: {album: 1},
// Filter params as plain object which will be converted to query parameters (params in axios)
filter: {userId: 1},
// Do not use and set response cache. Requests will still be aggregated. Already cached data will not be cleared
// Optional: default = false
noCache: false,
// Do not use request aggregation. Response will still be set and used from cache
// Optional: default = false
noRequestAggregation: false,
// Cache will not be used but set. Requests will still be aggregated
// Optional: default = false
refreshCache: false
}
Exceptions
Error codes from response (e.g. 401 - Unauthorized) will be mapped to an APIException
. You can catch a specific error by checking with instanceof
for your required exception.
import {APIException, UnauthorizedAPIException} from 'js-service-model'
[...]
try {
albums = await Album.objects.list()
} catch (error) {
if (error instanceof UnauthorizedAPIException) {
// Unauthorized
} else if (error instanceof APIException) {
// Any other HTTP error status code
} else {
// Other exceptions
throw error
}
}
All API exceptions inherit from APIException
and contain the response as property (error.response
).
HTTP status code | Exception |
---|---|
400 - Bad Request | BadRequestAPIException |
401 - Unauthorized | UnauthorizedAPIException |
403 - Forbidden | ForbiddenAPIException |
404 - Not Found | NotFoundAPIException |
500 - Internal Server Error | InternalServerErrorAPIException |
Other | APIException |
Custom ModelManager
You can extend the ModelManager
and add your own methods.
import {ModelManager} from 'js-service-model'
class Album extends ServiceModel {
[...]
static ModelManager = class extends ModelManager {
customMethod () {
const Model = this.model
return new Model({title: 'Custom Album'})
}
}
}
const customAlbum = Album.objects.customMethod()
It is also possible to overwrite some methods to do the list
/detail
request by yourself or map the response data before it gets cached and used for the model instance.
sendListRequest
- Gets called when doing a list request with
objects.list()
- Gets called when doing a list request with
sendDetailRequest
- Gets called when doing a detail with
objects.detail()
- Gets called when doing a detail with
sendCreateRequest
- Gets called when sending a create with
objects.create()
- Gets called when sending a create with
sendUpdateRequest
- Gets called when sending a update with
objects.update()
- Gets called when sending a update with
sendDeleteRequest
- Gets called when sending a delete with
objects.delete()
- Gets called when sending a delete with
buildRetrieveRequestConfig
- Gets called from
sendListRequest
andsendDetailRequest
and usesRetrieveInterfaceParams
to return the request configuration for axios.
- Gets called from
mapListResponseBeforeCache
- Gets called from
sendListRequest
with the response data before the data will be cached
- Gets called from
mapDetailResponseBeforeCache
- Gets called from
sendDetailRequest
with the response data before the data will be cached
- Gets called from
handleResponseError
- Receives Errors from
axios
and maps it to api exceptions
- Receives Errors from
Future
- Models
- Cache
- Define a different cacheDuration for a specific request
- Use cache from list response also for detail requests
- “garbage collector” to remove expired cache
- Cache
- Fields
- Different field types
- Standalone field instances
- Accessing foreign key fields and retrieving foreign model instances
- Global configuration with hooks
- …
Contribution
Feel free to create an issue for bugs, feature requests, suggestions or any idea you have. You can also add a pull request with your implementation.
It would please me to hear from your experience.
I used some ideas and names from django, django REST framework, ag-Grid and other libraries and frameworks.