Laravel OpenAPI Validator

Published on by

Laravel OpenAPI Validator image

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.

Zack Teska photo

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.

Filed in:
Cube

Laravel Newsletter

Join 40k+ other developers and never miss out on new tips, tutorials, and more.

image
Paragraph

Manage your Laravel app as if it was a CMS – edit any text on any page or in any email without touching Blade or language files.

Visit Paragraph
Laravel Forge logo

Laravel Forge

Easily create and manage your servers and deploy your Laravel applications in seconds.

Laravel Forge
Tinkerwell logo

Tinkerwell

The must-have code runner for Laravel developers. Tinker with AI, autocompletion and instant feedback on local and production environments.

Tinkerwell
No Compromises logo

No Compromises

Joel and Aaron, the two seasoned devs from the No Compromises podcast, are now available to hire for your Laravel project. ⬧ Flat rate of $7500/mo. ⬧ No lengthy sales process. ⬧ No contracts. ⬧ 100% money back guarantee.

No Compromises
Kirschbaum logo

Kirschbaum

Providing innovation and stability to ensure your web application succeeds.

Kirschbaum
Shift logo

Shift

Running an old Laravel version? Instant, automated Laravel upgrades and code modernization to keep your applications fresh.

Shift
Bacancy logo

Bacancy

Supercharge your project with a seasoned Laravel developer with 4-6 years of experience for just $2500/month. Get 160 hours of dedicated expertise & a risk-free 15-day trial. Schedule a call now!

Bacancy
Lucky Media logo

Lucky Media

Bespoke software solutions built for your business. We ♥ Laravel

Lucky Media
Lunar: Laravel E-Commerce logo

Lunar: Laravel E-Commerce

E-Commerce for Laravel. An open-source package that brings the power of modern headless e-commerce functionality to Laravel.

Lunar: Laravel E-Commerce
LaraJobs logo

LaraJobs

The official Laravel job board

LaraJobs
Larafast: Laravel SaaS Starter Kit logo

Larafast: Laravel SaaS Starter Kit

Larafast is a Laravel SaaS Starter Kit with ready-to-go features for Payments, Auth, Admin, Blog, SEO, and beautiful themes. Available with VILT and TALL stacks.

Larafast: Laravel SaaS Starter Kit
SaaSykit: Laravel SaaS Starter Kit logo

SaaSykit: Laravel SaaS Starter Kit

SaaSykit is a Laravel SaaS Starter Kit that comes with all features required to run a modern SaaS. Payments, Beautiful Checkout, Admin Panel, User dashboard, Auth, Ready Components, Stats, Blog, Docs and more.

SaaSykit: Laravel SaaS Starter Kit
Rector logo

Rector

Your partner for seamless Laravel upgrades, cutting costs, and accelerating innovation for successful companies

Rector

The latest

View all →
Apply Dynamic Filters to Eloquent Models with the Filterable Package image

Apply Dynamic Filters to Eloquent Models with the Filterable Package

Read article
Property Hooks Get Closer to Becoming a Reality in PHP 8.4 image

Property Hooks Get Closer to Becoming a Reality in PHP 8.4

Read article
Asserting Exceptions in Laravel Tests image

Asserting Exceptions in Laravel Tests

Read article
Reversible Form Prompts and a New Exceptions Facade in Laravel 11.4 image

Reversible Form Prompts and a New Exceptions Facade in Laravel 11.4

Read article
Basset is an alternative way to load CSS & JS assets image

Basset is an alternative way to load CSS & JS assets

Read article
Integrate Laravel with Stripe Connect Using This Package image

Integrate Laravel with Stripe Connect Using This Package

Read article