Improve your API with OpenAPI Rob Allen Longhorn PHP, November 2022
A presentation at Longhorn PHP in November 2022 in Austin, TX, USA by Rob Allen
Improve your API with OpenAPI Rob Allen Longhorn PHP, November 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! https://joind.in/talk/dbda1 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