Tips for tool-building using OpenAPI
Dmitrijus Glezeris
April 28, 2022
Table of contents
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:
1POST /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? They do have a point, so you add yet another client - js-gamer-sdk.
Oh, shoot! We forgot to add an endpoint for retrieving a gamer by UUID!
1GET /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:
1openapi: 3.0.02info:3title: Gamer API4description: An API for creating and managing gamers5version: 0.1.06servers:7- url: https://gamer-api.pci.test/api/v18description: 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.
1paths:2/gamers:3post:4operationId: createGamer5summary: Create a gamer6responses:7'200':8description: Gamer creation succeeded!9'500':10description: 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. Finally, 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.
1paths:2/gamers:3post:4operationId: createGamer5summary: Create a gamer6requestBody:7required: true8content:9application/json:10schema:11type: object12properties:13# 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.
1paths:2/gamers:3post:4operationId: createGamer5summary: Create a gamer6requestBody:7required: true8content:9application/json:10schema:11type: object12properties:13nickname:14type: string15example: 'monkKey'16clan:17type: string18example: '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:
1{2"nickname": "monkKey",3"clan": "chillpill"4}
The same approach can be used to describe responses.
1responses:2'200':3description: Gamer creation succeeded!4content:5application/json:6schema:7type: object8properties:9uuid:10type: string11format: uuid12example: 'e5159ff7-9e12-11ec-ad6c-2cdb0742b873'13nickname:14type: string15example: 'monkKey'16clan:17type: string18example: 'chillpill'
As you can see, the response of our API is almost identical to the request, except that an additional uuid field is returned. Note the format property, which serves as a hint to the content of the property. Judging from the current OpenAPI file, the response is as follows:
1{2"uuid": "e5159ff7-9e12-11ec-ad6c-2cdb0742b873",3"nickname": "monkKey",4"clan": "chillpill"5}
Here's what we have so far:
1openapi: 3.0.02info:3title: Gamer API4description: An API for creating and managing gamers5version: 0.1.06servers:7- url: https://gamer-api.pci.test/api/v18description: Local development server9paths:10/gamers:11post:12operationId: createGamer13summary: Create a gamer14requestBody:15required: true16content:17application/json:18schema:19type: object20properties:21nickname:22type: string23example: 'monkKey'24clan:25type: string26example: 'chillpill'27responses:28'200':29description: Gamer creation succeeded!30content:31application/json:32schema:33type: object34properties:35uuid:36type: string37format: uuid38example: 'e5159ff7-9e12-11ec-ad6c-2cdb0742b873'39nickname:40type: string41example: 'monkKey'42clan:43type: string44example: '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.
1omponents:2schemas:3GamerCreateRequest:4type: object5properties:6nickname:7type: string8clan:9type: string10GamerCreateResponse:11type: object12properties:13uuid:14type: string15format: uuid16nickname:17type: string18clan:19type: string
Then, you can reference both request and response definitions using the $ref keyword.
1paths:2/gamers:3post:4operationId: createGamer5summary: Create a gamer6requestBody:7required: true8content:9application/json:10schema:11$ref: '#/components/schemas/GamerCreateRequest'12responses:13'200':14description: Gamer creation succeeded!15content:16application/json:17schema:18$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.
1$ openapi generate -i /path/to/specification.yaml
Next, you need to pass the language you want to use for generation. We pass php, but we can just as easily generate libraries for java, nodejs, or even ruby if we want to.
1$ openapi generate -i /path/to/specification.yaml -g php
We then pass the folder where we want to put the generated code using -o.
1$ 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 are a godsend.
1$ 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 configurations that slightly tweaks the naming of the namespaces, folders, and variables.
1$ 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:
1$gamerCreateRequest = new GamerCreateRequest(); // \NordsecPayments\Dto\GamerCreateRequest2$gamerCreateRequest->setNickname('rocket')3$gamerCreateRequest->setClan('rocketRoll')45$gamerCreateResponse = $apiInstance->createGamer($gamerCreateRequest);6echo $gamerCreateResponse->getUuid();
Protip: If you don't want to set up 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.
Phpstorm OpenAPI support
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:
1$ mkdir slate23$ 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.
Generated slate documentation
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:
The postman import window
Then choose what you want to use the specification file for. We are going to use it as a "Test Suite":
Use the spec file as test suite
And finally, we click import and enjoy the results.
The main postman window
Once the file has been imported, you can see the newly created API and fire requests to it. This saves a lot of configuration, especially for both new developers and QA engineers.
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.