DEV Community

Cover image for Playing the API We're Dealt
Ryan Silva
Ryan Silva

Posted on

Playing the API We're Dealt

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);
  });
});
Enter fullscreen mode Exit fullscreen mode

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);
        }
      });
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

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());
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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(/*...*/);
  }
}
Enter fullscreen mode Exit fullscreen mode

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)