Laravel OpenAPI Validator

It's much easier to do what you say, when the robots check it for you.
By Zack Teska

Everyone loves someone who's always true to their word. When you open up a public API to the world from your application, it's equally important that you stay true to your word there, as well. What can other applications do with your API? And, how do they do it?

Chances are pretty good you're exposing an API specification, probably through documentation and/or something like an OpenAPI spec (if you're not, please please please drop everything you're doing and take care of that). This is a spectacular way to create a clear line of communication to your users on how you'd like them to interact. Even better yet, if you hold steadfast to your "word" (spec), you'll have a steady stream of followers pining to use your app!

If you're not already familiar with the OpenAPI 3 spec, you'll want to read up a bit on it first. You can find a great overview from Smartbear (the company behind the OpenAPI Specification now, formerly Swagger Specification) here.

Introducing Laravel OpenAPI Validator

Take your defined OpenAPI spec and automatically test your adherence to it in your PHPUnit tests. Behind the scenes this package connects the Laravel HTTP helpers to the PHP League's OpenAPI Validator. Best of all, it's (almost) plug-n-play.

Start by pulling in the package:

composer require kirschbaum-development/laravel-openapi-validator

And, in any feature/integration tests that you make an HTTP call to your API, simply apply the trait:

use Kirschbaum\OpenApiValidator\ValidatesOpenApiSpec;

class HttpTest extends TestCase
{
    use ValidatesOpenApiSpec;
}

In most situations, that's all you need to do. The trait will tap into any HTTP calls (such as $this->putJson(...) or $this->get(...)) and hand the request and response over to the validator automatically.

What can it do?

Say you have a spec, with a few paths that looks something like this:

openapi: "3.0.0"

// …

paths:
  /test:
    get:
      responses:
        '200':
          description: OK
  /form:
    post:
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                formInputInteger:
                  type: integer
                formInputString:
                  type: string
              required:
                - formInputInteger
                - formInputString
      responses:
        '200':
          description: OK

In my test, I'm going to assert that my simple get endpoint returns a 200:

/**
* @test
*/
public function testGetEndpoint()
{
    $response = $this->get('/test');

    $response->assertStatus(200);
}                

Voila! We've asserted that we get a 200! And, with the trait applied to this class, we're automatically checking adherence to our OpenAPI spec as well. In the implementation, let's break something to ensure it's working.

class TestController extends Controller
{
    public function __invoke(Request $request)
    {
        // Break our original implementation
        // return response()->json(status: 200);
        return response()->json(status: 418); // Tea, anyone?
    }
}                     

When we run our test again:

> ./vendor/bin/phpunit
PHPUnit 9.5.2 by Sebastian Bergmann and contributors.

.E                                                                  2 / 2 (100%)

Time: 00:00.138, Memory: 22.00 MB

There was 1 error:

1) Tests\Feature\SimpleGetTest::testBasicTest
League\OpenAPIValidation\PSR7\Exception\NoResponseCode: OpenAPI spec contains no such operation [/test,get,418]                  

Yep, sure enough, 418 was not a response we were expecting!

Ok, so it can check status codes, big whoop. That was already part of our assertions anyway! Let's try it out with something a bit more complex, like our form endpoint. Here's the spec (I've omitted any $refs and merged it into a single layer for easier reading):

openapi: "3.0.0"

// …

paths:
  /test:
    get:
      responses:
        '200':
          description: OK
  /form:
    post:
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                formInputInteger:
                  type: integer
                formInputString:
                  type: string
              required:
                - formInputInteger
                - formInputString
      responses:
        '200':
          description: OK
          content:
          application/json:
            schema:
              type: object
              properties:
                isValid:
                  type: boolean
                  description: Is this a valid object?
                howShiny:
                  type: integer
                  description: How shiny this object is.
              required:
                - isValid
                - howShiny

So, a few high-level things we can expect, just from the spec:

1. It's a POST request

2. The request body is required, and has two properties, formInputInteger that's an integer, and formInputString that's a string (very descriptive names are important ;-) )

3. The response code is 200

4. The response body is a json object with two properties, isValid (bool) and howShiny (integer).

Here's our (simplified) test:

/**
* @test
*/
public function testFormPost()
{
    $response = $this->postJson('/form', [
        'formInputInteger' => 42,
        'formInputString' => "Don't Panic"
    ]);

    $response->assertStatus(200);

    $this->assertTrue($response->json()['isValid'], true);
    $this->assertEquals(10, $response->json()['howShiny']);
}               

And a simple (naive) implementation

class FormController extends Controller
{
    public function __invoke(Request $request)
    {
        // ...Validation and typing here...
        if ($request->formInputInteger === 42
            && $request->formInputString === "Don't Panic") {
                return response()->json([
                    'isValid' => true,
                    'howShiny' => 10,
                ]);
            }

        return response()->json([
            'isValid' => false,
            'howShiny' => 0,
        ]);
    }
}                        

We run our test:

> ./vendor/bin/phpunit --filter testFormPost
PHPUnit 9.5.2 by Sebastian Bergmann and contributors.

.                                                                   1 / 1 (100%)

Time: 00:00.128, Memory: 22.00 MB

OK (1 test, 3 assertions)                                

And all is well! Looking at the spec, both the parameters on the request are required, so our OpenAPI validator should let us know that _before_ it hits any kind of Laravel validation. Let's try that out. Modify the test and comment out one of the post fields:

public function testFormPost()
{
    $response = $this->postJson('/form', [
        'formInputInteger' => 42,
        // 'formInputString' => "Don't Panic"
    ]);

    $response->assertStatus(200);

    $this->assertTrue($response->json()['isValid'], true);
    $this->assertEquals(10, $response->json()['howShiny']);
}                                   

Run the tests again and see:

> ./vendor/bin/phpunit --filter testFormPost
PHPUnit 9.5.2 by Sebastian Bergmann and contributors.

F                                                                   1 / 1 (100%)
{
    "formInputInteger": 42
}
Keyword validation failed: Required property 'formInputString' must be present in the object
Key: formInputString

Time: 00:00.107, Memory: 22.00 MB

There was 1 failure:

1) Tests\Feature\SimpleTest::testFormPost
Body does not match schema for content-type "application/json" for Request [post /form]

[...stack trace here...]

FAILURES!
Tests: 1, Assertions: 1, Failures: 1.                  

Failure, as expected! The validator lets us know that formInputString was a requirement on the request, and it wasn't specified. In other words, the "Body does not match schema...for Request."

Let's uncomment that line again and modify our _response_ this time, and only return that it's shiny:

class FormController extends Controller
{
    public function __invoke(Request $request)
    {
        // ...Validation and typing here...
        if ($request->formInputInteger === 42
            && $request->formInputString === "Don't Panic") {
                return response()->json([
                    // 'isValid' => true,
                    'howShiny' => 10,
                ]);
            }

        return response()->json([
            'isValid' => false,
            'howShiny' => 0,
        ]);
    }
}                                                       

Now we get a similar error, but on the response:

> ./vendor/bin/phpunit --filter testFormPost
PHPUnit 9.5.2 by Sebastian Bergmann and contributors.

F                                                                   1 / 1 (100%)
{
    "howShiny": 10
}
Keyword validation failed: Required property 'isValid' must be present in the object
Key: isValid

Time: 00:00.126, Memory: 24.00 MB

There was 1 failure:

1) Tests\Feature\SimpleTest::testFormPost
Body does not match schema for content-type "application/json" for Response [post /form 200]

[...stack trace here...]

FAILURES!
Tests: 1, Assertions: 1, Failures: 1.                                     

The package lets us know that our "body does not match schema...for Response". Our required property isValid (from the spec) wasn't specified in our response, so it failed our test for us.

Enjoy your valid fun!

We hope you enjoy this package, it definitely simplifies life a lot when you're working with an API. It gives you a nice layer of accountability that keeps you true to your word! Take a look at the repo for more usage info and let us know how it goes for you.

Tags: Laravel, API, OpenAPI Validator
Zack Teska
Web Application Developer
Author Image

Interested in speaking with a developer?

Connect with us.
©2021 Kirschbaum Development Group LLC