Improve your API with OpenAPI Rob Allen PHPUK, February 2022
A presentation at PHP UK in February 2022 in London, UK by Rob Allen
Improve your API with OpenAPI Rob Allen PHPUK, February 2022
APIs Power the Internet Rob Allen ~ @akrabat
APIs Power the Internet API Descriptions Power APIs Rob Allen ~ @akrabat
The OpenAPI Specification (OAS) defines a standard, programming language-agnostic interface description for HTTP APIs, which allows both humans and computers to discover and understand the capabilities of a service https://spec.openapis.org/oas/latest.html Rob Allen ~ @akrabat
It’s about documentation Rob Allen ~ @akrabat
It’s about design-first Rob Allen ~ @akrabat
It’s about communicating changes Rob Allen ~ @akrabat
It’s about development workflows Rob Allen ~ @akrabat
It’s about standardisation Rob Allen ~ @akrabat
It’s about a contract Rob Allen ~ @akrabat
“Using a consistent API description will help increase adoption of APIs across government by reducing time spent in understanding different APIs. gov.uk Rob Allen ~ @akrabat
Anatomy of the specification Rob Allen ~ @akrabat
openapi.yaml openapi: “3.1.0” # or “3.0.3” info: # … servers: # … paths: # … webhooks: # … components: # … security: # … tags: # … externalDocs: # … Rob Allen ~ @akrabat
Metadata info: title: Rock-Paper-Scissors version: “1.0.0” description: An implementation of Rock-Paper-Scissors. contact: name: “Rob Allen” license: name: The MIT License servers: - url: https://rock-paper-scissors.example.com description: “RPS production API” Rob Allen ~ @akrabat
Endpoints paths: /games: get: # … post: # … ‘/games/{game_id}/moves’: post: # … ‘/games/{game_id}/judgement’: get: # … Rob Allen ~ @akrabat
Endpoints paths: /games: post: operationId: createGame summary: Create a new game description: Create a new game of Rock-Paper-Scissors. requestBody: # … responses: # … Rob Allen ~ @akrabat
Endpoints paths: /games: post: operationId: createGame summary: Create a new game description: Create a new game of Rock-Paper-Scissors. requestBody: # … responses: # … Rob Allen ~ @akrabat
Endpoints paths: /games: post: operationId: createGame summary: Create a new game description: Create a new game of Rock-Paper-Scissors. requestBody: # … responses: # … Rob Allen ~ @akrabat
Endpoints paths: /games: post: operationId: createGame summary: Create a new game description: Create a new game of Rock-Paper-Scissors. requestBody: # … responses: # … Rob Allen ~ @akrabat
RequestBody requestBody: description: Game to add required: true content: application/json: schema: $ref: ‘#/components/schemas/NewGameRequest’ Rob Allen ~ @akrabat
RequestBody requestBody: description: Game to add required: true content: application/json: schema: $ref: ‘#/components/schemas/NewGameRequest’ Rob Allen ~ @akrabat
RequestBody requestBody: description: Game to add required: true content: application/json: schema: $ref: ‘#/components/schemas/NewGameRequest’ Rob Allen ~ @akrabat
Reuse of objects $ref allows us to define once & use in many places components: schemas: GameId: type: string format: “uuid” examples: - “2BC08389-885A-4322-80D0-EF0DE2D7CD37” Player: type: string example: “Lucy” Rob Allen ~ @akrabat
Reuse of objects $ref allows us to define once & use in many places components: schemas: GameId: type: string format: “uuid” examples: - “2BC08389-885A-4322-80D0-EF0DE2D7CD37” Player: type: string example: “Lucy” Rob Allen ~ @akrabat
Reuse of objects $ref allows us to define once & use in many places components: schemas: GameId: type: string format: “uuid” examples: - “2BC08389-885A-4322-80D0-EF0DE2D7CD37” Player: type: string example: “Lucy” Rob Allen ~ @akrabat
Reuse of objects $ref allows us to define once & use in many places components: schemas: GameId: type: string format: “uuid” examples: - “2BC08389-885A-4322-80D0-EF0DE2D7CD37” Player: type: string example: “Lucy” Rob Allen ~ @akrabat
Reuse of objects $ref allows us to define once & use in many places components: schemas: GameId: type: string format: “uuid” examples: - “2BC08389-885A-4322-80D0-EF0DE2D7CD37” Player: type: string example: “Lucy” Rob Allen ~ @akrabat
Reuse of objects $ref allows us to define once & use in many places components: schemas: GameId: type: string format: “uuid” examples: - “2BC08389-885A-4322-80D0-EF0DE2D7CD37” Player: type: string example: “Lucy” Rob Allen ~ @akrabat
Build on top of other components schemas: NewGameRequest: properties: player1: $ref: ‘#/components/schemas/Player’ player2: $ref: ‘#/components/schemas/Player’ required: - player1 - player2 examples: - ‘{“player1”:”Lucy”, “player2”:”Dave”}’ Rob Allen ~ @akrabat
Build on top of other components schemas: NewGameRequest: properties: player1: $ref: ‘#/components/schemas/Player’ player2: $ref: ‘#/components/schemas/Player’ required: - player1 - player2 examples: - ‘{“player1”:”Lucy”, “player2”:”Dave”}’ Rob Allen ~ @akrabat
Build on top of other components schemas: NewGameRequest: properties: player1: $ref: ‘#/components/schemas/Player’ player2: $ref: ‘#/components/schemas/Player’ required: - player1 - player2 examples: - ‘{“player1”:”Lucy”, “player2”:”Dave”}’ Rob Allen ~ @akrabat
Build on top of other components schemas: NewGameRequest: properties: player1: $ref: ‘#/components/schemas/Player’ player2: $ref: ‘#/components/schemas/Player’ required: - player1 - player2 examples: - ‘{“player1”:”Lucy”, “player2”:”Dave”}’ Rob Allen ~ @akrabat
Build on top of other components schemas: NewGameRequest: properties: player1: $ref: ‘#/components/schemas/Player’ player2: $ref: ‘#/components/schemas/Player’ required: - player1 - player2 examples: - ‘{“player1”:”Lucy”, “player2”:”Dave”}’ Rob Allen ~ @akrabat
RequestBody requestBody: description: Game to add required: true content: application/json: schema: $ref: ‘#/components/schemas/NewGameRequest’ Rob Allen ~ @akrabat
Responses responses: ‘201’: $ref: ‘#/components/responses/NewGameResponse’ ‘400’: $ref: ‘#/components/responses/NewGameError’ ‘500’: $ref: ‘#/components/responses/InternalServerError’ Rob Allen ~ @akrabat
Responses responses: ‘201’: $ref: ‘#/components/responses/NewGameResponse’ ‘400’: $ref: ‘#/components/responses/NewGameError’ ‘500’: $ref: ‘#/components/responses/InternalServerError’ Rob Allen ~ @akrabat
Responses responses: ‘201’: $ref: ‘#/components/responses/NewGameResponse’ ‘400’: $ref: ‘#/components/responses/NewGameError’ ‘500’: $ref: ‘#/components/responses/InternalServerError’ Rob Allen ~ @akrabat
Writing your spec Rob Allen ~ @akrabat
Editing It’s just text! Rob Allen ~ @akrabat
Editing GUI tools: Stoplight, OpenAPI-GUI, Swagger Editor Rob Allen ~ @akrabat
Linting & validation CLI tools: Spectral, openapi-spec-validator, etc. $ spectral lint rps-openapi.yaml No results with a severity of ‘error’ or higher found! Rob Allen ~ @akrabat
Validation error $ spectral lint rps-openapi.yaml …/slim4-rps-api/doc/rps-openapi.yaml 3:6 warning info-contact Info object must have “contact” object. info × 1 problem (0 errors, 1 warning, 0 infos, 0 hints) Rob Allen ~ @akrabat
Docs Rob Allen ~ @akrabat
Docs Rob Allen ~ @akrabat
Docs Rob Allen ~ @akrabat
Docs Rob Allen ~ @akrabat
Docs Rob Allen ~ @akrabat
Docs Rob Allen ~ @akrabat
Developers Rob Allen ~ @akrabat
Mock server $ prism mock rps-openapi.yaml Rob Allen ~ @akrabat
Make API calls $ curl http://127.0.0.1:4010/games -d ‘{}’ Rob Allen ~ @akrabat
Make API calls $ curl http://127.0.0.1:4010/games -d ‘{}’ {“message”:”Must provide both player1 and player2”} Rob Allen ~ @akrabat
Make API calls $ curl http://127.0.0.1:4010/games -d ‘{}’ {“message”:”Must provide both player1 and player2”} Rob Allen ~ @akrabat
Validation The schema section can be used to validate the request and response • Validate early and return a 422 • Validate that we return what we say we will • Put it in CI to prevent regressions Rob Allen ~ @akrabat
But I already have validation! Your code: • isn’t good enough! • isn’t reusable! • doesn’t match the docs! Rob Allen ~ @akrabat
But I already have validation! Your code: • isn’t good enough! • isn’t reusable! • doesn’t match the docs! However… Business logic validation still needed! Rob Allen ~ @akrabat
Validation in PHP OpenAPI 3.0: league/openapi-psr7-validator OpenAPI 3.1: opis/json-schema Rob Allen ~ @akrabat
Validation middleware Rob Allen ~ @akrabat
Test Request Rob Allen ~ @akrabat
Request is invalid Rob Allen ~ @akrabat
Request is invalid Rob Allen ~ @akrabat
Test Request Rob Allen ~ @akrabat
Request is valid Rob Allen ~ @akrabat
Test Response Rob Allen ~ @akrabat
Response is invalid Rob Allen ~ @akrabat
Response is invalid Rob Allen ~ @akrabat
Successful validation Rob Allen ~ @akrabat
Successful validation Rob Allen ~ @akrabat
Validation middleware class OpenApiValidationMiddleware implements MiddlewareInterface { public function __construct(string $oasFilename, Cache $cache) { $builder = new ValidatorBuilder(); $builder->fromYamlFile($oasFilename); $builder->setCache($cache)->overrideCacheKey(‘openapi’); $this->reqValidator = $builder->getServerRequestValidator(); $this->respValidator = $builder->getResponseValidator(); } public function process($request, $handler) { try { // validate request $match = $this->reqValidator->validate($request); } catch (ValidationFailed $e) { throw new HttpException($request, 422, $e); } // process $response = $handler->handle($request); try { // validate response $this->respValidator->validate($match, $response); return $response; } catch (ValidationFailed $e) { throw new HttpException($request, 500, $e); } } } Rob Allen ~ @akrabat
Validation middleware public function __construct(string $oasFilename, Cache $cache) { $builder = new ValidatorBuilder(); $builder->fromYamlFile($oasFilename); $builder->setCache($cache)->overrideCacheKey(‘openapi’); $this->reqValidator = $builder->getServerRequestValidator(); $this->respValidator = $builder->getResponseValidator(); } Rob Allen ~ @akrabat
Validation middleware public function __construct(string $oasFilename, Cache $cache) { $builder = new ValidatorBuilder(); $builder->fromYamlFile($oasFilename); $builder->setCache($cache)->overrideCacheKey(‘openapi’); $this->reqValidator = $builder->getServerRequestValidator(); $this->respValidator = $builder->getResponseValidator(); } Rob Allen ~ @akrabat
Validation middleware public function __construct(string $oasFilename, Cache $cache) { $builder = new ValidatorBuilder(); $builder->fromYamlFile($oasFilename); $builder->setCache($cache)->overrideCacheKey(‘openapi’); $this->reqValidator = $builder->getServerRequestValidator(); $this->respValidator = $builder->getResponseValidator(); } Rob Allen ~ @akrabat
Validation middleware public function __construct(string $oasFilename, Cache $cache) { $builder = new ValidatorBuilder(); $builder->fromYamlFile($oasFilename); $builder->setCache($cache)->overrideCacheKey(‘openapi’); $this->reqValidator = $builder->getServerRequestValidator(); $this->respValidator = $builder->getResponseValidator(); } Rob Allen ~ @akrabat
Validation middleware public function process($request, $handler) { try { // validate request $match = $this->reqValidator->validate($request); } catch (ValidationFailed $e) { throw new HttpException($request, 422, $e); } Rob Allen ~ @akrabat
Validation middleware public function process($request, $handler) { try { // validate request $match = $this->reqValidator->validate($request); } catch (ValidationFailed $e) { throw new HttpException($request, 422, $e); } Rob Allen ~ @akrabat
Validation middleware public function process($request, $handler) { try { // validate request $match = $this->reqValidator->validate($request); } catch (ValidationFailed $e) { throw new HttpException($request, 422, $e); } Rob Allen ~ @akrabat
Validation middleware public function process($request, $handler) { … // process $response = $handler->handle($request); Rob Allen ~ @akrabat
Validation middleware public function process($request, $handler) { … try { // validate response $this->respValidator->validate($match, $response); return $response; } catch (ValidationFailed $e) { throw new HttpException($request, 500, $e); } } Rob Allen ~ @akrabat
Validation middleware public function process($request, $handler) { … try { // validate response $this->respValidator->validate($match, $response); return $response; } catch (ValidationFailed $e) { throw new HttpException($request, 500, $e); } } Rob Allen ~ @akrabat
Validation middleware public function process($request, $handler) { … try { // validate response $this->respValidator->validate($match, $response); return $response; } catch (ValidationFailed $e) { throw new HttpException($request, 500, $e); } } Rob Allen ~ @akrabat
Compliance Testing Schemathesis reads your OpenAPI spec and tests your API against it pip install schemathesis schemathesis run —stateful=links —checks all \ —base-url=http://localhost:8888 \ doc/rps-openapi.yaml Rob Allen ~ @akrabat
Compliance Testing Rob Allen ~ @akrabat
Other Interesting Tools • • • • Optic: BC Break Detection php-openapi-faker: Create fake data from OpenAPI spec Response2Schema: Generate OpenAPI spec from JSON object Laravel OpenAPI: Generate OpenAPI spec from a Laravel app Many more at https://openapi.tools Rob Allen ~ @akrabat
To sum up Rob Allen ~ @akrabat
Resources • https://www.openapis.org • https://openapi.tools • https://github.com/thephpleague/openapi-psr7-validator • https://github.com/akrabat/slim4-rps-api Rob Allen ~ @akrabat
Rob Allen ~ @akrabat
Thank you! Rob Allen ~ @akrabat
Photo credits - Scaffolding: https://www.flickr.com/photos/pagedooley/49683539647 - Writing: https://www.flickr.com/photos/throughkikslens/14516757158 - Books: https://www.flickr.com/photos/eternaletulf/41166888495 - Computer code: https://www.flickr.com/photos/n3wjack/3856456237 - Rocket launch: https://www.flickr.com/photos/gsfc/16495356966 - Stars: https://www.flickr.com/photos/gsfc/19125041621 Rob Allen ~ @akrabat