Awwwards
Write a Decent API Already!'s Hero Image

Write a Decent API Already!

So you're writing an API. First off, go get Phil Sturgeon's book and read it cover to cover. It will absolutely save you time in the long run. I'm also assuming you're familiar with PHP and Laravel, but this stuff translates to other languages and frameworks.

Next, begin planning your endpoints. Let's say we're building some sort of movie database that lives on the internet, and we want to expose the API. You might do something like this:

Route::get('movies', 'MoviesController@index');
Route::post('movies', 'MoviesController@store');
Route::get('movies/{movie_id}', 'MoviesController@show');
Route::patch('movies/{movie_id}', 'MoviesController@update');
Route::delete('movies/{movie_id}', 'MoviesController@destroy');

Movies Controller Test

First off, you should really be testing each feature that you implement. A clear approach for this is to do test driven development because it helps you identify incorrect assumptions as you’re developing.

So, your test would look something like this:

public function test_can_get_all_movies()
{
    $movie = factory(App\Movie::class, 2)->create();
	
    $this->get('movies', ['accept' => 'application/json']);
    $this->assertResponseOk();
    $this->seeJsonStructure(['data' => [0, 1]])'
}

Here, I'm using Laravel's built in factory function to create 2 dummy Movie objects. Then, I make a GET request to the endpoint we've already defined and telling the server that I should get a valid JSON response back. Lastly, I'm asserting that I receive a HTTP status code of 200 and that the JSON is formatted so that the data element has two elements, one for each of the dummy Movie objects.

I like using the seeJsonStructure assertion because it allows me to verify that the data was returned in the way I expect. For instance, the assertion seeJsonContains(['name' => 'Movie Title']) doesn't tell the whole story. Was this in a collection of 2 elements? Was it returned by itself? Was it "included" with another object that mistakenly got sent back? Contains is a great way to verify granular things like the movie title got updated, but Structure is helpful to ensure that your API consumers won't be surprised.

Movies Controller

Now that we have a successful test, how do we implement it? I'm going to skip a few steps so that I'm not stuck in the TDD loop of

test
-> red

code
test
-> green

refactor
test
-> green

This is what the MoviesController index function might look like:

public function index(Request $request)
{
    $movies = Movie::when($request->has('genre'), function ($query) {
        return $query->where('genre', $request->input('genre'));
    })
    ->when($request->has('year'), function ($query) {
        return $query->where('year', $request->input('year'));
    })
    ->get()
    ->sortBy('ranking');
	
    if($movies->isEmpty()) {
        return $this->errorNotFound('Movies Not Found');
    }
	
    return $this->respondWithCollection($movies, new MovieTransformer);
}

Now what's this MovieTransformer thing you're seeing? Thanks to the incredible work by Phil Sturgeon and the guys at the PHP League, we have a really great template for how to write clear and consistent APIs. It's a package called Fractal and it helps you "output complex, flexible, Ajax/RESTful data structures".

I created a package to help automate the creation of the Transformer files that feels native to Laravel. Learn more about it here.

Here's what the MovieTransformer might look like:

public function transform(Movie $movie)
{
    return [
        'id' => $movie->id,
        'title' => $movie->title,
        'year' => $movie->year,
        'ranking' => $movie->complicatedAlgorithmToCalculateTomatoesThumbsAndStarsForAggregateRanking(),
        'links' => [
				    [
                'rel' => 'self',
                'uri' => '/movies/' . $movie->id,
            ],
        ],
    ];
}

Now you may be asking yourself: why can’t I just fetch the models from the database, use a toArray function on them and call it a day? One, you might expose sensitive data to your API consumers. Two, you necessarily change the implementation when you change an internal data structure. For instance, when you first started this database, your ranking was 0, 1, or 2 thumbs up. But then you started keeping track of how many tomatoes and stars each movie received and you began calculating an aggregate score of all these measures. Instead of changing the API to have ranking, tomatoes and stars, you can use the transform function to aggregate and calculate the new ranking without changing how your consumers use your API.

Now what about actors? We should probably make those endpoints, too. But, they could also be directors, or producers, or writers. Maybe we should just call them people.

Route::get('people', 'PeopleController@index');
Route::post('people', 'PeopleController@store');
Route::get('people/{person_id}', 'PeopleController@show');
Route::patch('people/{person_id}', 'PeopleController@update');
Route::delete('people/{person_id}', 'PeopleController@destroy');

This is pretty self-explanatory and follows the same format for Movies mentioned above so I'm not going into detail about it here.

I do, however, want to call attention to a design decision that I encourage you to explore. We need to associate certain people as actors, directors, producers, or writers in a movie. I think the best way would be to implement a many-to-many pivot table that tells me the Person, the Movie, and their role.

Schema::create('movie_person', function(Blueprint $table) {
    $table->unsignedInteger('person_id')->index();
    $table->foreign('person_id')->references('id')->on('people');

    $table->unsignedInteger('movie_id')->index();
    $table->foreign('movie_id')->references('id')->on('movies');

    $table->string('role');

    $table->index(['person_id', 'movie_id', 'role']);
});

You could do:

Route::post('people/{person_id}/movies/{movie_id}', 'PeopleController@attachMovie'); 

// Or would it be MoviesController@attachPerson ??

But I would wager that this is better:

Route::post('actors', 'ActorsController@store'); 
// where payload is [$person_id => 1, $movie_id => 1, 'role' => 'actor']

Route::delete('actors', 'ActorsController@destroy'); 

Route::post('directors', 'DirectorsController@store'); 
// where payload is [$person_id => 1, $movie_id => 1, 'role' => 'director']

Route::delete('directors', 'DirectorsController@destroy'); 

Here you have a single responsibility for this controller. It either attaches or detaches people from movies. It also helps to reduce the amount of gymnastics you have to perform when you're coming back to maintain the code (Do I put person first? or movie? which controller is that in? etc.). Trust me, your future self will thank you when you have a controller that handles Actors instead of having a bloated PeopleController.

Now we can hop back into the MovieTransformer and tell Fractal that we want to include actors and directors. The cool thing about Fractal? We can add embedded or included data without touching the transform function.

protected $availableIncludes = [
    'actors',
    'director',
];

public function includeActors(Movie $movie)
{
    $actors = $movie->actors;
    //$actors = $movie->people()->wherePivot('role', 'actor')->get();
	
    return $this->collection($actors, new PersonTransformer);
}

public function includeDirector(Movie $movie)
{
    $director = $movie->director;
    //$director = $movie->people()->wherePivot('role', 'director')->get();
	
    return $this->item($director, new PersonTransformer);
}

How cool is that? Fractal will interpret the ?include=actors,director from the query string and make sure it gets embedded in the Movie data! And, it'll make sure it goes through its own transformation so that you don't have to worry about it when transforming Movies.

And we can tell PersonTransformer to include the movies that a person has acted in or directed.

protected $availableIncludes = [
    'actedIn',
    'directed',
];

public function includeActedIn(Person $person)
{
    $movies = $person->actedIn;
    //$movies = $person->movies()->wherePivot('role', 'actor')->get();
	
    return $this->collection($movies, new MovieTransformer);
}

public function includeDirected(Person $person)
{
    $movies = $person->directed;
    //$movies = $person->movies()->wherePivot('role', 'director')->get();
	
    return $this->collection($movies, new MovieTransformer);
}

Pretty neat, huh?

There is a ton of stuff just like this in Phil's book, and some nuance within the Fractal documentation. This covers just a tiny fraction of what it means to plan, develop, and implement a robust API, but I hope it helps you start off on the right foot.


Patrick Guevara's Profile Picture

Patrick Guevara


Chief Software Engineer

Patrick cofounded Metric Loop on the dream of building really great software with a clean, transparent approach. He lives in Austin, Texas with his wife Jess and their dog named Moose.