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 |
|
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")
)
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 ###
.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