Laravel OpenAPI Validator
Published on 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/phpunitPHPUnit 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::testBasicTestLeague\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 testFormPostPHPUnit 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 testFormPostPHPUnit 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 objectKey: formInputString Time: 00:00.107, Memory: 22.00 MB There was 1 failure: 1) Tests\Feature\SimpleTest::testFormPostBody 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 testFormPostPHPUnit 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 objectKey: isValid Time: 00:00.126, Memory: 24.00 MB There was 1 failure: 1) Tests\Feature\SimpleTest::testFormPostBody 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.
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.