/stallone

An elegant and intuitive library to create REST clients

Primary LanguageJavaScriptMIT LicenseMIT

Stallone

npm version License: MIT Build

An elegant and intuitive library to create REST clients.

https://i.imgur.com/OnySz9Q.gif

Installation

You can install the library using npm:

$ npm install --save @sneppy/stallone

Or simply download and include it with a script tag:

<script src="https://unpkg.com/@sneppy/stallone"></script>

Stallone was designed to be used in a browser environment, and it uses XMLHttpRequests under the hood. To use it in a Node environment you will need a third-party library such as xhr2.

Basic usage

Create a new instance of Stallone:

import { Stallone } from '@sneppy/stallone'

let config = {
	baseURL: 'https://localhost:8080/api'
}
let api = new Stallone(config)

The constructor accepts a configuration object. This object should define at least the baseURL property, which is the base URL of the API endpoints.

Stallone uses ES6 classes to build the client. You need to declare a class for each type of resource:

class User extends api.Model {}
class Post extends api.Model {}

All classes must extend api.Model.

We can fetch an entity using Model.get(keys, options):

let user = User.get(2) // GET https://localhost:8080/api/user/2
let post = Post.get('my-first-post') // GET https://localhost:8080/api/post/my-first-post

By default, it will send a GET request at <baseURL>/<lowercase classname>/key0/key1/..../keyN. This can be changed by overriding the static method Model._path(keys), as explained later.

Stallone expects the server to send back data of the entity. If the data is nested, it will not work properly:

{
	"data": {
		"username": "...",
		"email": "..."
		// BAD
	},
	"created_at": "...",
	"updated_at": "..."
}

The return value of Model.get(keys) is an instance of that model, which we'll call entity. Once the entity data is loaded, we can access its properties using a familiar syntax:

console.log(user.email)
console.log(post['title'])

However, since the request is asynchronous, the entity data may be not available right away. If you want yo wait for the data to load, you can use Model.wait(event):

User.get('sneppy').wait().then((u) => console.log(`Hello, ${u.username}!`))

This method returns a JavaScript Promise that resolves whenever the entity record is loaded, created, updated or deleted. To filter this events, you may provide an optional argument and specify what type of event you want to wait for:

User.get('sneppy').wait('read').then((u) => console.log(`Hello, ${u.username}!`))

Since v1.0.0-beta.9 the 'ready' event is also available. If the data is already available it resolves immediately.

One nice feature of Stallone is that separate requests to the same resource share the same record. In fact, if you load the same user again the entity will be pre-filled with the data from the previous call:

User.get('sneppy').wait().then(() => console.log(User.get('sneppy').username))

Many entities may share the same record under the hood.

Stallone provides the method Model.create(data) to create a new entity:

let post = Post.create({
	author: 1,
	title: 'My first post',
	body: 'Lorem ipsum ...'
}) // POST https://localhost:8080/api/post {author: 1, title: 'My first post', body: 'Lorem ipsum ...'}

This sends a POST request to <baseURL>/<lowercase classname> and returns the entity created. It expects the server to respond with the data of the created entity.

Like before, the request is asynchronous and we may want to wait for it to finish using wait:

post.wait('create').then((p) => console.log(`created post "${p.title}"`))

An entity can be deleted using either the static method Model.delete(keys) or the instance method Model.delete():

Post.delete('my-first-post') // DELETE https://localhost:8080/api/post/my-first-post
// or
Post.get('my-first-post').wait().then((p) => p.delete()) // DELETE https://localhost:8080/api/post/<post._pk>

The former sends a single DELETE request using the provided key; the latter is recommended if the entity is already loaded.

Note that in the second case, the request is potentially sent to a different endpoint. By default, Stallone associates each entity with a relative endpoint, equal to <lowercase classname>/<primary key of the entity>.

This default behavior can be changed by overriding the instance getters Model._pk and Model._path (not to be confused with the static method), as explained later.

Unlike get and create, both forms of delete return a Promise that resolves when the entity is deleted:

post.delete().then(() => console.log('deleted'))

Note that other entities that share the same record may listen for such an event using wait:

otherPost.wait('delete').then(() => closePage())

Updating an entity in Stallone is as simple as changing the local entity and then calling Model.patch():

user.username = 'stallion'
user.email = 'stallion@sneppy.com'
user.patch() // PATCH https://localhost:8080/api/user/<user._pk> {username: 'stallion', email: 'stallion@sneppy.com'}

At the moment, Stallone only keeps track of property assignments, which means that if you change a mutable property (such as an array or an object), this property won't be updated when patching the entity. This may change in the future. You can refer to this Svelte tutorial and to this article for updating arrays and objects.

Stallone is smart enough to figure out what properties were modified and send a PATCH request with those changes. The server should send back any property that has been further modified by the request, though in general it can simply send back the data of the entity.

Like delete, it returns a Promise that resolves when the request has finished.

Note that, due to the architecture of Stallone, the local changes made to an entity are immediately reflected on all other entities that share the same record.

Customizing models

Though Stallone requires the backend to adopt to a certain structure, it is also flexible enough to conform to the backend.

The first property you may wish to change is the path used to fetch and create entities.

The default path is /<lowercase name of the model>/key0/key1/.../keyN:

User.get(1) // Get /user/1
Post.get('my-first-post') // GET /post/my-first-post
UserRelationship.get(1, 4) // GET /userrelationship/1/4

For instance, say that the endpoint to fetch the relationship between two users has the form /user/1/relationship/4. Then we would need to change the default behavior of UserRelationship like this:

class UserRelationship extends api.Model {
	static _path([ sender, recipient ]) {
		
		return `user/${sender}/relationship/${recipient}`
	}
}

UserRelationship.get(1, 4) // GET /user/1/relationship/4

Note that since _path is also called without arguments when creating an entity, you would get an error when calling UserRelationship.create().

In most cases you can simply override the static getter _dirname, which by default returns the lowercase name of the model:

class User extends api.Model {
	static _dirname() {

		return 'users'
	}
}

Sometimes you also need to override the instance getter Model._path which is used when patching or deleting the entity:

class UserRelationship extends api.Model {
	get _path() {

		// Use the entity properties to return the appropriate path
	}

	static _path([ sender, recipient ]) {
		
		return `user/${sender}/relationship/${recipient}`
	}
}

Since the base version of _path simply calls _path(this._pk), usually you can get away by simply override _pk, which returns the primary key of the entity:

class UserRelationship extends api.Model {
	get _pk_() {

		// Return an array with the sender and recipient keys
	}

	static _path([ sender, recipient ]) {
		
		return `user/${sender}/relationship/${recipient}`
	}
}

The default value of _pk is equal to the 'id' property of the entity, if any.

Any property defined on the instance as a higher priority than the same property in the record data. For instance, you may want to return a default profile picture if the user doesn't have any:

class User extends api.Model {
	get picture() {

		if (this._data && this._data.picture)
		{
			return this._data.picture
		}
		else
		{
			return 'default-image-uri'
		}
	}
}

There are a few reserved property defined on the entity:

  • _model refers to the class and may be used to access its static methods;
  • _record refers to the record object, and it is mainly used internally;
  • _data refers directly to the record's data.

You may also define custom setters:

class User extends api.Model {
	set username(value) {

		this._record.patch({
			username: value.trim().toLowerCase()
		})
	}
}

It is important that you call Record.patch(patches), otherwise the change will not be accounted for when calling patch. Not doing so may be useful to prevent some properties from being uploaded when patching an entity.

A setter or method may also patch multiple properties:

class User extends api.Model {
	get name() {

		return [this.first_name, this.last_name].join(' ')
	}

	setName(value) {

		let name = value.split(' ')
		this._record.patch({
			first_name: name[0],
			last_name: name.shift().join(' ')
		})
	}
}

User.get(11).wait().then((u) => u.setName('Sylvester Stallone'))

If you want to fetch another entity, you can use the following syntax:

class Post extends api.Model {
	static author = User
}

Which is equivalent to:

class Post extends api.Model {
	get author() {

		return User.get(...arrify(this._data.author))
	}
}

This method expects the entity to have a property with the same name (e.g. author) and will use that property to fetch the other entity. The property may also be a composite key (hence the arrify call, which wraps a non-array value in an array).

Collections

Resources are usually tied together by a graph of relationships.

For instance, a post belongs to one and only one user, whereas a user may have authored many posts.

We use Stallone.Collection(ModelType[, options]) to deal with one-to-many and many-to-many relationships:

class User extends api.Model {
	get posts() {

		return api.Collection(Post).in(this)
	}
}

class Post extends api.Model {
	static author = User
}

The Collection class is merely a wrapper around a record and it is capable of iterating over a set of entities. Ideally, the server should send back a list of keys rather than the list of entities, so that Stallone can leverage the record system, but both are ok:

GET /user/1/post
----------------
[1, 5, 2, 3]
or
[
	{
		id: 1,
		...
	},
	{
		id: 5,
		...
	},
	...
]

In both cases, you can iterate over the entities in a collection using the usual syntax:

for (let post of user.posts)
{
	console.log(post.title)
}

However, you may need to wait for the user, the collection and possibly the individual post entities to load before doing that. You can use Collection.wait() to wait for a collection to load:

user.posts.wait().then((posts) => console.log([ ...posts ]))

While the collection is loading, it will behave as an empty array for the sake of iterating:

<!-- This doesn't generate errors, as long as `user` is already loaded -->
<div v-for="post in user.posts" :key"post.id">
	<h2>{{ post.title }}</h2>
</div>

Collections may also be used to handle other type of queries, like fetching all entities of a certain type or performing a search. To fetch an arbitrary collection use Collection.get(path):

let users = api.Collection(User).get(User._path()) // GET /user

You can read Collection.length to get the number of entities in a Collection:

<p>There are {{ users.length }} total users</p>

While loading this property will return null.

Finally, familiar methods, such as forEach, map and reduce, may also available:

users.forEach((u) => console.log(u.username))

Request authorization

When creating a new instance of Stallone you may provide an authorization callback:

let api = new Stallone({
	baseURL: '...',
	authorize: (req) => {}
})

The callback receives the request object (which at the moment is a wrapper around an XMLHttpRequest).

You can set an authorization header using Request.header(name, value):

let authorize = (req) => {

	req.header('Authorization', accessToken)
}

Or set a query parameter with Request.query(query):

let authorize = (req) => {

	req.query({
		token: accessToken
	})
}

Making custom requests

You can use api.Request to create and dispatch requests using the above configuration (base URL + authorization callback):

export const logout = return () => {

	return api.Request('HEAD', '/logout')()
}

First you need to create a request, providing the HTTP method and the path relative to the base URL. The returned object is a callable that returns a Promise itself, that resolves with the result of the request:

// Example of a login method
export const login = async (login, password) => {

	let req = api.Request('POST', '/login')
	let [ res, status ] = await req({ login, password })

	let { access_token: accessToken } = res
	window.localStorage.setItem('access_token', accessToken)
}

You can set headers using header and query parameters using query, as explained before:

let req = api.Request('POST', '/login').header('Content-Type', 'application/json; charset=UTF-8').query({
	rememberme: true
}).header('Something-Else', 'foobar')

When dispatching the request, any type of data not accepted by XMLHttpRequest will be converted to a JSON string.

Stallone automatically decodes the response body using the "Content-Type" header, if present.

Vue integration

Stallone knows how to work with Vue.

To enable the Vue integration, you need to change your import to:

import { Stallone } from '@sneppy/stallone/vue'

All records are reactive, meaning that any change will immediately be reflected in the DOM:

<template>
	<div>
		<p>Welcome, {{ user.username }}!</p>
		<p>email: {{ user.email }}</p>

		<form @submit.prevent="updateUser">
			<input type="text" v-model="user.username" placeholder="username"/>
			<input type="email" v-model="user.username" placeholder="email"/>
			<button>Update info</button>
		</form>
	</div>
</template>

<script>
export default {
	setup() {

		let user = User.get('me')

		const updateUser = () => user.patch()

		return { user, updateUser }
	}
}
</script>

In addition, changes will be reflected on all entities that share the same record. For instance, if the user changes its profile picture this change will be visible everywhere automatically.

Stallone detects automatically if Vue is installed. In the future it may be possible to explicitly disable this feature.

When including Stallone directly with a script tag, make sure it is included after Vue


Check out other libraries:

  • vue-pop is a Vue 3 plugin to manage pop-up windows and notifications.
  • vue-menu is a Vue 3 plugin to create custom context menus.