Skip to content

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

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

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

<?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

<?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

<?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

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

###> 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

JWT_KEY_FILENAME=test_private.pem
JWT_PUBKEY_FILENAME=test_public.pem
JWT_PASSPHRASE=TestKeyPassHere