This is Part III of a multi-part series. Below are the links to other parts of this tutorial!
- OpenAPI Tutorial Part I: Introduction to OpenAPI
- OpenAPI Tutorial Part II: Common API Example
- OpenAPI Tutorial Part III: Paths and Basic Request Data
The end result of this article can be found at jtreminio/openapi-tutorial branch “part-iii”. You can clone it by doing
$ git clone git@github.com:jtreminio/openapi-tutorial.git
Make sure to checkout branch
part-iii
.
OpenAPI Tutorial Part II: Common API Example introduced a basic API codebase that we will begin adding OpenAPI support to.
Today we will go over adding paths, and the basic types of request data.
Is Redoc Documentation Server Running?
Before you go any further, make sure your Redoc server is running:
1
2
3
4
$ ./bin/docs.sh
Server started: http://127.0.0.1:8080
👀 Watching ./ for changes...
Adding Paths
You will notice that the jtreminio/openapi-tutorial
already contains some helpful comments to help developers know how each endpoint is supposed to work.
For example:
1
2
3
4
5
6
7
8
9
10
11
class PetController
{
/**
* POST /pet
*/
public function petCreate(RequestInterface $request): string
{
// ...
}
}
There are also endpoints using path parameters with these comments:
1
2
3
4
5
6
7
8
/**
* PUT /pet/{id}/photo
*/
public function petUpdatePhoto(RequestInterface $request): string
{
// ...
}
These comments are strictly for human readers, they do not affect your OpenAPI definitions. I included them because I expect many of your existing APIs might contain similar data. This might be the case for other parts of the codebase - you and your team have probably left behind comments to help each other better understand what the intended purpose of an endpoint is, how it is supposed to be called, what parameters it expects.
The benefit of using OpenAPI is you begin codifying these random and haphazard comments into a strict definition.
Definition of a Path
Paths are the endpoints your API makes available for users. They will contain the verb (GET
, POST
, PUT
, DELETE
, etc) and, if applicable, path parameters.
Paths combine the verb and the endpoint to create a unique definition. All this means is that POST /order
is a different path than UPDATE /order
.
Your First Path Definition
Let’s begin with PetController::petCreate()
, which does not have any path parameters:
1
2
3
4
5
6
7
8
/**
* POST /pet
*/
public function petCreate(RequestInterface $request): string
{
// ...
}
Import the OpenApi\Annotations
annotations namespace at the top of the class:
1
2
3
4
5
6
7
8
9
10
<?php
declare(strict_types=1);
namespace PetStoreApi\Controller;
use OpenApi\Annotations as OA;
use PetStoreApi\Model;
use PetStoreApi\RequestInterface;
Now, add our first path definition:
1
2
3
4
5
6
7
8
9
10
11
12
class PetController
{
/**
* @OA\Post(path="/pet",
* summary="Add a new pet",
* operationId="petCreate",
* tags={"Pet"},
* )
*/
public function petCreate(RequestInterface $request): string
{
Finally, generate the new definitions file:
1
2
3
4
5
6
7
8
9
10
11
12
13
$ ./bin/generate.php
Warning: @OA\Post() requires at least one @OA\Response()
in \PetStoreApi\Controller\PetController->petCreate()
in ./src/Controller/PetController.php on line 20
in ./vendor/zircote/swagger-php/src/Loggers/DefaultLogger.php on line 27
Wrote OAS file to ./bin/../openapi.yaml
Running php-cs-fixer
Loaded config default from "./.php-cs-fixer.php".
Using cache file ".php-cs-fixer.cache".
Ignore the warning for now, reload your Redoc browser window, and you should see the beginning of some documentation.
Your first endpoint definition |
If you take a look at your openapi.yaml
file you should see the change in the definition:
1
2
3
4
5
6
7
8
9
# ...
paths:
/pet:
post:
tags:
- Pet
summary: 'Add a new pet'
operationId: petCreate
Break It Down - Path Annotations
These four new lines of data are the core pieces of a path definition. Let’s go over each part before moving on.
Verb + Path
In our PetController::petCreate()
we have added the following:
1
2
3
4
5
6
7
8
9
10
11
12
class PetController
{
/**
* @OA\Post(path="/pet",
* summary="Add a new pet",
* operationId="petCreate",
* tags={"Pet"},
* )
*/
public function petCreate(RequestInterface $request): string
{
This is a path definition. zircote/swagger-php
has an annotation for each major verb:
@OA\Delete()
@OA\Get()
@OA\Head()
@OA\Options()
@OA\Patch()
@OA\Post()
@OA\Put()
@OA\Trace()
You really only need @OA\Delete()
, @OA\Get()
, @OA\Post()
, and @OA\Put()
.
The type of annotation you select and the path
value will be combined in the generated definition.
@OA\Post(path="/pet")
generates:
1
2
3
4
5
paths:
/pet:
post:
# ...
While @OA\Get(path="/pet")
generates:
1
2
3
4
5
paths:
/pet:
get:
# ...
If you have two paths, one with @OA\Post(path="/pet")
and the other with @OA\Get(path="/pet")
you can clearly see how OpenAPI considers these separate definitions:
1
2
3
4
5
6
7
paths:
/pet:
get:
# ...
post:
# ...
Summary & Description
We also define a summary
value, summary="Add a new pet"
. In Redoc summary
is used for the left sidebar listing all endpoints available, along with the verb (Post
). Each documentation generator will work differently and if you are using something else you will need to experiment yourself!
There is also description
which we have not used yet. It is similar to summary
except longer.
Operation ID
operationId
is a unique identifier for this path, operationId="petCreate"
. It must be unique across your entire definitions file. The convention is up to you, some teams like to use {verb}{Path}{Action}
like postPetCreate
. Others use {action}{PathRoot}
like createPet
.
I use {pathRoot}{Action}
like petCreate
. It helps group common paths together:
petCreate
petGet
petUpdate
petDelete
petList
petFindBy
petUpdatePhoto
Tags
Finally, we use tags
to group common functionality together, tags={"Pet"}
.
In Redoc this groups paths under a dropdown. You can think of them as categories. We will eventually have “Pet”, “Customer”, and “Store” tags. One endpoint can have one or more tags.
A note about curly braces
{ }
.zircote/swagger-php
uses thedoctrine/annotations
library to handle reading annotations. Unfortunately this library does not (currently) support PHP-style array braces[ ]
, which means that even for true arrays we must use curly braces{ }
.You would normally expect to see
tags=["Pet"]
but instead we must usetags={"Pet"}
.
Now it is time to let OpenAPI know what parameters this endpoint expects!
Adding a Body Definition
The POST /pet
is expecting data as part of the body request, and a possible file upload.
parameter | type |
---|---|
name | string |
age | integer |
type | string dog , cat , or fish |
status | string available , pending , or sold |
info | object DogInfo , CatInfo , or FishInfo |
photo | binary (file upload) |
This endpoint does not use the id
property in the Model\Pet
class. We will get to that later.
Body Annotations
You tell the annotations library this is a body schema by using @OA\Schema()
and defining the schema
property:
1
2
3
4
5
6
/**
* @OA\Schema(schema="Pet")
*/
class Pet
{
Add an @OA\Property()
definition to the name
property:
1
2
3
4
5
6
/**
* @var string
* @OA\Property()
*/
public $name;
The zircote/swagger-php
annotation library supports auto-detecting the property type by reading the @var
attached to each property, or detecting any default value assigned to the property. However, I find it is best to explicitly define the type:
1
2
3
4
5
6
/**
* @var string
* @OA\Property(type="string")
*/
public $name;
Now, if you generate the definitions file again ($ ./bin/generate.php
) you will see the new schema:
1
2
3
4
5
6
7
8
components:
schemas:
Pet:
properties:
name:
type: string
type: object
Do the same for age
, type
, and status
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/**
* @var int
* @OA\Property(type="integer")
*/
public $age;
// ...
/**
* @var string "dog"|"cat"|"fish"
* @OA\Property(type="string")
*/
public $type;
// ...
/**
* @var string "available"|"pending"|"sold"
* @OA\Property(type="string")
*/
public $status = 'available';
So far the types are simple, type="integer"
and type="string"
. What would you expect a file upload to be? If you guessed type="file"
or type="binary"
you would be forgiven for being wrong.
1
2
3
4
5
6
7
8
/**
* @var \SplFileInfo|null
* @OA\Property(type="string",
* format="binary",
* )
*/
public $photo = null;
For now, let’s skip over adding a definition for
info
. I promise we will come back to it in later parts of this series, but discriminators are a bit more advanced than what you are learning right now.
Regenerate your definitions file ($ ./bin/generate.php
) and behold:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
components:
schemas:
Pet:
properties:
name:
type: string
age:
type: integer
photo:
type: string
format: binary
type:
description: '"dog"|"cat"|"fish"'
type: string
status:
description: '"available"|"pending"|"sold"'
type: string
type: object
Break It Down - Body Annotations
zircote/swagger-php
is helping us out quite a bit here. We first tell it that this entire class is a schema definition:
1
2
3
4
5
6
/**
* @OA\Schema(schema="Pet")
*/
class Pet
{
That generates this YAML:
1
2
3
4
components:
schemas:
Pet:
You can change schema
to any unique name you want, it is completely separate from the actual class name. We only used the same name for class and schema to make this easier to learn.
With the above, zircote/swagger-php
now knows that the class directly following the annotation will contain more annotations that should be added to the definition. That is why this annotation works:
1
2
3
4
5
6
/**
* @var string "dog"|"cat"|"fish"
* @OA\Property(type="string")
*/
public $type;
The @OA\Property()
annotations works in a similar way to the @OA\Schema()
annotations - it is attached to the property directly following the annotation. So, the following would not affect $info
:
1
2
3
4
5
6
7
8
9
10
11
/**
* @var string "dog"|"cat"|"fish"
* @OA\Property(type="string")
*/
public $type;
/**
* @var DogInfo|CatInfo|FishInfo
*/
public $info;
You will also notice that any text added in the @var
docblock is used as the parameter’s description. We can change this later but keep it for now.
1
2
3
4
type:
description: '"dog"|"cat"|"fish"'
type: string
If you reload your documentation page you will not actually see any of these changes! For that, we need to reference this new Pet
schema from the POST /pet
path.
Referencing a Schema
Back in PetController::petCreate()
, update the path annotation to:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
* @OA\Post(path="/pet",
* summary="Add a new pet",
* operationId="petCreate",
* tags={"Pet"},
* @OA\RequestBody(
* @OA\MediaType(mediaType="multipart/form-data",
* @OA\Schema(ref="#/components/schemas/Pet"),
* ),
* ),
* )
*/
public function petCreate(RequestInterface $request): string
{
Refresh your documentation page and see the magic:
Your first request body |
Break It Down - RequestBody Annotations
I just introduced two new annotations to you, @OA\RequestBody()
and @OA\MediaType()
.
For an endpoint that expects body data (for example, from forms) we use the @OA\RequestBody()
annotation. This annotation should only be combined with @OA\Post()
and @OA\Put()
(and @OA\Patch()
if you ever use it).
@OA\MediaType()
defines the content-type this endpoint will accept. For any endpoint that will accept a file upload, you must use multipart/form-data
. You can use anything here, but should probably stick with the most common:
application/json
application/xml
text/plain; charset=utf-8
You can see more examples in Swagger’s Media Type page.
So far what we have is
1
2
3
4
5
6
7
8
9
10
11
12
paths:
/pet:
post:
tags:
- Pet
summary: 'Add a new pet'
operationId: petCreate
requestBody:
content:
multipart/form-data:
# ...
Finally, you see the powerful ref
for the first time. ref
deserves to have a large chunk of text explaining it, but for now let’s keep it simple.
At a Glance - ref
ref
is actually $ref
when written to the openapi.yaml
file. However, our annotations library expects ref
. Here is what it looks like:
1
2
3
4
5
6
7
8
9
10
11
12
13
paths:
/pet:
post:
tags:
- Pet
summary: 'Add a new pet'
operationId: petCreate
requestBody:
content:
multipart/form-data:
schema:
$ref: '#/components/schemas/Pet'
We use this special tool to reference schema defined in other parts of our definitions file. The first parts are the location OpenAPI can find the schema: #/components/schemas
.
The last part is the actual schema name, Pet
.
This is powerful because it allows us to reuse schemas without having to copy and paste their definitions over and over.
Enum Property Types
So far we have only added simple property types to our definitions, those that are string
, integer
, or string::binary
(file upload).
The string::binary
type clues us in that we can have further sub-types. One such example are enums.
The Pet::$type
property is currently defined as
1
2
3
4
5
6
/**
* @var string "dog"|"cat"|"fish"
* @OA\Property(type="string")
*/
public $type;
However, the "dog"|"cat"|"fish"
part is only a docblock description, it does not actually apply to the OpenAPI definition (outside of description).
We can easily tell OpenAPI this is an enum by using string::enum
:
1
2
3
4
5
6
7
8
/**
* @var string
* @OA\Property(type="string",
* enum={"dog", "cat", "fish"}
* )
*/
public $type;
Now our definitions file knows this is an enum and will require that the string is only one of the allowed:
1
2
3
4
5
6
7
8
9
10
11
12
13
components:
schemas:
Pet:
properties:
# ...
type:
type: string
enum:
- dog
- cat
- fish
type: object
Do the same for status
:
1
2
3
4
5
6
7
8
/**
* @var string
* @OA\Property(type="string",
* enum={"available", "pending", "sold"}
* )
*/
public $status = 'available';
Regenerate your descriptions file:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
components:
schemas:
Pet:
properties:
# ...
type:
type: string
enum:
- dog
- cat
- fish
status:
type: string
enum:
- available
- pending
- sold
type: object
Now reload your Redoc browser window and we see something useful:
Enums example |
But Wait, There’s More
So far we have used zircote/swagger-php
for things that we can easily do by manually-creating the definitions file. Let me show you one immediate benefit!
Enums are just arrays of values and are defined as you would expect:
1
2
3
4
5
6
7
8
/**
* @var string
* @OA\Property(type="string",
* enum={"dog", "cat", "fish"}
* )
*/
public $type;
Since the annotations live in a PHP class, we can benefit from actual PHP code in the annotations. If this enum is just an array, why not create a constant that contains the possible values?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
/**
* @OA\Schema(schema="Pet")
*/
class Pet
{
public const TYPE_ENUM = [
'dog',
'cat',
'fish',
];
public const STATUS_ENUM = [
'available',
'pending',
'sold',
];
// ...
/**
* @var string
* @OA\Property(type="string",
* enum=\PetStoreApi\Model\Pet::TYPE_ENUM
* )
*/
public $type;
// ...
/**
* @var string
* @OA\Property(type="string",
* enum=\PetStoreApi\Model\Pet::STATUS_ENUM
* )
*/
public $status = 'available';
Note that you need to use the FQDN when reference a constant in this manner.
This is only the most basic of what you can do with constants. As we go further along in this series we will see more useful examples!
Wrapping It Up
We have our first path defined! It even has some (basic) parameters, including a file upload and two enums!
In the next part of this series we will create more paths and their parameters, and we will go over how to add complex parameter types like arrays, objects, and arrays of objects.
So much left to do, but having the Redoc documentation to see our changes as we make them is a great motivator. I encourage you to continue playing with the API and reading both the Swagger OpenAPI Guide and the zircote/swagger-php
documentation to further familiarize yourself with the concepts we have learned so far, and what we will learn in the upcoming parts of this series.
Until next time, this is Señor PHP Developer Juan Treminio wishing you adios!