Testing your API documentation with Dredd

Published by at 8th August 2016 4:05 pm

Documenting your API is something most developers agree is generally a Good Thing, but it's a pain in the backside, and somewhat boring to do. What you really need is a tool that allows you to specify the details of your API before you start work, generate documentation from that specification, and test your implementation against that specification.

Fortunately, such a tool exists. The Blueprint specification allows you to document your API using a Markdown-like syntax. You can then create HTML documentation using a tool like Aglio or Apiary, and test it against your implementation using Dredd.

In this tutorial we'll implement a very basic REST API using the Lumen framework. We'll first specify our API, then we'll implement routes to match the implementation. In the process, we'll demonstrate the Blueprint specification in action.

Getting started

Assuming you already have PHP 5.6 or better and Composer installed, run the following command to create our Lumen app skeleton:

$ composer create-project --prefer-dist laravel/lumen demoapi

Once it has finished installing, we'll also need to add the Dredd hooks:

1$ cd demoapi
2$ composer require ddelnano/dredd-hooks-php

We need to install Dredd. It's a Node.js tool, so you'll need to have that installed. We'll also install Aglio to generate HTML versions of our documentation:

$ npm install -g aglio dredd

We also need to create a configuration file for Dredd, which you can do by running dredd init. Or you can just copy the one below:

1dry-run: null
2hookfiles: null
3language: php
4sandbox: false
5server: 'php -S localhost:3000 -t public/'
6server-wait: 3
7init: false
8custom:
9 apiaryApiKey: ''
10names: false
11only: []
12reporter: apiary
13output: []
14header: []
15sorted: false
16user: null
17inline-errors: false
18details: false
19method: []
20color: true
21level: info
22timestamp: false
23silent: false
24path: []
25hooks-worker-timeout: 5000
26hooks-worker-connect-timeout: 1500
27hooks-worker-connect-retry: 500
28hooks-worker-after-connect-wait: 100
29hooks-worker-term-timeout: 5000
30hooks-worker-term-retry: 500
31hooks-worker-handler-host: localhost
32hooks-worker-handler-port: 61321
33config: ./dredd.yml
34blueprint: apiary.apib
35endpoint: 'http://localhost:3000'

If you choose to run dredd init, you'll see prompts for a number of things, including:

  • The server command
  • The blueprint file name
  • The endpoint
  • Any Apiary API key
  • The language you want to use

There are Dredd hooks for many languages, so if you're planning on building a REST API in a language other than PHP, don't worry - you can still test it with Dredd, you'll just get prompted to install different hooks.

Note the hookfiles section, which specifies a hookfile to run during the test in order to set up the API. We'll touch on that in a moment. Also, note the server setting - this specifies the command we should call to run the server. In this case we're using the PHP development server.

If you're using Apiary with your API (which I highly recommend), you can also set the following parameter to ensure that every time you run Dredd, it submits the results to Apiary:

1custom:
2 apiaryApiKey: <API KEY HERE>
3 apiaryApiName: <API NAME HERE>

Hookfiles

As mentioned, the hooks allow you to set up your API. In our case, we'll need to set up some fixtures for our tests. Save this file at tests/dredd/hooks/hookfile.php:

1<?php
2
3use Dredd\Hooks;
4use Illuminate\Support\Facades\Artisan;
5
6require __DIR__ . '/../../../vendor/autoload.php';
7
8$app = require __DIR__ . '/../../../bootstrap/app.php';
9
10$app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
11
12Hooks::beforeAll(function (&$transaction) use ($app) {
13 putenv('DB_CONNECTION=sqlite');
14 putenv('DB_DATABASE=:memory:');
15 Artisan::call('migrate:refresh');
16 Artisan::call('db:seed');
17});
18Hooks::beforeEach(function (&$transaction) use ($app) {
19 Artisan::call('migrate:refresh');
20 Artisan::call('db:seed');
21});

Before the tests run, we set the environment up to use an in-memory SQLite database. We also migrate and seed the database, so we're working with a clean database. As part of this tutorial, we'll create seed files for the fixtures we need in the database.

This hookfile assumes that the user does not need to be authenticated to communicate with the API. If that's not the case for your API, you may want to include something like this in your hookfile's beforeEach callback:

1 $user = App\User::first();
2 $token = JWTAuth::fromUser($user);
3 $transaction->request->headers->Authorization = 'Bearer ' . $token;

Here we're using the JWT Auth package for Laravel to authenticate users of our API, and we need to set the Authorization header to contain a valid JSON web token for the given user. If you're using a different method, such as HTTP Basic authentication, you'll need to amend this code to reflect that.

With that done, we need to create the Blueprint file for our API. Recall the following line in dredd.yml:

blueprint: apiary.apib

This specifies the path to our documentation. Let's create that file:

$ touch apiary.apib

Once this is done, you should be able to run Dredd:

1$ dredd
2info: Configuration './dredd.yml' found, ignoring other arguments.
3info: Using apiary reporter.
4info: Starting server with command: php -S localhost:3000 -t public/
5info: Waiting 3 seconds for server command to start...
6warn: Parser warning in file 'apiary.apib': (warning code undefined) Could not recognize API description format. Falling back to API Blueprint by default.
7info: Beginning Dredd testing...
8complete: Tests took 619ms
9complete: See results in Apiary at: https://app.apiary.io/public/tests/run/4aab4155-cfc4-4fda-983a-fea280933ad4
10info: Sending SIGTERM to the backend server
11info: Backend server was killed

With that done, we're ready to start work on our API.

Our first route

Dredd is not a testing tool in the usual sense. Under no circumstances should you use it as a substitute for something like PHPUnit - that's not what it's for. It's for ensuring that your documentation and your implementation remain in sync. However, it's not entirely impractical to use it as a Behaviour-driven development tool in the same vein as Cucumber or Behat - you can use it to plan out the endpoints your API will have, the requests they accept, and the responses they return, and then verify your implementation against the documentation.

We will only have a single endpoint, in order to keep this tutorial as simple and concise as possible. Our endpoint will expose products for a shop, and will allow users to fetch, create, edit and delete products. Note that we won't be implementing any kind of authentication, which in production is almost certainly not what you want - we're just going for the simplest possible implementation.

First, we'll implement getting a list of products:

1FORMAT: 1A
2
3# Demo API
4
5# Products [/api/products]
6Product object representation
7
8## Get products [GET /api/products]
9Get a list of products
10
11+ Request (application/json)
12
13+ Response 200 (application/json)
14 + Body
15
16 {
17 "id": 1,
18 "name": "Purple widget",
19 "description": "A purple widget",
20 "price": 5.99,
21 "attributes": {
22 "colour": "Purple",
23 "size": "Small"
24 }
25 }

A little explanation is called for. First the FORMAT section denotes the version of the API. Then, the # Demo API section denotes the name of the API.

Next, we define the Products endpoint, followed by our first method. Then we define what should be contained in the request, and what the response should look like. Blueprint is a little more complex than that, but that's sufficient to get us started.

Then we run dredd again:

1$ dredd.yml
2info: Configuration './dredd.yml' found, ignoring other arguments.
3info: Using apiary reporter.
4info: Starting server with command: php -S localhost:3000 -t public/
5info: Waiting 3 seconds for server command to start...
6info: Beginning Dredd testing...
7fail: GET /api/products duration: 61ms
8info: Displaying failed tests...
9fail: GET /api/products duration: 61ms
10fail: headers: Header 'content-type' has value 'text/html; charset=UTF-8' instead of 'application/json'
11body: Can't validate real media type 'text/plain' against expected media type 'application/json'.
12statusCode: Status code is not '200'
13
14request:
15method: GET
16uri: /api/products
17headers:
18 Content-Type: application/json
19 User-Agent: Dredd/1.5.0 (Linux 4.4.0-31-generic; x64)
20
21body:
22
23
24
25expected:
26headers:
27 Content-Type: application/json
28
29body:
30{
31 "id": 1,
32 "name": "Purple widget",
33 "description": "A purple widget",
34 "price": 5.99,
35 "attributes": {
36 "colour": "Purple",
37 "size": "Small"
38 }
39}
40statusCode: 200
41
42
43actual:
44statusCode: 404
45headers:
46 host: localhost:3000
47 connection: close
48 x-powered-by: PHP/7.0.8-0ubuntu0.16.04.2
49 cache-control: no-cache
50 date: Mon, 08 Aug 2016 10:30:33 GMT
51 content-type: text/html; charset=UTF-8
52
53body:
54<!DOCTYPE html>
55<html>
56 <head>
57 <meta name="robots" content="noindex,nofollow" />
58 <style>
59 /* Copyright (c) 2010, Yahoo! Inc. All rights reserved. Code licensed under the BSD License: http://developer.yahoo.com/yui/license.html */
60 html{color:#000;background:#FFF;}body,div,dl,dt,dd,ul,ol,li,h1,h2,h3,h4,h5,h6,pre,code,form,fieldset,legend,input,textarea,p,blockquote,th,td{margin:0;padding:0;}table{border-collapse:collapse;border-spacing:0;}fieldset,img{border:0;}address,caption,cite,code,dfn,em,strong,th,var{font-style:normal;font-weight:normal;}li{list-style:none;}caption,th{text-align:left;}h1,h2,h3,h4,h5,h6{font-size:100%;font-weight:normal;}q:before,q:after{content:'';}abbr,acronym{border:0;font-variant:normal;}sup{vertical-align:text-top;}sub{vertical-align:text-bottom;}input,textarea,select{font-family:inherit;font-size:inherit;font-weight:inherit;}input,textarea,select{*font-size:100%;}legend{color:#000;}
61 html { background: #eee; padding: 10px }
62 img { border: 0; }
63 #sf-resetcontent { width:970px; margin:0 auto; }
64 .sf-reset { font: 11px Verdana, Arial, sans-serif; color: #333 }
65 .sf-reset .clear { clear:both; height:0; font-size:0; line-height:0; }
66 .sf-reset .clear_fix:after { display:block; height:0; clear:both; visibility:hidden; }
67 .sf-reset .clear_fix { display:inline-block; }
68 .sf-reset * html .clear_fix { height:1%; }
69 .sf-reset .clear_fix { display:block; }
70 .sf-reset, .sf-reset .block { margin: auto }
71 .sf-reset abbr { border-bottom: 1px dotted #000; cursor: help; }
72 .sf-reset p { font-size:14px; line-height:20px; color:#868686; padding-bottom:20px }
73 .sf-reset strong { font-weight:bold; }
74 .sf-reset a { color:#6c6159; cursor: default; }
75 .sf-reset a img { border:none; }
76 .sf-reset a:hover { text-decoration:underline; }
77 .sf-reset em { font-style:italic; }
78 .sf-reset h1, .sf-reset h2 { font: 20px Georgia, "Times New Roman", Times, serif }
79 .sf-reset .exception_counter { background-color: #fff; color: #333; padding: 6px; float: left; margin-right: 10px; float: left; display: block; }
80 .sf-reset .exception_title { margin-left: 3em; margin-bottom: 0.7em; display: block; }
81 .sf-reset .exception_message { margin-left: 3em; display: block; }
82 .sf-reset .traces li { font-size:12px; padding: 2px 4px; list-style-type:decimal; margin-left:20px; }
83 .sf-reset .block { background-color:#FFFFFF; padding:10px 28px; margin-bottom:20px;
84 -webkit-border-bottom-right-radius: 16px;
85 -webkit-border-bottom-left-radius: 16px;
86 -moz-border-radius-bottomright: 16px;
87 -moz-border-radius-bottomleft: 16px;
88 border-bottom-right-radius: 16px;
89 border-bottom-left-radius: 16px;
90 border-bottom:1px solid #ccc;
91 border-right:1px solid #ccc;
92 border-left:1px solid #ccc;
93 }
94 .sf-reset .block_exception { background-color:#ddd; color: #333; padding:20px;
95 -webkit-border-top-left-radius: 16px;
96 -webkit-border-top-right-radius: 16px;
97 -moz-border-radius-topleft: 16px;
98 -moz-border-radius-topright: 16px;
99 border-top-left-radius: 16px;
100 border-top-right-radius: 16px;
101 border-top:1px solid #ccc;
102 border-right:1px solid #ccc;
103 border-left:1px solid #ccc;
104 overflow: hidden;
105 word-wrap: break-word;
106 }
107 .sf-reset a { background:none; color:#868686; text-decoration:none; }
108 .sf-reset a:hover { background:none; color:#313131; text-decoration:underline; }
109 .sf-reset ol { padding: 10px 0; }
110 .sf-reset h1 { background-color:#FFFFFF; padding: 15px 28px; margin-bottom: 20px;
111 -webkit-border-radius: 10px;
112 -moz-border-radius: 10px;
113 border-radius: 10px;
114 border: 1px solid #ccc;
115 }
116 </style>
117 </head>
118 <body>
119 <div id="sf-resetcontent" class="sf-reset">
120 <h1>Sorry, the page you are looking for could not be found.</h1>
121 <h2 class="block_exception clear_fix">
122 <span class="exception_counter">1/1</span>
123 <span class="exception_title"><abbr title="Symfony\Component\HttpKernel\Exception\NotFoundHttpException">NotFoundHttpException</abbr> in <a title="/home/matthew/Projects/demoapi/vendor/laravel/lumen-framework/src/Concerns/RoutesRequests.php line 450" ondblclick="var f=this.innerHTML;this.innerHTML=this.title;this.title=f;">RoutesRequests.php line 450</a>:</span>
124 <span class="exception_message"></span>
125 </h2>
126 <div class="block">
127 <ol class="traces list_exception">
128 <li> in <a title="/home/matthew/Projects/demoapi/vendor/laravel/lumen-framework/src/Concerns/RoutesRequests.php line 450" ondblclick="var f=this.innerHTML;this.innerHTML=this.title;this.title=f;">RoutesRequests.php line 450</a></li>
129 <li>at <abbr title="Laravel\Lumen\Application">Application</abbr>->handleDispatcherResponse(<em>array</em>('0')) in <a title="/home/matthew/Projects/demoapi/vendor/laravel/lumen-framework/src/Concerns/RoutesRequests.php line 387" ondblclick="var f=this.innerHTML;this.innerHTML=this.title;this.title=f;">RoutesRequests.php line 387</a></li>
130 <li>at <abbr title="Laravel\Lumen\Application">Application</abbr>->Laravel\Lumen\Concerns\{closure}() in <a title="/home/matthew/Projects/demoapi/vendor/laravel/lumen-framework/src/Concerns/RoutesRequests.php line 636" ondblclick="var f=this.innerHTML;this.innerHTML=this.title;this.title=f;">RoutesRequests.php line 636</a></li>
131 <li>at <abbr title="Laravel\Lumen\Application">Application</abbr>->sendThroughPipeline(<em>array</em>(), <em>object</em>(<abbr title="Closure">Closure</abbr>)) in <a title="/home/matthew/Projects/demoapi/vendor/laravel/lumen-framework/src/Concerns/RoutesRequests.php line 389" ondblclick="var f=this.innerHTML;this.innerHTML=this.title;this.title=f;">RoutesRequests.php line 389</a></li>
132 <li>at <abbr title="Laravel\Lumen\Application">Application</abbr>->dispatch(<em>null</em>) in <a title="/home/matthew/Projects/demoapi/vendor/laravel/lumen-framework/src/Concerns/RoutesRequests.php line 334" ondblclick="var f=this.innerHTML;this.innerHTML=this.title;this.title=f;">RoutesRequests.php line 334</a></li>
133 <li>at <abbr title="Laravel\Lumen\Application">Application</abbr>->run() in <a title="/home/matthew/Projects/demoapi/public/index.php line 28" ondblclick="var f=this.innerHTML;this.innerHTML=this.title;this.title=f;">index.php line 28</a></li>
134 </ol>
135</div>
136
137 </div>
138 </body>
139</html>
140
141
142
143complete: 0 passing, 1 failing, 0 errors, 0 skipped, 1 total
144complete: Tests took 533ms
145[Mon Aug 8 11:30:33 2016] 127.0.0.1:44472 [404]: /api/products
146complete: See results in Apiary at: https://app.apiary.io/public/tests/run/0153d5bf-6efa-4fdb-b02a-246ddd75cb14
147info: Sending SIGTERM to the backend server
148info: Backend server was killed

Our route is returning HTML, not JSON, and is also raising a 404 error. So let's fix that. First, let's create our Product model at app/Product.php:

1<?php
2
3namespace App;
4
5use Illuminate\Database\Eloquent\Model;
6
7class Product extends Model
8{
9 //
10}

Next, we need to create a migration for the database tables for the Product model:

1$ php artisan make:migration create_product_table
2Created Migration: 2016_08_08_105737_create_product_table

This will create a new file under database/migrations. Open this file and paste in the following:

1<?php
2
3use Illuminate\Database\Schema\Blueprint;
4use Illuminate\Database\Migrations\Migration;
5
6class CreateProductTable extends Migration
7{
8 /**
9 * Run the migrations.
10 *
11 * @return void
12 */
13 public function up()
14 {
15 // Create products table
16 Schema::create('products', function (Blueprint $table) {
17 $table->increments('id');
18 $table->string('name');
19 $table->text('description');
20 $table->float('price');
21 $table->json('attributes');
22 $table->timestamps();
23 });
24 }
25
26 /**
27 * Reverse the migrations.
28 *
29 * @return void
30 */
31 public function down()
32 {
33 // Drop products table
34 Schema::drop('products');
35 }
36}

Note that we create fields that map to the attributes our API exposes. Also, note the use of the JSON field. In databases that support it, like PostgreSQL, it uses the native JSON support, otherwise it works like a text field. Next, we run the migration to create the table:

1$ php artisan migrate
2Migrated: 2016_08_08_105737_create_product_table

With our model done, we now need to ensure that when Dredd runs, there is some data in the database, so we'll create a seeder file at database/seeds/ProductSeeder:

1<?php
2
3use Illuminate\Database\Seeder;
4use Carbon\Carbon;
5
6class ProductSeeder extends Seeder
7{
8 /**
9 * Run the database seeds.
10 *
11 * @return void
12 */
13 public function run()
14 {
15 // Add product
16 DB::table('products')->insert([
17 'name' => 'Purple widget',
18 'description' => 'A purple widget',
19 'price' => 5.99,
20 'attributes' => json_encode([
21 'colour' => 'purple',
22 'size' => 'Small'
23 ]),
24 'created_at' => Carbon::now(),
25 'updated_at' => Carbon::now(),
26 ]);
27 }
28}

You also need to amend database/seeds/DatabaseSeeder to call it:

1<?php
2
3use Illuminate\Database\Seeder;
4
5class DatabaseSeeder extends Seeder
6{
7 /**
8 * Run the database seeds.
9 *
10 * @return void
11 */
12 public function run()
13 {
14 $this->call('ProductSeeder');
15 }
16}

I found I also had to run the following command to find the new seeder:

$ composer dump-autoload

Then, call the seeder:

1$ php artisan db:seed
2Seeded: ProductSeeder

We also need to enable Eloquent, as Lumen disables it by default. Uncomment the following line in bootstrap/app.php:

$app->withEloquent();

With that done, we can move onto the controller.

Creating the controller

Create the following file at app/Http/Controllers/ProductController:

1<?php
2
3namespace App\Http\Controllers;
4
5use Illuminate\Http\Request;
6
7use App\Product;
8
9class ProductController extends Controller
10{
11 private $product;
12
13 public function __construct(Product $product) {
14 $this->product = $product;
15 }
16
17 public function index()
18 {
19 // Get all products
20 $products = $this->product->all();
21
22 // Send response
23 return response()->json($products, 200);
24 }
25}

This implements the index route. Note that we inject the Product instance into the controller. Next, we need to hook it up in app/Http/routes.php:

1<?php
2
3/*
4|--------------------------------------------------------------------------
5| Application Routes
6|--------------------------------------------------------------------------
7|
8| Here is where you can register all of the routes for an application.
9| It is a breeze. Simply tell Lumen the URIs it should respond to
10| and give it the Closure to call when that URI is requested.
11|
12*/
13
14$app->get('/api/products', 'ProductController@index');

Then we run Dredd again:

1$ dredd
2info: Configuration './dredd.yml' found, ignoring other arguments.
3info: Using apiary reporter.
4info: Starting server with command: php -S localhost:3000 -t public/
5info: Waiting 3 seconds for server command to start...
6info: Beginning Dredd testing...
7[Mon Aug 8 12:36:28 2016] 127.0.0.1:45466 [200]: /api/products
8fail: GET /api/products duration: 131ms
9info: Displaying failed tests...
10fail: GET /api/products duration: 131ms
11fail: body: At '' Invalid type: array (expected object)
12
13request:
14method: GET
15uri: /api/products
16headers:
17 Content-Type: application/json
18 User-Agent: Dredd/1.5.0 (Linux 4.4.0-31-generic; x64)
19
20body:
21
22
23
24expected:
25headers:
26 Content-Type: application/json
27
28body:
29{
30 "id": 1,
31 "name": "Purple widget",
32 "description": "A purple widget",
33 "price": 5.99,
34 "attributes": {
35 "colour": "Purple",
36 "size": "Small"
37 }
38}
39statusCode: 200
40
41
42actual:
43statusCode: 200
44headers:
45 host: localhost:3000
46 connection: close
47 x-powered-by: PHP/7.0.8-0ubuntu0.16.04.2
48 cache-control: no-cache
49 content-type: application/json
50 date: Mon, 08 Aug 2016 11:36:28 GMT
51
52body:
53[
54 {
55 "id": 1,
56 "name": "Purple widget",
57 "description": "A purple widget",
58 "price": "5.99",
59 "attributes": "{\"colour\":\"purple\",\"size\":\"Small\"}",
60 "created_at": "2016-08-08 11:32:24",
61 "updated_at": "2016-08-08 11:32:24"
62 }
63]
64
65
66
67complete: 0 passing, 1 failing, 0 errors, 0 skipped, 1 total
68complete: Tests took 582ms
69complete: See results in Apiary at: https://app.apiary.io/public/tests/run/83da2d67-c846-4356-a3b8-4d7c32daa7ef
70info: Sending SIGTERM to the backend server
71info: Backend server was killed

Whoops, looks like we made a mistake here. The index route returns an array of objects, but we're looking for a single object in the blueprint. We also need to wrap our attributes in quotes, and add the created_at and updated_at attributes. Let's fix the blueprint:

1FORMAT: 1A
2
3# Demo API
4
5# Products [/api/products]
6Product object representation
7
8## Get products [GET /api/products]
9Get a list of products
10
11+ Request (application/json)
12
13+ Response 200 (application/json)
14 + Body
15
16 [
17 {
18 "id": 1,
19 "name": "Purple widget",
20 "description": "A purple widget",
21 "price": 5.99,
22 "attributes": "{\"colour\": \"Purple\",\"size\": \"Small\"}",
23 "created_at": "*",
24 "updated_at": "*"
25 }
26 ]

Let's run Dredd again:

1$ dredd
2info: Configuration './dredd.yml' found, ignoring other arguments.
3info: Using apiary reporter.
4info: Starting server with command: php -S localhost:3000 -t public/
5info: Waiting 3 seconds for server command to start...
6info: Beginning Dredd testing...
7pass: GET /api/products duration: 65ms
8complete: 1 passing, 0 failing, 0 errors, 0 skipped, 1 total
9complete: Tests took 501ms
10[Mon Aug 8 13:05:54 2016] 127.0.0.1:45618 [200]: /api/products
11complete: See results in Apiary at: https://app.apiary.io/public/tests/run/7c23d4ae-aff2-4daf-bbdf-9fd76fc58b97
12info: Sending SIGTERM to the backend server
13info: Backend server was killed

And now we can see that our test passes.

Next, we'll implement a test for fetching a single product:

1## Get a product [GET /api/products/1]
2Get a single product
3
4+ Request (application/json)
5
6+ Response 200 (application/json)
7 + Body
8
9 {
10 "id": 1,
11 "name": "Purple widget",
12 "description": "A purple widget",
13 "price": 5.99,
14 "attributes": "{\"colour\": \"Purple\",\"size\": \"Small\"}",
15 "created_at": "*",
16 "updated_at": "*"
17 }

Note the same basic format - we define the URL that should be fetched, the content of the request, and the response, including the status code.

Let's hook up our route in app/Http/routes.php:

$app->get('/api/products/{id}', 'ProductController@show');

And add the show() method to the controller:

1 public function show($id)
2 {
3 // Get individual product
4 $product = $this->product->findOrFail($id);
5
6 // Send response
7 return response()->json($product, 200);
8 }

Running Dredd again should show this method has been implemented:

1$ dredd
2info: Configuration './dredd.yml' found, ignoring other arguments.
3info: Using apiary reporter.
4info: Starting server with command: php -S localhost:3000 -t public/
5info: Waiting 3 seconds for server command to start...
6info: Beginning Dredd testing...
7pass: GET /api/products duration: 66ms
8[Mon Aug 8 13:21:31 2016] 127.0.0.1:45750 [200]: /api/products
9pass: GET /api/products/1 duration: 17ms
10complete: 2 passing, 0 failing, 0 errors, 0 skipped, 2 total
11complete: Tests took 521ms
12[Mon Aug 8 13:21:31 2016] 127.0.0.1:45752 [200]: /api/products/1
13complete: See results in Apiary at: https://app.apiary.io/public/tests/run/bb6d03c3-8fad-477c-b140-af6e0cc8b96c
14info: Sending SIGTERM to the backend server
15info: Backend server was killed

That's our read support done. We just need to add support for POST, PATCH and DELETE methods.

Our remaining methods

Let's set up the test for our POST method first:

1## Create products [POST /api/products]
2Create a new product
3
4+ name (string) - The product name
5+ description (string) - The product description
6+ price (float) - The product price
7+ attributes (string) - The product attributes
8
9+ Request (application/json)
10 + Body
11
12 {
13 "name": "Blue widget",
14 "description": "A blue widget",
15 "price": 5.99,
16 "attributes": "{\"colour\": \"blue\",\"size\": \"Small\"}"
17 }
18
19+ Response 201 (application/json)
20 + Body
21
22 {
23 "id": 2,
24 "name": "Blue widget",
25 "description": "A blue widget",
26 "price": 5.99,
27 "attributes": "{\"colour\": \"blue\",\"size\": \"Small\"}",
28 "created_at": "*",
29 "updated_at": "*"
30 }

Note we specify the format of the parameters that should be passed through, and that our status code should be 201, not 200 - this is arguably a more correct choice for creating a resource. Be careful of the whitespace - I had some odd issues with it. Next, we add our route:

$app->post('/api/products', 'ProductController@store');

And the store() method in the controller:

1 public function store(Request $request)
2 {
3 // Validate request
4 $valid = $this->validate($request, [
5 'name' => 'required|string',
6 'description' => 'required|string',
7 'price' => 'required|numeric',
8 'attributes' => 'string',
9 ]);
10
11 // Create product
12 $product = new $this->product;
13 $product->name = $request->input('name');
14 $product->description = $request->input('description');
15 $product->price = $request->input('price');
16 $product->attributes = $request->input('attributes');
17
18 // Save product
19 $product->save();
20
21 // Send response
22 return response()->json($product, 201);
23 }

Note that we validate the attributes, to ensure they are correct and that the required ones exist. Running Dredd again should show the route is now in place:

1$ dredd
2info: Configuration './dredd.yml' found, ignoring other arguments.
3info: Using apiary reporter.
4info: Starting server with command: php -S localhost:3000 -t public/
5info: Waiting 3 seconds for server command to start...
6info: Beginning Dredd testing...
7pass: GET /api/products duration: 69ms
8[Mon Aug 8 15:17:35 2016] 127.0.0.1:47316 [200]: /api/products
9pass: GET /api/products/1 duration: 18ms
10[Mon Aug 8 15:17:35 2016] 127.0.0.1:47318 [200]: /api/products/1
11pass: POST /api/products duration: 42ms
12complete: 3 passing, 0 failing, 0 errors, 0 skipped, 3 total
13complete: Tests took 575ms
14[Mon Aug 8 15:17:35 2016] 127.0.0.1:47322 [201]: /api/products
15complete: See results in Apiary at: https://app.apiary.io/public/tests/run/cb5971cf-180d-47ed-abf4-002378941134
16info: Sending SIGTERM to the backend server
17info: Backend server was killed

Next, we'll implement PATCH. This targets an existing object, but accepts parameters in the same way as POST:

1## Update existing products [PATCH /api/products/1]
2Update an existing product
3
4+ name (string) - The product name
5+ description (string) - The product description
6+ price (float) - The product price
7+ attributes (string) - The product attributes
8
9+ Request (application/json)
10 + Body
11
12 {
13 "name": "Blue widget",
14 "description": "A blue widget",
15 "price": 5.99,
16 "attributes": "{\"colour\": \"blue\",\"size\": \"Small\"}"
17 }
18
19+ Response 200 (application/json)
20 + Body
21
22 {
23 "id": 2,
24 "name": "Blue widget",
25 "description": "A blue widget",
26 "price": 5.99,
27 "attributes": "{\"colour\": \"blue\",\"size\": \"Small\"}",
28 "created_at": "*",
29 "updated_at": "*"
30 }

We add our new route:

$app->patch('/api/products/{id}', 'ProductController@update');

And our update() method:

1 public function update(Request $request, $id)
2 {
3 // Validate request
4 $valid = $this->validate($request, [
5 'name' => 'string',
6 'description' => 'string',
7 'price' => 'numeric',
8 'attributes' => 'string',
9 ]);
10
11 // Get product
12 $product = $this->product->findOrFail($id);
13
14 // Update it
15 if ($request->has('name')) {
16 $product->name = $request->input('name');
17 }
18 if ($request->has('description')) {
19 $product->description = $request->input('description');
20 }
21 if ($request->has('price')) {
22 $product->price = $request->input('price');
23 }
24 if ($request->has('attributes')) {
25 $product->attributes = $request->input('attributes');
26 }
27
28 // Save product
29 $product->save();
30
31 // Send response
32 return response()->json($product, 200);
33 }

Here we can't guarantee every parameter will exist, so we test for it. We run Dredd again:

1$ dredd
2info: Configuration './dredd.yml' found, ignoring other arguments.
3info: Using apiary reporter.
4info: Starting server with command: php -S localhost:3000 -t public/
5info: Waiting 3 seconds for server command to start...
6info: Beginning Dredd testing...
7pass: GET /api/products duration: 74ms
8[Mon Aug 8 15:27:14 2016] 127.0.0.1:47464 [200]: /api/products
9pass: GET /api/products/1 duration: 19ms
10[Mon Aug 8 15:27:14 2016] 127.0.0.1:47466 [200]: /api/products/1
11pass: POST /api/products duration: 36ms
12[Mon Aug 8 15:27:14 2016] 127.0.0.1:47470 [201]: /api/products
13[Mon Aug 8 15:27:14 2016] 127.0.0.1:47474 [200]: /api/products/1
14pass: PATCH /api/products/1 duration: 34ms
15complete: 4 passing, 0 failing, 0 errors, 0 skipped, 4 total
16complete: Tests took 2579ms
17complete: See results in Apiary at: https://app.apiary.io/public/tests/run/eae98644-44ad-432f-90fc-5f73fa674f66
18info: Sending SIGTERM to the backend server
19info: Backend server was killed

One last method to implement - the DELETE method. Add this to apiary.apib:

1## Delete products [DELETE /api/products/1]
2Delete an existing product
3
4+ Request (application/json)
5
6+ Response 200 (application/json)
7 + Body
8
9 {
10 "status": "Deleted"
11 }

Next, add the route:

$app->delete('/api/products/{id}', 'ProductController@destroy');

And the destroy() method in the controller:

1 public function destroy($id)
2 {
3 // Get product
4 $product = $this->product->findOrFail($id);
5
6 // Delete product
7 $product->delete();
8
9 // Return empty response
10 return response()->json(['status' => 'deleted'], 200);
11 }

And let's run Dredd again:

1$ dredd
2info: Configuration './dredd.yml' found, ignoring other arguments.
3info: Using apiary reporter.
4info: Starting server with command: php -S localhost:3000 -t public/
5info: Waiting 3 seconds for server command to start...
6info: Beginning Dredd testing...
7pass: GET /api/products duration: 66ms
8[Mon Aug 8 15:57:44 2016] 127.0.0.1:48664 [200]: /api/products
9pass: GET /api/products/1 duration: 19ms
10[Mon Aug 8 15:57:44 2016] 127.0.0.1:48666 [200]: /api/products/1
11pass: POST /api/products duration: 45ms
12[Mon Aug 8 15:57:44 2016] 127.0.0.1:48670 [201]: /api/products
13pass: PATCH /api/products/1 duration: 24ms
14[Mon Aug 8 15:57:44 2016] 127.0.0.1:48674 [200]: /api/products/1
15pass: DELETE /api/products/1 duration: 27ms
16complete: 5 passing, 0 failing, 0 errors, 0 skipped, 5 total
17complete: Tests took 713ms
18[Mon Aug 8 15:57:44 2016] 127.0.0.1:48678 [200]: /api/products/1
19complete: See results in Apiary at: https://app.apiary.io/public/tests/run/a3e11d59-1dad-404b-9319-61ca5c0fcd15
20info: Sending SIGTERM to the backend server
21info: Backend server was killed

Our REST API is now finished.

Generating HTML version of your documentation

Now we have finished documenting and implementing our API, we need to generate an HTML version of it. One way is to use aglio:

$ aglio -i apiary.apib -o output.html

This will write the documentation to output.html. There's also scope for choosing different themes if you wish.

You can also use Apiary, which has the advantage that they'll create a stub of your API so that if you need to work with the API before it's finished being implemented, you can use that as a placeholder.

Summary

The Blueprint language is a useful way of documenting your API, and makes it simple enough that it's hard to weasel out of doing so. It's worth taking a closer look at the specification as it goes into quite a lot of detail. It's hard to ensure that the documentation and implementation remain in sync, so it's a good idea to use Dredd to ensure that any changes you make don't invalidate the documentation. With Aglio or Apiary, you can easily convert the documentation into a more attractive format.

You'll find the source code for this demo API on Github, so if you get stuck, take a look at that. I did have a fair few issues with whitespace, so bear that in mind if it behaves oddly. I've also noticed a few quirks, such as Dredd not working properly if a route returns a 204 response code, which is why I couldn't use that for deleting - this appears to be a bug, but hopefully this will be resolved soon.

I'll say it again, Dredd is not a substitute for proper unit tests, and under no circumstances should you use it as one. However, it can be very useful as a way to plan how your API will work and ensure that it complies with that plan, and to ensure that the implementation and documentation don't diverge. Used as part of your normal continuous integration setup, Dredd can make sure that any divergence between the docs and the application is picked up on and fixed as quickly as possible, while also making writing documentation less onerous.