PHP
Article

Testing APIs with RAML

By Lukas White

In a recent article I looked at RESTful API Modeling Language (RAML). I provided an overview of what RAML is all about, how to write it and some of its uses.

This time, I’m going to look at some of the ways in which you can use RAML for testing. We’ll start by using RAML to validate responses from an API. Then we’ll look at an approach you could take to mock an API server, using a RAML file to create mock HTTP responses.

Validating API Responses

First, let’s define a simple RAML file for a fictional API. I’ve left out some routes, but it will be enough to demonstrate the principles.

#%RAML 0.8
title: Albums
version: v1
baseUri: http://localhost:8000
traits:
  - secured:
      description: Some requests require authentication
      queryParameters:
        accessToken:
          displayName: Access Token
          description: An access token is required for secure routes
          required: true
  - unsecured:
      description: This is not secured
/account:
  displayName: Account
  get:
    description: Get the currently authenticated user's account details.    
    is: [secured]
    responses:
      200:
        body:
          application/json: 
            schema: |
              { "$schema": "http://json-schema.org/schema#",
                "type": "object",
                "description": "A user",
                "properties": {
                  "id":  { 
                    "description": "Unique numeric ID for this user",
                    "type": "integer" 
                  },
                  "username":  { 
                    "description": "The user's username",
                    "type": "string" 
                  },                  
                  "email":  { 
                    "description": "The user's e-mail address",
                    "type": "string",
                    "format": "email" 
                  },
                  "twitter": {
                    "description": "User's Twitter screen name (without the leading @)",
                    "type": "string",
										"maxLength": 15
                  }
                },
                "required": [ "id", "username" ]
              }
            example: |
              {
                "id": 12345678,
                "username": "joebloggs",
                "email": "joebloggs@example.com",                                
                "twitter": "joebloggs"
              }
  put:
    description: Update the current user's account
/albums:
  displayName: Albums
  /{id}:      
    displayName: Album
    uriParameters: 
      id:
        description: Numeric ID which represents the album
    /tracks:
      displayName: Album Tracklisting
      get:
        responses:
          200:
            body:
              application/json: 
                schema: |
                  { "$schema": "http://json-schema.org/schema#",
                    "type": "array",
                    "description": "An array of tracks",
                    "items": {
                      "id":  { 
                        "description": "Unique numeric ID for this track",
                        "type": "integer" 
                      },
                      "name":  { 
                        "description": "The name of the track",
                        "type": "string" 
                      }
                    },
                    "required": [ "id", "name" ]
                  }
                example: |
                  [                    
                    {
                      "id": 12345,
                      "name": "Dark & Long"
                    },
                    {
                      "id": 12346,
                      "name": "Mmm Skyscraper I Love You"
                    }
                  ]

Notice we’ve defined a trait for secured routes, which expects an access token as a query parameter. We’ve defined a few available routes, and defined some JSON schemas to specify the format of the results. We’ve also included some example responses; these are what we’re going to use to generate mock responses.

Let’s create an application which we’ll use for both parts of this tutorial. You’ll find it on Github.

In this first part I’ll show how you can parse a RAML file, extract the schema for a given route and then use this to test against.

Create a project directory, and create the file test/fixture/api.raml with the contents above.

We’re going to use Guzzle to access the API, PHPUnit as a testing framework and this PHP-based RAML parser. So, create a composer.json to define these dependencies:

{
    "name": "sitepoint/raml-testing",
    "description": "A simple example of testing APIs against RAML definitions",
    "require": {
        "alecsammon/php-raml-parser": "dev-master",
        "guzzle/guzzle": "~3.9@dev",				
        "phpunit/phpunit": "~4.6@dev"
    },
    "authors": [
        {
            "name": "lukaswhite",
            "email": "hello@lukaswhite.com"
        }
    ],
		"autoload": {
        "psr-0": {
          "Sitepoint\\": "src/"
        }
      },
    "minimum-stability": "dev"
}

Run composer install to download the required packages.

Now, let’s create a simple test which validates the response from an API. We’ll begin with a phpunit.xml file:

<?xml version="1.0" encoding="UTF-8"?>
<phpunit
    colors="true"
    convertErrorsToExceptions="false"
    convertNoticesToExceptions="false"
    convertWarningsToExceptions="false"
    stopOnFailure="false"
    bootstrap="vendor/autoload.php"
    strict="true"
    verbose="true"
    syntaxCheck="true">

    <testsuites>
        <testsuite name="PHP Raml Parser">
            <directory>./test</directory>
        </testsuite>
    </testsuites>

    <filter>
        <whitelist>
            <directory suffix=".php">./src</directory>
        </whitelist>
    </filter>

</phpunit>

The PHP RAML Parser currently shows a deprecation error. To get around this, we’re setting convertErrorsToExceptions, convertNoticesToExceptions and convertWarningsToExceptions to false.

Let’s create a skeleton test class. Name this test/AccountTest.php, and start by defining the setUp() method:

<?php

class AccountTest extends PHPUnit_Framework_TestCase
{
    /**
     * @var \Raml\Parser
     */
    private $parser;

    public function setUp()
    {
        parent::setUp();
        $parser = new \Raml\Parser();
        $this->api = $parser->parse(__DIR__.'/fixture/api.raml');

        $routes = $this->api->getResourcesAsUri()->getRoutes();

        $response = $routes['GET /account']['response']->getResponse(200);

        $this->schema = $response->getSchemaByType('application/json');
    }

}

Here we’re parsing the RAML file, then extracting all of the defined routes. Next we’re pulling out the route identified by the string GET /account. From that, we’re extracting the definition of a successful response, and from that, we’re grabbing the JSON schema which defines the expected structure of the JSON response.

Now we can create a simple test which calls our endpoint, checks that we get back a 200 status, that the response format is JSON and that it validates against the schema.

/** @test */
public function shouldBeExpectedFormat()
{    
	$accessToken = 'some-secret-token';

	$client = new \Guzzle\Http\Client();
        
	$request = $client->get($this->api->getBaseUri() . '/account', [
		'query' => [
			'accessToken' => $accessToken,
		]
	]);
        
	$response = $client->send($request);
        
	// Check that we got a 200 status code
	$this->assertEquals( 200, $response->getStatusCode() );
	
	// Check that the response is JSON       
	$this->assertEquals( 'application/json', $response->getHeader('Content-Type')->__toString());
	
	// Check the JSON against the schema
	$this->assertTrue($this->schema->validate($response->getBody()));

}

It’s as simple as that; the RAML Parser provides a validator against our defined JSON schema.

There are a number of ways in which you could use RAML to test your APIs. As well as JSON schemas, RAML also supports XML schemas – so the principle of checking the results in XML format would be broadly similar. You could test that the appropriate status codes are returned, that the routes defined in your RAML all exist, and so on.

In the next section, we’ll look at using RAML to mock API responses.

Mocking an API using RAML

Another neat thing we can do with RAML is use it to mock an API. There are probably too many variations across different APIs to create a one-size-fits-all mocking class, so let’s build one which can be tweaked to your requirements.

What we’ll do is create three things:

  • A “response” class, which encapsulates standard HTTP response data, such as the status code and body
  • A class which uses RAML to respond to “URLs”
  • A simple “server” which you can run on a web server

To keep things simple, we’ll use the same code as in the previous section. We just need to add an additional dependency; FastRoute, a simple and fast routing component which we’ll use to determine the route we’re going to respond to. Add it to the require section of your composer.json file:

"nikic/fast-route": "~0.3.0"

Now, let’s create a really simple Response class; create this in src/Sitepoint/Response.php:

<?php namespace Sitepoint;

class Response {

	/**
	 * The HTTP status code
	 * @var integer
	 */
	public $status;

	/**
	 * The body of the response
	 * @var string
	 */
	public $body;

	/**
	 * An array of response headers
	 * @var array
	 */
	public $headers;

	/**
	 * Constructor
	 * 
	 * @param integer $status The HTTP status code
	 */
	public function __construct($status = 200)
	{
		$this->status = $status;
		
		$this->headers = [
			'Content-Type' => 'application/json'
		];
	}

	/**
	 * Sets the response body
	 * 
	 * @param string $body
	 */
	public function setBody($body)
	{
		$this->body = $body;
		$this->headers['Content-Length'] = strlen($body);
	}

}

Nothing too complex here. Note that we’re going to mock an API which only “speaks” JSON, so we’re forcing the Content-Type to application/json.

Now let’s start a class which given an HTTP verb and path, will look through a RAML file to find a matching route, and return an appropriate response. We’ll do so by pulling out the example from the appropriate type of response. For the purposes of this component, that will always be a successful (status code 200) JSON response.

Create the file src/Sitepoint/RamlApiMock.php, and begin the class with the following:

<?php namespace Sitepoint;

class RamlApiMock {

	/**
	 * Constructor
	 * 
	 * @param string $ramlFilepath Path to the RAML file to use
	 */
	public function __construct($ramlFilepath)
	{
		// Create the RAML parser and parse the RAML file
		$parser = new \Raml\Parser();
		$api = $parser->parse($ramlFilepath);

		// Extract the routes
		$routes = $api->getResourcesAsUri()->getRoutes();
		$this->routes = $routes;

		// Iterate through the available routes and add them to the Router
		$this->dispatcher = \FastRoute\simpleDispatcher(function(\FastRoute\RouteCollector $r) use ($routes) {
			foreach ($routes as $route) {
				$r->addRoute($route['method'], $route['path'], $route['path']);
			}
		});

	}
	
}

Let’s look at what we’re doing here.

As before, we’re parsing a RAML file. Then we’re extracting all of the available routes. Next we iterate through those, and add them to a collection which is compatible with the FastRoute component. Lucky for us, it uses the same format, so it’ll be able to translate the following:

/albums/123/tracks

to this:

/albums/{id}/tracks

The addRoute() method takes as its third argument the name of a function designed to handle the route. Ordinarily, you’d define separate functions to handle each route accordingly. However, since we’re going to handle all routes using the same code, we’re overriding this behaviour slightly, and rather than a function name we’re adding the path a second time. This way, we can extract the path in our handler to determine what response we need to send.

Let’s create a dispatch() method.

/**
	 * Dispatch a route
	 * 
	 * @param  string $method  The HTTP verb (GET, POST etc)
	 * @param  string $url     The URL
	 * @param  array  $data    An array of data (Note, not currently used)
	 * @param  array  $headers An array of headers (Note, not currently used)
	 * @return Response
	 */
	public function dispatch($method, $url, $data = array(), $headers = array())
	{
		// Parse the URL
		$parsedUrl = parse_url($url);
		$path = $parsedUrl['path'];

		// Attempt to obtain a matching route
		$routeInfo = $this->dispatcher->dispatch($method, $path);

		// Analyse the route
		switch ($routeInfo[0]) {
			case \FastRoute\Dispatcher::NOT_FOUND:
				// Return a 404
				return new Response(404);				
				break;

			case \FastRoute\Dispatcher::METHOD_NOT_ALLOWED:
				// Method not allows (405)
				$allowedMethods = $routeInfo[1];				
				// Create the response...
				$response = new Response(405);
				// ...and set the Allow header
				$response->headers['Allow'] = implode(', ', $allowedMethods);
				return $response;
				break;

			case \FastRoute\Dispatcher::FOUND:
				$handler = $routeInfo[1];
				$vars = $routeInfo[2];
				$signature = sprintf('%s %s', $method, $handler);
				$route = $this->routes[$signature];

				// Get any query parameters
				if (isset($parsedUrl['query'])) {					
					parse_str($parsedUrl['query'], $queryParams);							
				} else {
					$queryParams = [];
				}

				// Check the query parameters
				$errors = $this->checkQueryParameters($route, $queryParams);
				if (count($errors)) {
					$response = new Response(400);
					$response->setBody(json_encode(['errors' => $errors]));
					return $response;
				}				

				// If we get this far, is a successful response
				return $this->handleRoute($route, $vars);
		
				break;
		}

	}

So what’s going on here?

We start by parsing the URL and extracting the path, then use FastRoute to try and find a matching route.

The RouteCollection’s dispatch() method returns an array, with its first element telling us whether it’s a valid route, whether it’s a valid route but invalid method, or simply not found.

If we can’t find a matching route, we generate a 404 Not Found. If the method isn’t supported we generate a 405 Method Not Allowed, popping the allowed methods into the appropriate header.

The third case is where it gets interesting. We generate a “signature” by concatenating the method and path, so it looks something like this:

GET /account

or:

GET /albums/{id}/tracks

We can then use that to grab the route definition from the $routes property, which you’ll recall we pulled out of our RAML file.

The next step is to create an array of query parameters, and then call a function which checks them – we’ll come to that particular function in a moment. Because different API’s can handle errors very differently, you may wish to modify this to suit your API – in this example I’m simply returning a 400 Bad Request, with the body containing a JSON representation of the specific validation errors.

At this point, you may wish to add some additional checks or validation. You could, for example, check whether the request has the appropriate security credentials supplied. We’re going to implement this via the required accessToken query parameter, which we’ve defined as a trait in the RAML file.

Finally, we call the handleRoute() method, passing the route definition and any URI parameters. Before we look at that, let’s return to our query parameters validation.

/**
	 * Checks any query parameters
	 * @param  array 	$route  The current route definition, taken from RAML
	 * @param  array 	$params The query parameters
	 * @return boolean
	 */
	public function checkQueryParameters($route, $params)
	{
		// Get this route's available query parameters
		$queryParameters = $route['response']->getQueryParameters();

		// Create an array to hold the errors
		$errors = [];

		if (count($queryParameters)) {

			foreach($queryParameters as $name => $param) {				

				// If the display name is set then great, we'll use that - otherwise we'll use
				// the name
				$displayName = (strlen($param->getDisplayName())) ? $param->getDisplayName() : $name;

				// If the parameter is required but not supplied, add an error
				if ($param->isRequired() && !isset($params[$name])) {
					$errors[$name] = sprintf('%s is required', $displayName);
				}

				// Now check the format
				if (isset($params[$name])) {

					switch ($param->getType()) {
						case 'string':
							if (!is_string($params[$name])) {
								$errors[$name] = sprintf('%s must be a string');
							}
							break;
						case 'number':
							if (!is_numeric($params[$name])) {
								$errors[$name] = sprintf('%s must be a number');
							}
							break;
						case 'integer':
							if (!is_int($params[$name])) {
								$errors[$name] = sprintf('%s must be an integer');
							}
							break;
						case 'boolean':
							if (!is_bool($params[$name])) {
								$errors[$name] = sprintf('%s must be a boolean');
							}
							break;
						// date and file are omitted for brevity
					}

				}

			}
		}

		// Finally, return the errors
		return $errors;
	}

This is pretty simple stuff – and note that I’ve left out certain parameter types to keep things simple – but this can be used to play around with query parameters, and reject requests where those parameters don’t fit the specifications laid out in our RAML file.

Finally, the handleRoute() function:

/** 
	 * Return a response for the given route
	 * 
	 * @param  array 	$route  The current route definition, taken from RAML
	 * @param  array 	$vars   An optional array of URI parameters
	 * @return Response
	 */
	public function handleRoute($route, $vars)
	{
		// Create a reponse
		$response = new Response(200);	

		// Return an example response, from the RAML
		$response->setBody($route['response']->getResponse(200)->getExampleByType('application/json'));

		// And return the result
		return $response;

	}

What we’re doing here is extracting the example from the appropriate route, then returning it as a response with a status code of 200.

At this point, you could use the RamlApiMock in your unit tests. However, with a simple addition, we can also provide this mocking component as a web service, simply by wrapping a call to it with some simple routing logic.

To do this, create an index.php file with the following contents:

<?php
require_once 'vendor/autoload.php';

use Sitepoint\RamlApiMock;

// The RAML library is currently showing a deprecated error, so ignore it
error_reporting(E_ERROR | E_WARNING | E_PARSE | E_NOTICE);

// Create the router
$router = new RamlApiMock('./test/fixture/api.raml');

// Handle the route
$response = $router->dispatch($_SERVER['REQUEST_METHOD'], $_SERVER['REQUEST_URI']);

// Set the HTTP response code
http_response_code($response->status);

// Optionally set some response headers
if (count($response->headers)) {
	foreach ($response->headers as $name => $value) {
		header(sprintf('%s: %s', $name, $value));
	}
}

// Print out the body of the response
print $response->body;

Hopefully this is pretty self-explanatory; we’re instantiating our “router”, handling the requested URL and method combination, then sending back the response with the appropriate status code and any headers.

Now run the server with the following:

php -S localhost:8000

Because APIs vary considerably, it’s likely you’ll need to modify this example to suit your implementation. Hopefully, it gives you enough to get started.

Summary

In this article I’ve looked at RAML in the context of testing and mocking APIs.

Since RAML provides an unambiguous and comprehensive statement of how an API should function, it’s very useful both for testing against and providing mock responses.

There’s much more you can do with RAML, and these examples only really touch the surface of how RAML can be used in testing, but hopefully I’ve provided you with a few ideas.

Comments
miguelibarra

Fantastic article! Just the thing I needed to test an API from a provider who likes to change specs without notice :/ and the only way to find out was through manual testing when my application breaks.

Recommended

Learn Coding Online
Learn Web Development

Start learning web development and design for free with SitePoint Premium!

Get the latest in PHP, once a week, for free.