Complete UI development using only mock API setup
What to do when we reach a state in UI development where we need to interact with APIs, but none exists?
Before anything, we need an API contract from the backend development team. It could be something like an API schema doc or an OpenAPI document.
Ways to advance in UI with API contracts
- The easiest way is to comment out the API call function with some static Mock data. The obvious issue here is we are stuck with one set of static data.
- Use a pre-configured mock server like the JSON server.
This is better than the first approach, gets us started quickly, and supports dynamic data. But API structure is strict and governed by JSON server. It might not be possible to replicate our actual API routes with JSON server. - Use client-side Network interceptor libraries like axios-mock-adapter or Pretender.
What would be Ideal Mocking Setup?
- We should be able to list/create/update/delete data dynamically.
- We should be able to replicate our actual API routes.
- We should have the minimum amount of deviation in our production code from using the Mock setup.
- In short, it should be as close to the real thing as possible.
Mirage
It is written on top of the pretender library and runs in isolation from your production code. It works in your browser, client-side, by intercepting XMLHttpRequest and Fetch requests.
The biggest advantage of Mirage over any other Client-side mocking library is the in-memory database. We can define Models which inherit useful functionalities like create, update, delete records, and querying methods like find, findBy, where, etc.
Also, it has hooks to transform the response data to exactly match your Production APIs response structure.
Basic Mirage Setup Example
Let's assume we have a UI that shows lists of cars and allows the user to add more cars to the list.
So, we have the below information on APIs and schema
GET /api/cars : to fetch list of cars
POST /api/car : to add new car to databaseCar Schema
{
name: <string>
type: <string>
transmission": <string>,
capacity: <number>,
manufacturer: <companyId>
}Company Schema
{
name: <string>
carIds: <array>
}
Let's setup Mirage
Install “miragejs” from npm and import “createServer” method.
This method returns a server instance. When a mirage server instance is created it will start incepting the network requests.
Now we will mock the first API to get the list of cars.
We need one collection for cars in Mirage DB, so we need to let Mirage know about this by adding a model.
And then we add a route for getting the list of cars
import { createServer } from ‘miragejs’;export default createServer({
models: {
car: Model
}, routes() {
this.namespace = “/api”;
this.get(‘cars’, (schema, request) => {
return schema.cars.all();
});
}
});
Now when our application makes a call to /api/cars, it gets intercepted by mirage and it calls the route handler we provided with the API.
this.get(‘cars’, (schema, request) => {
return schema.cars.all();
});
route handler provides two params schema and request. Using schema we can find collections and models in DB. So, when we write “schema.cars.all” we access the cars collection using the schema object and then call the collection method “all” to get the list of all cars.
The request object contains url, body, query params, headers, etc.
Now, If we want to support an API like /api/cars?name=somecar, then we will modify our handler like below
this.get(‘cars’, (schema, request) => {
if(request.queryParams){
return schema.cars.findBy(request.queryParams)
}
return schema.cars.all();
});
Now the response we get is empty because we don't have anything in the car collection yet.
Let's add some initial records
mirage config has seeds property, which we can use to add some records at startup.
import { createServer } from ‘miragejs’;export default createServer({
models: {
car: Model,
company: Model
},seeds(server) {
server.create('car', {
name: 'CAR1',
transmission: 'Manual',
type: 'sedan',
capacity: 5,
manufacturerId: 1
});server.create('car', {
name: 'CAR2',
transmission: 'Automatic',
type: 'hatchback',
capacity: 4,
manufacturerId: 1
})
server.create('company', {
name: 'Tata'
}) }, routes() {
this.namespace = “/api”;
this.get(‘cars’, (schema, request) => {
return schema.cars.all();
});
}
});
Now we get a list of cars when we call /api/cars
{
"cars": [
{
"name": "CAR1",
"transmission": "Manual",
"type": "sedan",
"capacity": 5,
"id": "1"
},
{
"name": "CAR2",
"transmission": "Automatic",
"type": "hatchback",
"capacity": 4,
"id": "2"
}
]
}
Let’s add Manufacturer information in cars API
To add manufacturer, we will extend the car Model object and add manufacturer relationship with a car.
Mirage provides two methods belongsTo and hasMany to define relationships.
Since a car can belong to only one Company we will define it as belongsTo()
models: {
car: Model.extend({
manufacturer: belongsTo('company')
}),
company: Model
},
But if we call /api/cars again we will get the same response without manufacturer information.
Now to get manufacturer information included in the response we will add something called a serializer in our config. A Serializer provides hooks to modify the response of our APIs. Mirage provides a few predefined serializers. We will use RestSerializer.
import { createServer, Model, belongsTo, hasMany, RestSerializer } from ‘miragejs’;export default createServer({
serializers: {
car: RestSerializer.extend({
embed: true,
include: ['manufacturer']
})
},
....})
Now we get manufacturer in response
{
"cars": [
{
"name": "CAR1",
"transmission": "Manual",
"type": "sedan",
"capacity": 5,
"id": "1",
"manufacturer": {
"name": "Honda",
"id": "1"
}
},
{
"name": "CAR2",
"transmission": "Automatic",
"type": "hatchback",
"capacity": 4,
"id": "2",
"manufacturer": {
"name": "Honda",
"id": "1"
}
}
]
}
Let’s modify the response to match our production APIs
Serializer provides a serialize hook, which we can use to modify the response to match out production API response structure.
For example, let us assume our APIs return response like below
{
data:[{
name:'CAR1',
"transmission": "Automatic",
"type": "hatchback",
"capacity": 4,
...
}],
metadata: {}
}
So, now we will implement serialize method
import { createServer, Model, belongsTo, hasMany, RestSerializer } from ‘miragejs’;export default createServer({
serializers: {
car: RestSerializer.extend({
embed: true,
include: ['manufacturer'],
serialize(object, request) {
// call base call method
let json = RestSerializer.prototype.serialize.apply(this, arguments);
return {
metadata: {},
data: json[object.modelName] || json[this._container.inflector.pluralize(object.modelName)]
}
}
})
},
....})
Now we get the below response as desired
{
"metadata": {},
"data": [
{
"name": "CAR1",
"transmission": "Manual",
"type": "sedan",
"capacity": 5,
"id": "1",
"manufacturer": {
"name": "Honda",
"id": "1"
}
},
{
"name": "CAR2",
"transmission": "Automatic",
"type": "hatchback",
"capacity": 4,
"id": "2",
"manufacturer": {
"name": "Honda",
"id": "1"
}
}
]
}
Final code (you can visit https://miragejs.com/repl/ and run this)
import { createServer, Model, belongsTo, hasMany, RestSerializer } from 'miragejs';const AppSerializer = RestSerializer
.extend({
attrs:['name', 'type'],
serialize(object, request) {
console.log(object.modelName)
let json = RestSerializer.prototype.serialize.apply(this, arguments); return {
metadata: {},
data: json[object.modelName] || json[this._container.inflector.pluralize(object.modelName)]
}
}
})export default
createServer({
serializers: {
car: AppSerializer.extend({
embed: true,
include: ['manufacturer']
})
}, models: {
car: Model.extend({
manufacturer: belongsTo('company')
}),
company: Model.extend({
car: hasMany()
})
}, seeds(server) {
server.create('company', {
name: 'Honda',
id:1
})
server.schema.cars.create({
name: 'CAR1',
transmission: 'Manual',
type: 'sedan',
capacity: 5,
manufacturerId: 1
}); server.create('car', {
name: 'CAR2',
transmission: 'Automatic',
type: 'hatchback',
capacity: 4,
manufacturerId: 1
})
}, routes() {
this.namespace = "/api";
this.get('cars', (schema, request) => {
const qParams = request.queryParams;
if(Object.keys(qParams).length) {
return schema.cars.findBy(qParams);
}
return schema.cars.all();
}); this.get('/car/:id', (schema, request) => {
const id = request.params.id;
return schema.cars.find(id);
});
this.post('/car', (schema, request) => {
const body = JSON.parse(request.requestBody)
return schema.cars.create(body);
})
this.get('/companies', (schema, request) => {
return schema.companies.all();
})
this.post('/company', (schema, request) => {
const body = JSON.parse(request.requestBody)
return schema.companies.create(body);
}) this.passthrough();
}
});
Visit https://miragejs.com/ to learn more functionalities of Mirage. This just covers the necessary basics.
PS: When we develop features using this methodology, we end up with a set of UI features that work without backend support, and this is really useful for delivering predictable Presentation/Demo.