Stoplight Platform is a series of tools that makes API design and documentation super easy. Their Studio product is an OpenAPI editor. Prism allows you to create a mock HTTP server to generate sample data based on your design specification. The Elements project creates documentation from your OpenAPI specification file. At Bluehorn Digital, we used Stoplight Studio to fine-tune an API provided by a Laravel (Lumen, actually) application.
I want to share how we used Stoplight Studio to customize the default Laravel model serialization API. I used Studio to create the API design and specify adjustments needed to the Laravel models (casts, date formats) and required fields in our controller request validation when creating new models. Studio also allowed us to provide documentation that could be handed off to the consumer! The consumer was also able to begin writing their integration application using the HTTP mock server provided by Prism. 💥
Note you don't need to use Stoplight's products or tools. You could use the Swagger Editor or any other OpenAPI tools. I like the Spotlight Platform the most.
This could be considered "backward," in that we didn't design the API first in an OpenAPI specification and then write the server code. However, life isn't always perfect. In our case, we had existing Laravel models and an API that we needed to expose to a new third-party consumer, where the API was entirely consumed by our own frontend. The following process allowed us to audit model data and design a proper API for third-party consumers.
The Laravel application
Let's start with a basic Laravel application and a model that will be consumed over the API.
The model, migration, factory, and controller setup
I will use a basic example using a model for a Reservation at a hotel, inn, cabin, or other.
composer create-project laravel/laravel openapi-app
cd openapi-app
Now that the Laravel application is created, we can generate a model. We will create a migration and factory. Factories allow us to create sample data for our models. We will use the factory, so we have some basic JSON schema to import into Stoplight Studio.
# Shortcut to generate a model, migration, factory, seeder, and controller...
php artisan make:model Reservation --all
Now that the model and its migration, seeder, and controller are generated, we can create its schema. This is done by editing the migration added in database/migrations
. Mine was 2021_09_18_005952_create_reservations_table.php
, which yours will be different due to the date!
We'll add some primary fields.
- The reservation date (we'll pretend check-in is always at 2 PM, so the time doesn't matter.)
- A boolean to mark if the reservation was confirmed
- A requests and admin notes text field
- A reference to the user who owns the reservation and who approved it.
<?php
use App\Models\User;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateReservationsTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('reservations', function (Blueprint $table) {
$table->id();
$table->timestamps();
$table->date('reservation_date');
$table->boolean('confirmed')->default(false);
$table->text('requests')->nullable();
$table->text('admin_notes')->nullable();
$table->foreignId('approved_by')
->references('id')
->on('users');
$table->foreignId('reserved_for')
->references('id')
->on('users');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('reservations');
}
}
Next, we need to edit the model so that the Eloquent ORM understands the relationships we desire on approved_by
and reserved_for
. Edit app/Models/Reservation.php and add the following methods so that the ORM understands our desired relationships to the User model.
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Reservation extends Model
{
use HasFactory;
public function approved_by()
{
return $this->belongsTo(User::class, 'approved_by', 'id');
}
public function reserved_for()
{
return $this->belongsTo(User::class, 'reserved_for', 'id');
}
}
Now that we have the schema and model relationships defined, let's update the factory to generate models with sweet sample data. Edit the database/factories/ReservationFactory.php
that was generated. We need to update the definition to use some random data from the Faker library. We will also randomly load a user and add it as the reference for our reservation owner and approval user.
<?php
namespace Database\Factories;
use App\Models\Reservation;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
class ReservationFactory extends Factory
{
/**
* The name of the factory's corresponding model.
*
* @var string
*/
protected $model = Reservation::class;
/**
* Define the model's default state.
*
* @return array
*/
public function definition()
{
return [
'reservation_date' => $this->faker->iso8601(),
'confirmed' => $this->faker->boolean(),
'requests' => $this->faker->text(),
'admin_notes' => $this->faker->text(),
'approved_by' => User::inRandomOrder()->take(1)->first()->id,
'reserved_for' => User::inRandomOrder()->take(1)->first()->id,
];
}
}
Then, let's update the database seeder database/seeders/DatabaseSeeder.php
so we get some generated models. Note! We need to import users first since our reservation factory randomly selects user relationships.
<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
class DatabaseSeeder extends Seeder
{
/**
* Seed the application's database.
*
* @return void
*/
public function run()
{
\App\Models\User::factory(4)->create();
\App\Models\Reservation::factory(2)->create();
}
}
For testing, we'll generate four different users and two reservations. That's more than enough to create a sample schema that we will import into Stoplight Studio for fine-tuning.
Next, we need to make a route and update the controller to retrieve our models in a serialized JSON format. Edit routes/api.php
and add the following entry after the default code.
Route::get('/reservations', [App\Http\Controllers\ReservationController::class, 'index']);
Next, we need to fetch models in our ReservationController::index
method. To keep things simple, we're just going to load all reservations with all relations so we can get a big picture. (Hint: we would also want an admin-based endpoint for editing and one for customers to only view.) Pop open app/Http/Controllers/ReservationController.php
and give it a good ole update like the following.
<?php
namespace App\Http\Controllers;
use App\Models\Reservation;
use Illuminate\Http\Request;
class ReservationController extends Controller
{
/**
* Display a listing of the resource.
*
* @return \Illuminate\Http\Response
*/
public function index()
{
return Reservation::with('approved_by')->with('reserved_for')->get();
}
}
Okay! LET'S SEE SOME JSON!
First. Let's get Laravel to use SQLite to avoid needing a MySQL database. Edit your .env
and update the DB_*
variables to:
DB_CONNECTION=sqlite
# DB_HOST=127.0.0.1
# DB_PORT=3306
# DB_DATABASE=laravel
# DB_USERNAME=root
# DB_PASSWORD=
One more quick gotcha. Laravel will not automatically make the SQLite database file if it is missing. So create an empty file named database.sqlite
in the database
directory.
touch database/database.sqlite
Now that we use SQLite and have a database connection run the following to populate the database schema and sample data.
php artisan migrate --seed
Now fire up the built-in PHP server and ping that endpoint!
php artisan serve
Go to http://127.0.0.1:8000/api/reservations
in your browser, or with cURL, and you should see some data!
Making an API you won't hate with Stoplight Studio
Sorry, Phil Sturgeon. I couldn't resist putting a twist on APIs You Won't Hate.
Head over to the Stoplight Studio page and get started with their online Workspace (requires an account) or download a desktop app (account optional, local storage available.) I use the desktop app connected to a workspace.
Once you have the app opened, create a new local project. I am going to call it Reservation API in this example.
Once we have our project, let's add a new API inside of the project. This is where we will begin using the user interface to generate our OpenAPI schema. Click on the API button in the sidebar to get started and give the API a name. Your API contains all of the various endpoints. If you're used to microservices architecture, each API would be its microservice.
Out of the box, new APIs have a default User model and paths for getting a user, adding a user, and updating a user. My first step is to delete these things usually. As far as I know, there is no way to turn this off. For this post, ignore them or go ahead and delete them.
We're going to add a new model for our reservation model in Laravel and then an endpoint that matches the path we accessed before – /api/reservations
. To add new items to your project, click the blue plus button.
So, let's add our model! Click the add plus button, and then model. Name the model "Reservation" and set the scope to Single API, then press create. You can choose the Common option under Scope. Use this if you have APIs that the same models when designing your actual APIs.
Now that we have a model, you will see the Generate from JSON button to create the model's schema from an existing JSON blob, which we happen to have! That's why we started the Laravel model first, so we had a baseline we could transform and improve. Click on Generate from JSON and paste the data from a Reservation model.
Next, we click on Generate. And, boom! We now have a model schema that we can tweak, adjust, and verify.
Okay. You might be thinking: Is this necessary? I could refresh the http://127.0.0.1:8000/api/reservations
endpoint and keep changing my Laravel model as I specify casts, what should be hidden, etc. This is a true statement and completely valid. But. BUT. Is your API documented this way? Is there an architecture document? Are there notes? Can this be shared and mocked without an actual backend server available? At first glance, this may seem like overkill, but from my experience, it is not. We have all been on projects where we look at the code and wonder, "why in the world does this do that?!" Well, the API design document explains it all and is the contract and source of truth.
I also like this approach as it feels easier to audit the fields returned by the API and see what should be removed or restricted. For example, we do not need to know when the reference users were created, updated, had their email verified, or their email. We probably need their name field and identifier (for links.)
When you hover over a field on a model, a few icons pop up. You can move a property up or down in order, duplicate it, or delete it.
Let's delete the created_at
, updated_at
, email
, and email_verified_at
from the approved_by
and reserved_for
properties.
Now that we've removed frivolous fields let's review what is left. Notice that everything is a string. The next step is to identify the different schemas for each property correctly.
created_at
,updated_at
: it's a string, but it's a date and time stringreservation_date
: it's a string, but it's only a date (remember, we decided check-in is always at 2 PM, we don't care about the time.)confirmed
: this is not a string; it's a boolean! But, that's because all values are treated as strings when retrieved from the database.
First, let's address the format of the date strings. This can be done by specifying properties on these fields. We can open the properties for each field and specify the format. The row icon on the right is for Other properties.
Clicking that icon will open a dialog that lets us specify a format for our string fields. The OpenAPI specification has many string field types available.
To change a field's schema, you click on the current schema type to open a dialog. Make sure you unselect the current schema type! OpenAPI allows for unions – string OR number. So a field can be of multiple types.
Woo! We've done it! We triaged our model's schema!
Next, we need to create an endpoint in Stoplight that represents the Laravel route we have already defined. Click the add plus button, and then Endpoint. Set the path to /api/reservation
, just like we have it in Laravel. Click on GET to toggle the HTTP method. We are only going to define one HTTP method which allows fetching the data. Press Create to add the endpoint.
With the endpoint created, we need to specify the response. Click + Response to allow specifying a response for the endpoint. This will create a default HTTP 200 response. Click on + Add Body for the response, which will enable us to specify that it returns an array of Reservation models.
The default body response is a single object. Our endpoint currently returns an array of models. Click on object to change the schema to array. When an array is chosen, we can specify a subtype. Choose $ref for the subtype so that we can reference our Reservation model.
And we have now defined our endpoint! We can mock the response and see if we're satisfied before updating our Laravel model.
Previewing our data using Prism
The Prism HTTP server mocking tool is built into Stoplight Studio. All we have to do is click Mocks and then the API endpoint we want to mock, and we'll get the output of sample data!
You'll notice when accessing the mocks that we could do more fine-tuning of our API specification. That's a bit outside the scope of this post. But, it shows why this step is essential! For example, we know that identifiers will not be floats or negative. These are properties that can be set on each model field. I know this may seem trivial, but will it seem trivial to a third-party consumer?
Let's say this is "good enough," and we want to update our Laravel model to reflect the API we have designed.
Wrangling our Laravel model
There are two things we need to do
- Update the castings for
confirmed
andreservation_date
- Update the
with
method calls to restrict the returned fields from user references.
Specifying casts in our Laravel model
Models in Laravel extend Illuminate\Database\Eloquent\Model
, which implements the Illuminate\Database\Eloquent\Concerns\HasAttributes
trait. This trait allows us to identify how fields on our model should be cast when accessed or serialized. Using the $casts
property, we can specify the cast types.
The following $casts property should be added to your Reservation model to match the API design we created in Stoplight Studio.
protected $casts = [
'confirmed' => 'boolean',
'reservation_date' => 'date:Y-m-d ',
];
With these added, we can refresh the output from Laravel and see that the confirmed and reservation_date now meet our API design expectations! Woo 🥳
Controlling the data returned from relationships
The final step is to update the user model relationships we include. We can do that by restricting the fields returned in our with
calls to load relationships. We need to use a colon after the relationship name and specify the fields we want to be returned.
/**
* Display a listing of the resource.
*
* @return \Illuminate\Http\Response
*/
public function index()
{
return Reservation::with('approved_by:id,name')
->with('reserved_for:id,name')
->get();
}
Adding :id,name
ensures only these fields are returned. And, with that change, we now meet our API design specification!
Thanks!
Hopefully you found this interesting! I didn't have time to research existing tooling. The Laravel community has some amazing packages out there, so many there is an OpenAPI and Eloquent ORM model package? If not, I plan on writing one!
If you need assistance with your API driven Laravel or Drupal application, contact Bluehorn Digital at [email protected] and see how my team and myself can assist you!
Want more? Sign up for my weekly newsletter