Hacking OpenAPI
Introduction
Back in the days I used apiDoc. Wonderful tool. But growing project caused apiDoc to fail.
When time has come to look for something else there were two options: API Blueprint and OpenAPI.
Spent some time (about 4 hour) failing to make API Blueprint work with deepObject parameters I went with the only option left — mysterious OpenAPI.
Sample data model and API
To demonstrate canonical OpenAPI approach problem I need to describe some model and few API methods on it. I’ll take online-store product as an example.
Data model
Attributes (there might be more, but that’s enough):
- Name (string) —
name
- Description (string) —
description
- Vendor code (number) —
vendor_code
- Picture (file) —
picture
Let’s describe canonical OpenAPI scheme:
components:
schemas:
Product:
type: object
description: Product
properties:
name:
type: string
description: Name
description:
type: string
description: Description
vendor_code:
type: number
description: Vendor code
picture:
type: string
format: binary
description: Picture
HTTP API
We need to describe two API-methods: “create” and “get”.
Create method will look like this:
paths:
/api/products:
post:
summary: Create product
requestBody:
required: true
content:
multipart/form-data:
schema:
"$ref": "#/components/schemas/Product"
responses:
'200':
# ...
And get method will look like this:
paths:
/api/products/{id}:
get:
summary: Get product
parameters:
- name: id
in: query
description: Product ID
schema:
type: string
responses:
'200':
content:
application/json:
schema:
$ref: "#/components/schemas/Product"
Seems legit? Well, yes, but no.
What’s wrong?
In real world scenario no one is going to build an API that consumes file as binary data (as part of multipart/form-data
) and returns file as binary data (as part of application/json
).
Response will most probably look like:
picture:
type: object
description: Picture
properties:
link:
type: string
description: File url
filename:
type: string
description: File name
In terms of OpenAPI it means, that producer is not equal to consumer. So we can’t use the same schema for both. Is there any thing we can do?
In the world of ponies and rainbows we’ll be able to describe a model and then cut/amend it however we need. But not only it’s unsupported (such merge won’t ever be part of standard and allOf
is both broken and not meant for amending but combination), it’s also dangerous, because no one can guess how base model changes will affect derived model.
Don’t use common schemas
And just write full descriptions at every place.
But that’s going to be hell of a duplication. And, considering how redundant OpenAPI description is, the documentat is going to be huge. Impractically huge.
Take common descriptions to the upper level
I’m not talking about files, but abstraction level.
Common descriptions are property descriptions. And that’s what we’re going to take from method descriptions somewhere else.
OpenAPI does not specify what exact schemas we must should describe, so describing subschemas should be easy.
Then we’ll describe final schemas inside methods, but use property refs instead of full property descriptions.
«Hacked» API
Model properties description:
components:
schemas:
Product::name:
type: string
description: Name
Product::description:
type: string
description: Description
Product::vendor_code:
type: number
description: Vendor code
Product::picture:
type: object
description: Picture
properties:
link:
type: string
description: File url
filename:
type: string
description: File name
Product::picture::upload:
type: string
format: binary
description: Picture
Use property descriptions in methods
Create will now look like this:
paths:
/api/products:
post:
summary: Create product
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
properties:
name: {"$ref": "#/components/schemas/Product::name"}
description: {"$ref": "#/components/schemas/Product::description"}
vendor_code: {"$ref": "#/components/schemas/Product::vendor_code"}
picture: {"$ref": "#/components/schemas/Product::picture::upload"}
responses:
'200':
# ...
And get will now look like this::
paths:
/api/products/{id}:
get:
summary: Get product
parameters:
- name: id
in: query
description: Product ID
schema:
type: string
responses:
'200':
content:
application/json:
schema:
type: object
properties:
name: {"$ref": "#/components/schemas/Product::name"}
description: {"$ref": "#/components/schemas/Product::description"}
vendor_code: {"$ref": "#/components/schemas/Product::vendor_code"}
picture: {"$ref": "#/components/schemas/Product::picture"}
Some sort of conclusion
That way of describing is closer to real life actually.
One rarely write the same code to consume and to render data. But properties (even within code) are often the same (types and descriptions), except files (and alike).
Some additional shitty code
The other OpenAPI problem is this: descriptions can only be in one file. I can understand why they (OpenAPI authors) did it but it’s not that convenient.
It’s easy to fix: parse all files, merge and render into a single file.
It happend so that I already built such tool. Not sure if it works :)