Tips for tool-building using OpenAPI
The main reason I became a developer was to build interesting things. But as with every job, you quickly realize that repetitive tasks are a big part of your life. Fortunately, as developers, we are in a unique position to create tools that either reduce or completely eliminate those repetitive tasks.
One such task for us at Nord Security is to create client SDKs for the APIs and keep them up to date.
What’s the problem with client SDKs?
Let’s say we build a simple gamer API that would allow gamers to register:
POST /api/v1/gamers
We then create a php-gamer-sdk
client so that other projects could use it. After some discussion with the other team, you find out that we need a golang sdk as well. Easy, you create a go-gamer-sdk
for everyone to use.
Then you receive a message from the nodejs gang: why is there no client for the almighty typescript? Well, they do have a point! Let’s add yet another client – js-gamer-sdk
.
Oh, shoot! We forgot to add an endpoint for retrieving a gamer by uuid!
GET /api/v1/gamers/{uuid}
Now comes the tedious and lengthy task of updating all the client-SDKs. All the developers now need to know 3 languages to be able to maintain the codebase.
An alternative to manual SDK updates
Instead of creating the SDKs manually, we can use OpenAPI. OpenAPI is a standard for describing APIs using plain old YAML files. This standard (previously known as Swagger) uses a format that can be read by both humans and machines.
How to get started
Every OpenAPI file starts with the basic info:
openapi: 3.0.0
info:
title: Gamer API
description: An API for creating and managing gamers
version: 0.1.0
servers:
- url: https://gamer-api.pci.test/api/v1
description: Local development server
The first line defines the version of the standard used. We then add additional info about the microservice, including the servers where you can access our API.
Now it’s time to actually describe the endpoints.
paths:
/gamers:
post:
operationId: createGamer
summary: Create a gamer
responses:
'200':
description: Gamer creation succeeded!
'500':
description: Unexpected issue detected, try again later.
We first describe the path of our endpoint as `/gamers`, and the method that is used to access it as `post`. After that, you assign a name to this endpoint via `operationId` and give a brief description in the `summary` field. Last but not least, you specify what response codes could be returned by the API.
One thing is missing though: there is no info on what kind of data the API receives to create a gamer.
Let’s fix that in the next section.
Accepting request parameters
Describing incoming data is pretty easy using the requestBody property.
paths:
/gamers:
post:
operationId: createGamer
summary: Create a gamer
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
# define all your json properties you will pass in the request
We want to show that the request parameters are non-optional, so we mark the content as required. Note that we will pass JSON to the API endpoint, which is why we tell OpenAPI that the request must be in application/json format.
Now comes the important part: we describe the type of the request data. There are many types to choose from, but for this example, we will describe the data as an object. Now all that’s left is to define its properties.
paths:
/gamers:
post:
operationId: createGamer
summary: Create a gamer
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
nickname:
type: string
example: 'monkKey'
clan:
type: string
example: 'chillpill'
As you can see, you can add examples of the fields you can pass. For the gamer to create an endpoint, the following object can be passed:
{
"nickname": "monkKey",
"clan": "chillpill"
}
The same approach can be used to describe responses
responses:
'200':
description: Gamer creation succeeded!
content:
application/json:
schema:
type: object
properties:
uuid:
type: string
format: uuid
example: 'e5159ff7-9e12-11ec-ad6c-2cdb0742b873'
nickname:
type: string
example: 'monkKey'
clan:
type: string
example: 'chillpill'
As you can see, the response of our API is almost identical to the request, except for an additional `uuid` field is returned. Note the `format` property that servers as a hint to the content of the property.
Judging from the current OpenAPI file, the response is as follows:
{
"uuid": "e5159ff7-9e12-11ec-ad6c-2cdb0742b873",
"nickname": "monkKey",
"clan": "chillpill"
}
Here’s what we have so far:
openapi: 3.0.0
info:
title: Gamer API
description: An API for creating and managing gamers
version: 0.1.0
servers:
- url: https://gamer-api.pci.test/api/v1
description: Local development server
paths:
/gamers:
post:
operationId: createGamer
summary: Create a gamer
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
nickname:
type: string
example: 'monkKey'
clan:
type: string
example: 'chillpill'
responses:
'200':
description: Gamer creation succeeded!
content:
application/json:
schema:
type: object
properties:
uuid:
type: string
format: uuid
example: 'e5159ff7-9e12-11ec-ad6c-2cdb0742b873'
nickname:
type: string
example: 'monkKey'
clan:
type: string
example: 'chillpill'
Using references
Even though our specification file describes a simple endpoint, it already looks like a mess – it is pretty difficult to read. There is, however, an easy way around that – references.
The idea behind a reference is simple – you can define something elsewhere in the specification and point to it. It’s basically like defining a variable and using it later.
So first, define your request and response as reusable components.
components:
schemas:
GamerCreateRequest:
type: object
properties:
nickname:
type: string
clan:
type: string
GamerCreateResponse:
type: object
properties:
uuid:
type: string
format: uuid
nickname:
type: string
clan:
type: string
Then, you can reference both request and response definition using the $ref
keyword.
paths:
/gamers:
post:
operationId: createGamer
summary: Create a gamer
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/GamerCreateRequest'
responses:
'200':
description: Gamer creation succeeded!
content:
application/json:
schema:
$ref: '#/components/schemas/GamerCreateResponse'
Here I am referencing the schemas I defined earlier. As you can see, the file now looks much cleaner!
And with that, we are officially done with our specifications. Now, we can use it to generate things!
Generating a client
For client generation, we use an amazing tool called OpenAPI generator. This tool allows us to generate clients in a variety of languages. Let’s use it to generate a PHP client.
First, you need to save all the API definitions we have so far into a YAML file and pass the path to it.
$ openapi generate -i /path/to/specification.yaml
We then pass the folder where to put the generated code using -o
$ openapi generate -i /path/to/specification.yaml -g php -o ./gen/php
It’s also important to name the package properly, so that it could be added to package repositories. For that, --git-user*
options a godsend.
$ openapi generate -i /path/to/specification.yaml -g php -o ./gen/php --git-user-id=nordsec --git-repo-id=gamer-sdk
And finally, you can pass a bunch of additional configuration that slightly tweaks the naming of the namespaces, folders and variables
$ openapi generate -i /path/to/specification.yaml -g php -o ./gen/php --git-user-id=nordsec --git-repo-id=gamer-sdk --additional-properties=variableNamingConvention=camelCase,packageName=GamerApiClient,modelPackage=Dto,invokerPackage=NordsecGamer
And you’re done, the command can now be run! Check your gen/php directory for the generated source code.
The openapi generator not only creates a client, but data transfer objects for requests and responses as well. Here’s an example of how you could use the generated code:
$gamerCreateRequest = new GamerCreateRequest(); // NordsecPaymentsDtoGamerCreateRequest
$gamerCreateRequest->setNickname('rocket')
$gamerCreateRequest->setClan('rocketRoll')
$gamerCreateResponse = $apiInstance->createGamer($gamerCreateRequest);
echo $gamerCreateResponse->getUuid();
Protip: If you don’t want to setup an additional tool just to play around with OpenAPI, you can even use PhpStorm to generate code. Just open your OpenAPI file and use the icons at the top.
Generating documentation
Fortunately for us, generating SDKs for APIs is not the only benefit of OpenAPI. There are tools that use the specification file to generate API references. This is a cheap way to create simple documentation.
For that, we use widdershins. Here’s how you can quickly generate a slate-compatible documentation file:
$ mkdir slate
$ widdershins --search false --language_tabs shell php go javascript --summary /path/to/specification.yaml -o ./slate/index.html.md
This will generate documentation with code examples that are compatible with slate in the slate directory.
Not only does this produce beautiful documentation, but it also doesn’t require any additional effort. Nice!
Using Postman
That wasn’t even the last possible use-case for the OpenAPI specification! OpenAPI can help you query your endpoints using postman. Instead of defining all endpoints yourself, use the import feature.
Navigate to File -> Import:
Then choose what you want to use the specification file for. We are going to use it as a “Test Suite”:
And finally, we click import and enjoy the results.
OpenAPI – a powerful tool
Even though we’ve touched on quite a few usages of OpenAPI, we’ve barely scratched the surface. Because the format is readable by both humans and machines, more and more tools are created that take advantage of it. Therefore, OpenAPI is definitely a powerful tool for any API developer.
Want to read more like this?
Get the latest news and tips from NordVPN.