It's not about the API you're dealt, but how you play the hand
When writing web applications, sometimes we aren't dealt the API we want. Either the "RESTful" APIs are anything but, or the data is sent in a weird format, or we might have to support requests that don't fit into a REST model. When we can't change our hand, our job is to make the UI code functional without becoming an unmaintainable mess.
My approach to working with the API follows the "thin controllers, fat services" methodology. I want all business logic and data manipulation to happen in a service, abstracted behind sensible, predictable, and easily testable methods. Following are some ways that I've played my not-quite-ideal hand. I'm using Angular, but these ideas are not framework-specific.
Mixing Up the Payload
The most common situation I encounter with my APIs is that I want to change the payload structure. Sometimes I want to initially sort a collection because the API wouldn't sort it for me, or I simply need to rename a property. In other cases, I've needed to extract a completely structure from the API response.
To address this, I borrowed a convention from BackboneJS and I implement a parse
method, which takes raw response data and returns the object's desired data. I have a common REST service which uses this method. Everyone working on our codebase knows that any incoming data manipulation happens in parse
, which provides predictability and consistency. With clear input and output requirements, parse
is also easy to unit test.
The logic in parse
should never go in the controller. We want the data representation to be altered only once when it arrives and remain consistent throughout different views. If one controller rearranges the data, then we risk regressions if that controller is ever removed and other code depended on the new format.
The following snippet shows the service, controller, and unit tests for a basic API call that needs some data sorted and a field reassigned after being fetched:
//MyResource will store the data fetched from the API
class MyResource {
get url() {
return '/api/my_resource';
}
//Incoming data will have its list sorted and a description field set
parse(data) {
if (data) {
if (data.list) {
data.list = data.list.sort();
} else {
data.list = [];
}
data.description = data.notes;
}
return data;
}
}
//An abbreviated example of a common REST service
function restServiceFactory($http) {
class RestService {
//Perform a GET of the resource
fetch(resource) {
return $http.GET(resource.url).then(resp => {
//First run the data through parse
let data = resource.parse(resp.data);
//Is this a collection? Update the array with the response data
if (isArray(resource)) {
resource.splice(0, resource.length, ...data);
}
//It's an object; update it with the response data
else {
angular.extend(resource, data);
}
return resource;
});
}
}
return new RestService();
}
angular.module('my.module', []).factory('restService', restServiceFactory);
//Example controller which fetches MyResource
class MyController {
constructor(restService) {
this.resource = new MyResource();
restService.fetch(this.resource);
}
}
//Unit tests for MyResource
describe('MyResource test', function() {
it('initializes list', function() {
let resource = new MyResource();
expect(resource.parse({}).list).toEqual([]);
});
it('sorts the list', function() {
let resource = new MyResource(),
list = ["pear", "apple", "banana"];
expect(resource.parse({list}).list).toEqual(["apple", "banana", "pear"]);
});
it('sets description field from notes', function() {
let resource = new MyResource(),
notes = "some notes";
expect(resource.parse({notes}).description).toEqual(notes);
});
});
Occasionally I need to extract an entirely different structure than what the API provides. Let's say I have a book list API. My app actually needs a list of authors, but that API doesn't exist. I might be tempted to do something like this:
class BookCollection extends Array {
get url() {
return '/api/books';
}
}
class AuthorListController {
constructor(restService) {
//Fetch list of books, then extract the author list from it
restService.fetch(new BookCollection()).then(books => {
let authorsMap = {};
this.authors = [];
books.forEach(book => {
if (!authorsMap[book.author.id]) {
authorsMap[book.author.id] = true;
this.authors.push(book.author);
}
});
});
}
}
But now the controller is fatter, and this logic can't be reused by other views. This would be far better:
class BookCollection extends Array {
get url() {
return '/api/books';
}
getAuthors() {
let authorsMap = {}, authors = [];
this.forEach(book => {
if (!authorsMap[book.author.id]) {
authorsMap[book.author.id] = true;
authors.push(book.author);
}
});
return authors;
}
}
class AuthorListController {
constructor(restService) {
restService.fetch(new BookCollection())
.then(books => this.authors = books.getAuthors());
}
}
Now the logic is hidden in the service, and anyone with a book collection can get the authors list. But what if the app is primarily focused on authors, and we don't ever need a book collection? By using the parse method, we can change the service to better express our objective—we can deal with a collection of authors from the beginning:
class AuthorCollection extends Array {
get url() {
return '/api/books';
}
//API input is a list of books
parse(data) {
let authorsMap = {}, authors = [];
this.forEach(book => {
if (!authorsMap[book.author.id]) {
authorsMap[book.author.id] = true;
authors.push(book.author);
}
});
return authors;
}
}
class AuthorListController {
constructor(restService) {
restService.fetch(new AuthorCollection())
.then(authors => this.authors = authors);
}
}
Now the controllers can deal solely with author collections, and the fact that it has to use a different API to get the data is completely transparent.
Custom or Non-RESTful Requests
The previous examples show how to manipulate data on basic GETs—they can all use the same fetch
method to perform the request. But what if the request is non-RESTful, uses an unexpected verb, or needs to be customized in some special way? I extend the default RestService
as a new service and add or override methods as needed. The service should always have readable, obvious methods that describe what they're going to do.
Overriding Service Methods
If we're already using fetch
to do GETs, then it makes sense to override fetch
if we need to do something different when downloading a resource. For example, if I want to get the list of books, but I want to use a query parameter to sort the collection first:
class RestService {
//fetch accepts httpOptions
fetch(resource, httpOptions) {
return $http.GET(resource.url, httpOptions).then(/*...*/);
}
}
class BookService extends RestService {
//Override the default fetch, passing sortBy=name query parameter
fetch(resource) {
return super.fetch(resource, {params: {sortBy: 'name'}});
}
}
class BookListController {
constructor(restService) {
restService.fetch(new BookCollection())
//Already sorted by name
.then(books => this.books = books);
}
}
How about when the API developer insists that updating a book should be done as a POST to /books
instead of a conventional PUT to /books/{id}
? You can imagine that RestService has a save
method which normally does a PUT to the resource URI. So we override save
:
class RestService {
//Typically, save does a PUT to the resource URI
save(resource) {
return $http.PUT(resource.url, resource).then(/*...*/);
}
}
class BookService extends RestService {
save(book) {
return $http.POST(book.collectionUrl).then(/*...*/);
}
}
class BookController {
/*...*/
save() {
this.restService.save(this.book);
}
}
There are many possibilities for customization, but the main point is the controller can be ignorant of the fact that this endpoint is weird. It just calls the same save
method that it always calls.
Custom Service Methods
Sometimes we need to do something that isn't RESTful and doesn't fit into our model. Suppose a book URI includes the author: /authors/{authorId}/books/{bookId}
. We want to change a book's author, which means a new book URI and some updated author collections. Our API provides a separate endpoint to reassign books to new authors: a POST to /authors/{authorId}/books/{bookId}/reassign
. We can handle this with a new service method:
class BookService extends RestService {
reassign(book) {
return $http.POST(`${book.url}/reassign`).then(/*...*/);
}
}
Similar to the custom save
and fetch
, this new method provides new functionality for a book. Any complexity involved in reassigning a book should be hidden here, so the controller doesn't need to know about it.
When we have to play the API we've been dealt instead of the API we want, it takes some thought to keep the UI code in good shape. By confining all special API logic in the service layer, we keep the controllers simpler, reduce regressions, and maintain testability. What are some ways you've played your hand?
Top comments (0)