Laravel OpenAPI Validator

September 28th, 2021

api-validator2.png

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:

1composer 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:

1use Kirschbaum\OpenApiValidator\ValidatesOpenApiSpec;
2 
3class HttpTest extends TestCase
4{
5 use ValidatesOpenApiSpec;
6}

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:

1openapi: "3.0.0"
2
3// …
4
5paths:
6 /test:
7 get:
8 responses:
9 '200':
10 description: OK
11 /form:
12 post:
13 requestBody:
14 required: true
15 content:
16 application/json:
17 schema:
18 type: object
19 properties:
20 formInputInteger:
21 type: integer
22 formInputString:
23 type: string
24 required:
25 - formInputInteger
26 - formInputString
27 responses:
28 '200':
29 description: OK

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

1/**
2* @test
3*/
4public function testGetEndpoint()
5{
6 $response = $this->get('/test');
7 
8 $response->assertStatus(200);
9}

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.

1class TestController extends Controller
2{
3 public function __invoke(Request $request)
4 {
5 // Break our original implementation
6 // return response()->json(status: 200);
7 return response()->json(status: 418); // Tea, anyone?
8 }
9}

When we run our test again:

1> ./vendor/bin/phpunit
2PHPUnit 9.5.2 by Sebastian Bergmann and contributors.
3 
4.E 2 / 2 (100%)
5 
6Time: 00:00.138, Memory: 22.00 MB
7 
8There was 1 error:
9 
101) Tests\Feature\SimpleGetTest::testBasicTest
11League\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):

1openapi: "3.0.0"
2
3// …
4
5paths:
6 /test:
7 get:
8 responses:
9 '200':
10 description: OK
11 /form:
12 post:
13 requestBody:
14 required: true
15 content:
16 application/json:
17 schema:
18 type: object
19 properties:
20 formInputInteger:
21 type: integer
22 formInputString:
23 type: string
24 required:
25 - formInputInteger
26 - formInputString
27 responses:
28 '200':
29 description: OK
30 content:
31 application/json:
32 schema:
33 type: object
34 properties:
35 isValid:
36 type: boolean
37 description: Is this a valid object?
38 howShiny:
39 type: integer
40 description: How shiny this object is.
41 required:
42 - isValid
43 - 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:

1/**
2* @test
3*/
4public function testFormPost()
5{
6 $response = $this->postJson('/form', [
7 'formInputInteger' => 42,
8 'formInputString' => "Don't Panic"
9 ]);
10 
11 $response->assertStatus(200);
12 
13 $this->assertTrue($response->json()['isValid'], true);
14 $this->assertEquals(10, $response->json()['howShiny']);
15}

And a simple (naive) implementation

1class FormController extends Controller
2{
3 public function __invoke(Request $request)
4 {
5 // ...Validation and typing here...
6 if ($request->formInputInteger === 42
7 && $request->formInputString === "Don't Panic") {
8 return response()->json([
9 'isValid' => true,
10 'howShiny' => 10,
11 ]);
12 }
13 
14 return response()->json([
15 'isValid' => false,
16 'howShiny' => 0,
17 ]);
18 }
19}

We run our test:

1> ./vendor/bin/phpunit --filter testFormPost
2PHPUnit 9.5.2 by Sebastian Bergmann and contributors.
3 
4. 1 / 1 (100%)
5 
6Time: 00:00.128, Memory: 22.00 MB
7 
8OK (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:

1public function testFormPost()
2{
3 $response = $this->postJson('/form', [
4 'formInputInteger' => 42,
5 // 'formInputString' => "Don't Panic"
6 ]);
7 
8 $response->assertStatus(200);
9 
10 $this->assertTrue($response->json()['isValid'], true);
11 $this->assertEquals(10, $response->json()['howShiny']);
12}

Run the tests again and see:

1> ./vendor/bin/phpunit --filter testFormPost
2PHPUnit 9.5.2 by Sebastian Bergmann and contributors.
3 
4F 1 / 1 (100%)
5{
6 "formInputInteger": 42
7}
8Keyword validation failed: Required property 'formInputString' must be present in the object
9Key: formInputString
10 
11Time: 00:00.107, Memory: 22.00 MB
12 
13There was 1 failure:
14 
151) Tests\Feature\SimpleTest::testFormPost
16Body does not match schema for content-type "application/json" for Request [post /form]
17 
18[...stack trace here...]
19 
20FAILURES!
21Tests: 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:

1class FormController extends Controller
2{
3 public function __invoke(Request $request)
4 {
5 // ...Validation and typing here...
6 if ($request->formInputInteger === 42
7 && $request->formInputString === "Don't Panic") {
8 return response()->json([
9 // 'isValid' => true,
10 'howShiny' => 10,
11 ]);
12 }
13 
14 return response()->json([
15 'isValid' => false,
16 'howShiny' => 0,
17 ]);
18 }
19}

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

1> ./vendor/bin/phpunit --filter testFormPost
2PHPUnit 9.5.2 by Sebastian Bergmann and contributors.
3 
4F 1 / 1 (100%)
5{
6 "howShiny": 10
7}
8Keyword validation failed: Required property 'isValid' must be present in the object
9Key: isValid
10 
11Time: 00:00.126, Memory: 24.00 MB
12 
13There was 1 failure:
14 
151) Tests\Feature\SimpleTest::testFormPost
16Body does not match schema for content-type "application/json" for Response [post /form 200]
17 
18[...stack trace here...]
19 
20FAILURES!
21Tests: 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.

Filed in:

Zack Teska

Zack, Senior Developer at Kirschbaum, began his career as a web application developer almost 20 years ago when he started tinkering with PHP in his basement. Since then, his passion for learning and experimentation has driven his work with companies across several industries. He enjoys identifying business issues and discovering impactful, elegant solutions.

Having studied dance at St. Olaf College, Zack finds opportunities to incorporate wellness and the creative beauty of movement in both the digital and analogue worlds. Zack specializes in PHP, Laravel, VueJS, and Javascript, as well as Docker, Kubernetes, and CI/CD pipelines; and is always interested in learning something new.