Creating a REST API

If you are creating a REST API then by default you should follow these practices

Use OpenAPI3

There are quite a few API standards. We have settled on OpenAPI 3 as the best one for our requirements.

This is the latest version of what was called Swagger

Code First or Design First

Quick Explanation

Design first means that you would manually create your OpenAPI spec, use that to generate client code and models and then use those generated models to build your server side logic. This is a valid approach however there are some drawbacks and on balance we have decided to go with Code First.

We Use Code First

Code first means that you build your server side code, including all models, and then use that to generate the OpenAPI spec.

The API should be designed code first. That means that the primary source of truth is your API code models and controllers.

The API spec should be automatically generated based on your code and the supporting annotations.

Generating the Spec

To generate the API spec, we use the zircote/swagger-php library, which has become the standard solution for PHP.

There are other libraries and bundles that wrap this library, however it is better to use it on it's own, as it is very simple and this avoids version contraint and dependency issues.

To generate a spec object, its as simple as: ```php <?php

1
2
3
4
private function createSpec(): OpenApi
{
    return scan($this->environmentHelper->getProjectDir() . '/src/');
}

It physically scans every PHP file in the directory you pass and returns an instance of `OpenApi` You can then generate json or yaml from this object, for example:php <?php $openapi->toJson(); ```

Writing the Annotations for the spec

You add comments to:

  • Controllers
  • Models

For your top level API comments, eg

1
2
3
@OA\OpenApi(
    @OA\Info(title="Edmonds Commerce API", version="1.0")
)
these should go on your IndexController, for example:

 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
final class IndexController extends AbstractController
{
    public const ROUTE_INDEX      = '/';
    public const ROUTE_INDEX_NAME = 'index';
    public const ROUTE_DOCS       = '/openapi.json';
    public const ROUTE_DOCS_NAME  = 'docs';
    /**
     * @var OpenApiGenerator
     */
    private $apiGenerator;

    public function __construct(OpenApiGenerator $apiGenerator)
    {
        $this->apiGenerator = $apiGenerator;
    }

    /**
     * @Route(IndexController::ROUTE_INDEX, name=IndexController::ROUTE_INDEX_NAME)
     */
    public function redirectToApiDocs(): RedirectResponse
    {
        return $this->redirectToRoute(self::ROUTE_DOCS_NAME);
    }

    /**
     * @OA\OpenApi(
     *   @OA\Info(title="Edmonds Commerce API", version="1.0")
     * )
     * @Route(IndexController::ROUTE_DOCS, name=IndexController::ROUTE_DOCS_NAME)
     */
    public function index(): JsonResponse
    {
        return new JsonResponse($this->apiGenerator->getJsonString(), Response::HTTP_OK, [], true);
    }
}

You should refer to the documentation on the zircote module, with some caveats:

  • You should use class constants wherever possible instead of "magic strings" :(
  • You should make extensive use of references, and every object should be a model in it's own right
  • You should provide examples, but need to handle these with some special moves

Adding Examples

If you want to add examples, you will find that it is impossible to do this using Annotations as your example JSON will be double encoded due to the way this works.

To generate your example in pure JSON, you actually need to pass in an instance of stdClass or real array as required.

The best way to do this is to post process the OpenApi object before calling toJson

 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
<?php
    private function injectExamples(OpenApi $spec): void
    {
        $errors = [];
        foreach ($spec->components->schemas as $component) {
            if (isset(self::EXAMPLES_MAP[$component->schema])) {
                try {
                    $component->example = $this->jsonDecode->decode(
                        self::EXAMPLES_MAP[$component->schema],
                        JsonEncoder::FORMAT
                    );
                } catch (NotEncodableValueException $exception) {
                    $errors[$component->schema] = 'Invalid JSON: ' . self::EXAMPLES_MAP[$component->schema];
                }
                continue;
            }
            $errors[$component->schema] = 'no example in map';
        }
        if ([] === $errors || $this->environmentHelper->isDebug() === false) {
            return;
        }
        throw new RuntimeException(
            'Components without examples defined in '
            . 'MyApiProject\Helper\OpenApiGenerator::EXAMPLES_MAP : '
            . print_r($errors, true)
        );
    }

Adding Error Reponses

Generally every single one of your routes will have error response codes. This is very repetitive and easier to add using post processing

 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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
<?php
          /**
          * Loop through all the paths in the spec and inject all the standard error responses
          *
          * @param OpenApi $spec
          */
         private function injectErrorResponses(OpenApi $spec): void
         {
             foreach ($spec->paths as $path) {
                 if ($path->post instanceof Operation) {
                     $this->injectErrorResponsesToPathMethod($path->post);
                 }
                 if ($path->get instanceof Operation) {
                     $this->injectErrorResponsesToPathMethod($path->get);
                 }
                 if ($path->put instanceof Operation) {
                     $this->injectErrorResponsesToPathMethod($path->put);
                 }
                 if ($path->patch instanceof Operation) {
                     $this->injectErrorResponsesToPathMethod($path->patch);
                 }
                 if ($path->delete instanceof Operation) {
                     $this->injectErrorResponsesToPathMethod($path->delete);
                 }
                 if ($path->trace instanceof Operation) {
                     $this->injectErrorResponsesToPathMethod($path->trace);
                 }
                 if ($path->options instanceof Operation) {
                     $this->injectErrorResponsesToPathMethod($path->options);
                 }
                 if ($path->head instanceof Operation) {
                     $this->injectErrorResponsesToPathMethod($path->head);
                 }
             }
         }

         private function injectErrorResponsesToPathMethod(Operation $operation): void
         {
             $errorCodes = [
                 SymfonyResponse::HTTP_INTERNAL_SERVER_ERROR,
                 SymfonyResponse::HTTP_BAD_REQUEST,
                 SymfonyResponse::HTTP_NOT_FOUND,
                 SymfonyResponse::HTTP_UNAUTHORIZED,
             ];
             foreach ($errorCodes as $code) {
                 $operation->responses[] = $this->createErrorResponse($code);
             }
         }

         private function createErrorResponse(int $code): Response
         {
             return new Response(
                 [
                     'response'    => $code,
                     'description' => 'A ' . $code . ' error',
                     'content'     => [
                         self::JSON_CONTENT_TYPE =>
                             new MediaType(
                                 [
                                     'mediaType' => self::JSON_CONTENT_TYPE,
                                     'schema'    => new JsonContent(['ref' => Error::OA_SCHEMA_REF]),
                                 ]
                             ),
                     ],
                 ]
             );
         }

Full Example API Generator Helper Class

Here is a full example class

  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
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
<?php

declare(strict_types=1);

namespace MyApiProject\Helper;

use MyApiProject\Model\Error;
use MyApiProject\Model\Order\OrderAddressModel;
use MyApiProject\Model\Order\OrderCommentModel;
use MyApiProject\Model\Order\OrderCommentModelCollection;
use MyApiProject\Model\Order\OrderDetailsModel;
use MyApiProject\Model\Order\OrderLineItemModel;
use MyApiProject\Model\Order\OrderLineItemModelCollection;
use MyApiProject\Model\Order\OrderStatusModel;
use MyApiProject\Model\OrderModel;
use MyApiProject\Model\Product\ProductIdCollection;
use MyApiProject\Model\Product\ProductSkuCollection;
use MyApiProject\Model\ProductModel;
use MyApiProject\Model\ProductModelCollection;
use OpenApi\Annotations\JsonContent;
use OpenApi\Annotations\MediaType;
use OpenApi\Annotations\OpenApi;
use OpenApi\Annotations\Operation;
use OpenApi\Annotations\Response;
use Psr\Cache\CacheItemPoolInterface;
use RuntimeException;
use Symfony\Component\HttpFoundation\Response as SymfonyResponse;
use Symfony\Component\Serializer\Encoder\JsonDecode;
use Symfony\Component\Serializer\Encoder\JsonEncoder;
use Symfony\Component\Serializer\Exception\NotEncodableValueException;
use function OpenApi\scan;

final class OpenApiGenerator
{
    private const CACHE_KEY_JSON    = 'api-spec-json';
    private const CACHE_KEY_SPEC    = 'api-spec-instance';
    private const JSON_CONTENT_TYPE = 'application/json';

    private const EXAMPLES_MAP = [
        Error::OA_SCHEMA_NAME                        => Error::EXAMPLE_JSON,
        OrderAddressModel::OA_SCHEMA_NAME            => OrderAddressModel::EXAMPLE_JSON,
        OrderCommentModel::OA_SCHEMA_NAME            => OrderCommentModel::EXAMPLE_JSON,
        OrderCommentModelCollection::OA_SCHEMA_NAME  => OrderCommentModelCollection::EXAMPLE_JSON,
        OrderDetailsModel::OA_SCHEMA_NAME            => OrderDetailsModel::EXAMPLE_JSON,
        OrderLineItemModel::OA_SCHEMA_NAME           => OrderLineItemModel::EXAMPLE_JSON,
        OrderLineItemModelCollection::OA_SCHEMA_NAME => OrderLineItemModelCollection::EXAMPLE_JSON,
        OrderStatusModel::OA_SCHEMA_NAME             => OrderStatusModel::EXAMPLE_JSON,
        OrderModel::OA_SCHEMA_NAME                   => OrderModel::EXAMPLE_JSON,
        ProductModel::OA_SCHEMA_NAME                 => ProductModel::EXAMPLE_JSON,
        ProductIdCollection::OA_SCHEMA_NAME          => ProductIdCollection::EXAMPLE_JSON,
        ProductSkuCollection::OA_SCHEMA_NAME         => ProductSkuCollection::EXAMPLE_JSON,
        ProductModelCollection::OA_SCHEMA_NAME       => ProductModelCollection::EXAMPLE_JSON,
    ];

    /**
     * @var EnvironmentHelper
     */
    private $environmentHelper;
    /**
     * @var string
     */
    private $jsonString;
    /**
     * @var CacheItemPoolInterface
     */
    private $cache;
    /**
     * @var OpenApi
     */
    private $spec;
    /**
     * @var JsonDecode
     */
    private $jsonDecode;

    public function __construct(
        EnvironmentHelper $environmentHelper,
        CacheItemPoolInterface $cache,
        ?JsonDecode $jsonDecode
    ) {
        $this->environmentHelper = $environmentHelper;
        $this->cache             = $cache;
        $this->jsonDecode        = $jsonDecode ?? new JsonDecode();
    }

    public function getJsonString(): string
    {
        if ($this->jsonString !== null) {
            return $this->jsonString;
        }
        if ($this->environmentHelper->isDebug()) {
            $this->jsonString = $this->createJsonString();

            return $this->jsonString;
        }
        $cache            = $this->cache->getItem(self::CACHE_KEY_JSON);
        if ($cache->isHit()) {
            $this->jsonString = $cache->get();

            return $this->jsonString;
        }
        $this->jsonString = $this->createJsonString();

        return $this->jsonString;
    }

    private function createJsonString(): string
    {
        $openapi = $this->getSpec();

        return $openapi->toJson();
    }

    public function getSpec(): OpenApi
    {
        if ($this->spec !== null) {
            return $this->spec;
        }
        if ($this->environmentHelper->isDebug()) {
            $this->spec = $this->createSpec();

            return $this->spec;
        }
        $cache      = $this->cache->getItem(self::CACHE_KEY_SPEC);
        if ($cache->isHit()) {
            $this->spec = $cache->get();

            return $this->spec;
        }
        $this->spec = $this->createSpec();

        return $this->spec;
    }

    private function createSpec(): OpenApi
    {
        $spec = scan($this->environmentHelper->getProjectDir() . '/src/');
        $this->injectExamples($spec);
        $this->injectErrorResponses($spec);

        return $spec;
    }

    private function injectExamples(OpenApi $spec): void
    {
        $errors = [];
        foreach ($spec->components->schemas as $component) {
            if (isset(self::EXAMPLES_MAP[$component->schema])) {
                try {
                    $component->example = $this->jsonDecode->decode(
                        self::EXAMPLES_MAP[$component->schema],
                        JsonEncoder::FORMAT
                    );
                } catch (NotEncodableValueException $exception) {
                    $errors[$component->schema] = 'Invalid JSON: ' . self::EXAMPLES_MAP[$component->schema];
                }
                continue;
            }
            $errors[$component->schema] = 'no example in map';
        }
        if ([] === $errors || $this->environmentHelper->isDebug() === false) {
            return;
        }
        throw new RuntimeException(
            'Components without examples defined in '
            . 'MyApiProject\Helper\OpenApiGenerator::EXAMPLES_MAP : '
            . print_r($errors, true)
        );
    }

    /**
     * Loop through all the paths in the spec and inject all the standard error responses
     *
     * @param OpenApi $spec
     */
    private function injectErrorResponses(OpenApi $spec): void
    {
        foreach ($spec->paths as $path) {
            if ($path->post instanceof Operation) {
                $this->injectErrorResponsesToPathMethod($path->post);
            }
            if ($path->get instanceof Operation) {
                $this->injectErrorResponsesToPathMethod($path->get);
            }
            if ($path->put instanceof Operation) {
                $this->injectErrorResponsesToPathMethod($path->put);
            }
            if ($path->patch instanceof Operation) {
                $this->injectErrorResponsesToPathMethod($path->patch);
            }
            if ($path->delete instanceof Operation) {
                $this->injectErrorResponsesToPathMethod($path->delete);
            }
            if ($path->trace instanceof Operation) {
                $this->injectErrorResponsesToPathMethod($path->trace);
            }
            if ($path->options instanceof Operation) {
                $this->injectErrorResponsesToPathMethod($path->options);
            }
            if ($path->head instanceof Operation) {
                $this->injectErrorResponsesToPathMethod($path->head);
            }
        }
    }

    private function injectErrorResponsesToPathMethod(Operation $operation): void
    {
        $errorCodes = [
            SymfonyResponse::HTTP_INTERNAL_SERVER_ERROR,
            SymfonyResponse::HTTP_BAD_REQUEST,
            SymfonyResponse::HTTP_NOT_FOUND,
            SymfonyResponse::HTTP_UNAUTHORIZED,
        ];
        foreach ($errorCodes as $code) {
            $operation->responses[] = $this->createErrorResponse($code);
        }
    }

    private function createErrorResponse(int $code): Response
    {
        return new Response(
            [
                'response'    => $code,
                'description' => 'A ' . $code . ' error',
                'content'     => [
                    self::JSON_CONTENT_TYPE =>
                        new MediaType(
                            [
                                'mediaType' => self::JSON_CONTENT_TYPE,
                                'schema'    => new JsonContent(['ref' => Error::OA_SCHEMA_REF]),
                            ]
                        ),
                ],
            ]
        );
    }
}

Securing the API

For securing a REST API, we suggest using JSON Web Tokens (JWT).

There is a well supported and documented Symfony bundle that handles this:

https://github.com/lexik/LexikJWTAuthenticationBundle

Installing

Follow the documentation

https://github.com/lexik/LexikJWTAuthenticationBundle/blob/master/Resources/doc/index.md

Key Management

By following the instructions, you should have generated some keys in config/jwt. We never want to track production keys, but we do want to track keys used for testing.

The default installation will add

1
2
3
###> lexik/jwt-authentication-bundle ###
/config/jwt/*.pem
###< lexik/jwt-authentication-bundle ###
To your project .gitignore file. This should be updated as follows:

1
2
3
4
5
###> lexik/jwt-authentication-bundle ###
/config/jwt/*.pem
!/config/jwt/test_public.pem
!/config/jwt/test_private.pem
###< lexik/jwt-authentication-bundle ###

Json Login

The JWT system relies on the standard Symfony JSON login system

https://symfony.com/doc/current/security/json_login_setup.html

Testing

When using JWT in your project, you need to take care of being able to test

Authenticate Client

You need to be able to create an authenticaed client

Follow the docs: https://github.com/lexik/LexikJWTAuthenticationBundle/blob/master/Resources/doc/3-functional-testing.md

Test Config for Keys

Your .env.test file should include the test keys

1
2
3
JWT_KEY_FILENAME=test_private.pem
JWT_PUBKEY_FILENAME=test_public.pem
JWT_PASSPHRASE=TestKeyPassHere