Skip to content
Snippets Groups Projects
Commit ed6569d9 authored by Wim Leers's avatar Wim Leers
Browse files

Issue #3466555 by traviscarden, Wim Leers, tedbow, balintbrews: Also validate...

Issue #3466555 by traviscarden, Wim Leers, tedbow, balintbrews: Also validate request bodies against the OpenAPI spec
parent e9f85273
No related branches found
No related tags found
1 merge request!206#3466555: Also validate request bodies against the OpenAPI spec
Pipeline #269150 failed
......@@ -24,19 +24,6 @@ experience_builder.components:
requirements:
_permission: 'access administration pages'
experience_builder.component:
path: '/xb-component/{component_id}'
defaults:
_controller: '\Drupal\experience_builder\Controller\SdcController::component'
_title: 'Component'
component_id: NULL
requirements:
_permission: 'access administration pages'
options:
parameters:
component_id:
type: string
experience_builder.render_component:
path: '/xb-render-component/{component_id}'
defaults:
......
......@@ -28,13 +28,13 @@ services:
arguments: ['experience_builder']
# Event subscribers.
experience_builder.openapi.validator.subscriber:
class: Drupal\experience_builder\EventSubscriber\ApiResponseValidator
experience_builder.openapi.http_message_validator.subscriber:
class: Drupal\experience_builder\EventSubscriber\ApiMessageValidator
arguments:
$logger: '@logger.channel.experience_builder'
$appRoot: '%app.root%'
calls:
- [setValidator, []]
- [setValidatorBuilder, []]
tags:
- { name: event_subscriber }
experience_builder.theme_negotiator.xb:
......
# Wherever the OpenAPI spec indicates a specific field order, follow that order.
# For example: https://spec.openapis.org/oas/latest.html#fixed-fields. Sort list
# values, such as paths and components, alphabetically for easy reading and to
# prevent needless merge conflicts.
# Tip: copy/paste this into https://editor-next.swagger.io/ and edit it there, to get real-time feedback!
# For readability and to prevent needless merge conflicts, follow all possible
# conventions in the official OpenAPI specification and documentation, including
# the order of fixed fields like paths and components, spacing, and quoting of
# strings. Pay attention to the rest of the file for any other conventions. Use
# one of the automated formatters in the resources below.
#
# Tip: Use https://editor-next.swagger.io/ for real-time, in-browser feedback.
#
# Resources:
# - https://spec.openapis.org/oas/v3.1.0.html - Official specification.
# - https://learn.openapis.org/specification/ - Official documentation.
# - https://swagger.io/docs/specification/ - Alternative documentation.
# - https://www.jetbrains.com/help/idea/openapi.html - PhpStorm features.
# - https://codifyformatter.org/yaml-formatter,
# https://onlineyamltools.com/prettify-yaml - YAML formatters that follow the
# official OpenAPI conventions. They are functionally identical. Warning: They
# also strip comments and blank lines. Review diffs carefully to avoid losing
# any.
openapi: 3.1.0
info:
version: 0.x
title: Experience Builder
description: API Spec for Experience Builder
description: API Spec for Experience Builder
version: 0.x
paths:
/xb-field-form/{entityTypeId}/{id}:
'/api/layout/{entityTypeId}/{entityId}':
get:
parameters:
- $ref: '#/components/parameters/entityTypeId'
- $ref: '#/components/parameters/id'
responses:
200:
description: 'The form'
/api/layout/{entityTypeId}/{id}:
get:
parameters:
- name: entityTypeId
in: path
required: true
description: 'The entity type ID of the layout to retrieve'
schema:
type: string
example: node
- name: id
in: path
required: true
description: 'The entity ID of the layout to retrieve'
schema:
type: integer
examples:
'first entity of this type':
value: 14
'another entity of this type':
value: 42
- $ref: '#/components/parameters/entityId'
responses:
200:
description: 'The layout'
'200':
description: The layout
content:
application/json:
examples:
'empty component tree':
emptyComponentTree:
value:
layout:
uuid: root
......@@ -52,7 +43,7 @@ paths:
name: root
children: []
model: {}
'component tree with single component':
componentTreeWithSingleComponent:
value:
layout:
uuid: root
......@@ -61,90 +52,86 @@ paths:
children:
- uuid: bce8d97e-8d44-4609-adc3-5e1015342b49
nodeType: component
type: sdc_test:my-cta
type: 'sdc_test:my-cta'
model:
bce8d97e-8d44-4609-adc3-5e1015342b49:
text: hello, world!
href: https://drupal.org
text: 'hello, world!'
href: 'https://drupal.org'
name: My Test CTA Component
schema:
type: object
required: [layout, model]
required:
- layout
- model
properties:
layout:
$ref: '#/components/schemas/Layout'
model:
$ref: '#/components/schemas/Model'
/api/preview/{entityTypeId}/{id}:
'/api/preview/{entityTypeId}/{entityId}':
post:
parameters:
- name: entityTypeId
in: path
required: true
description: 'The entity type ID of the layout to retrieve'
schema:
type: string
example: node
- name: id
in: path
required: true
description: 'The entity ID of the layout to retrieve'
schema:
type: integer
examples:
'first entity of this type':
value: 14
'another entity of this type':
value: 42
- $ref: '#/components/parameters/entityTypeId'
- $ref: '#/components/parameters/entityId'
requestBody:
$ref: '#/components/requestBodies/Layout'
responses:
200:
'200':
description: Generated preview successfully
/xb-components:
get:
responses:
200:
'200':
description: All available components
content:
application/json:
examples:
'a single available component':
singleAvailableComponent:
value:
'sdc_test:my-cta':
id: sdc_test:my-cta
id: 'sdc_test:my-cta'
name: Call to Action
metadata:
path: core/modules/system/tests/modules/sdc_test/components/my-cta
documentation: No documentation found. Add a README.md in your component directory.
path: >-
core/modules/system/tests/modules/sdc_test/components/my-cta
documentation: >-
No documentation found. Add a README.md in your
component directory.
status: stable
machineName: my-cta
name: Call to Action
group: All Components
schema:
type: object
required: [text]
required:
- text
properties:
text:
type: [string, object]
type:
- string
- object
title: Title
description: The title for the cta
examples:
- Press
- Submit now
href:
type: [string, object]
type:
- string
- object
title: URL
format: uri
target:
type: [string, object]
type:
- string
- object
title: Target
enum:
- ''
- _blank
attributes:
type:
- "Drupal\\Core\\Template\\Attribute"
- Drupal\Core\Template\Attribute
- object
name: Attributes
additionalProperties: false
......@@ -158,75 +145,84 @@ paths:
patternProperties:
'^\\[a-zA-Z0-9_-]:[a-zA-Z0-9_-]$':
$ref: '#/components/schemas/Component'
/xb/api/entity-form/{entityTypeId}/{id}/{entityFormMode}:
description: 'Fetches the entity form with the specified form mode'
'/xb-field-form/{entityTypeId}/{entityId}':
get:
parameters:
- $ref: '#/components/parameters/entityTypeId'
- $ref: '#/components/parameters/id'
- $ref: '#/components/parameters/entityId'
responses:
'200':
description: The form
'/xb-render-component/{componentId}':
get:
parameters:
- name: componentId
in: path
required: true
description: TODO
schema:
type: string
responses:
'200':
description: The rendered component, along with relevant metadata for re-rendering it client-side.
content:
application/json:
schema:
type: object
required:
- id
- markup
- props
- metadata
properties:
id:
type: string
description: The component ID
markup:
type: string
# @see \Drupal\Core\Theme\Component\ComponentMetadata
metadata:
type: object
props:
type: object
additionalProperties: false
'/xb/api/entity-form/{entityTypeId}/{entityId}/{entityFormMode}':
description: Fetches the entity form with the specified form mode
get:
parameters:
- $ref: '#/components/parameters/entityTypeId'
- $ref: '#/components/parameters/entityId'
- name: entityFormMode
in: path
required: true
description: 'The entity form mode of the form to retrieve'
description: The entity form mode of the form to retrieve
schema:
type: string
example: default
responses:
200:
'200':
$ref: '#/components/responses/FormResponse'
/xb/api/entity-form/{entityTypeId}/{id}:
description: 'Fetches the entity form with the "default" form mode'
'/xb/api/entity-form/{entityTypeId}/{entityId}':
description: Fetches the entity form with the "default" form mode
get:
parameters:
- $ref: '#/components/parameters/entityTypeId'
- $ref: '#/components/parameters/id'
- $ref: '#/components/parameters/entityId'
responses:
200:
'200':
$ref: '#/components/responses/FormResponse'
components:
parameters:
entityTypeId:
name: entityTypeId
in: path
required: true
description: 'The entity type ID of the form to retrieve'
schema:
type: string
example: node
id:
name: id
in: path
required: true
description: 'The entity ID of the form to retrieve'
schema:
type: integer
examples:
'first entity of this type':
value: 14
'another entity of this type':
value: 42
responses:
FormResponse:
description: 'The form'
content:
application/json:
examples:
'A form':
value:
html: "<form></form>"
schema:
type: object
required: [html]
properties:
html:
schema:
type: string
schemas:
Component:
title: Component
type: object
required: [name, id, metadata, field_data, default_markup]
required:
- name
- id
- metadata
- field_data
- default_markup
properties:
name:
type: string
......@@ -242,7 +238,10 @@ components:
# Keys are props for this SDC. Only required SDC props are required here.
additionalProperties:
type: object
required: [sourceType, expression, required]
required:
- sourceType
- expression
- required
properties:
sourceType:
type: string
......@@ -251,7 +250,12 @@ components:
required:
type: boolean
default_values:
type: [string, integer, number, boolean, object]
type:
- string
- integer
- number
- boolean
- object
default_markup:
type: string
dynamic_prop_source_candidates:
......@@ -260,7 +264,9 @@ components:
Layout:
title: layout
type: object
required: [uuid, nodeType]
required:
- uuid
- nodeType
additionalProperties: false
properties:
uuid:
......@@ -291,7 +297,42 @@ components:
Model:
title: model
type: object
responses:
FormResponse:
description: The form
content:
application/json:
examples:
arbitraryForm:
value:
html: <form></form>
schema:
type: object
required:
- html
properties:
html:
schema:
type: string
parameters:
entityTypeId:
name: entityTypeId
in: path
required: true
description: The entity type ID
schema:
type: string
example: node
entityId:
name: entityId
in: path
required: true
description: The entity ID
schema:
type: integer
examples:
arbitraryEntityId:
value: 42
requestBodies:
Layout:
content:
......
......@@ -6,6 +6,7 @@ namespace Drupal\experience_builder\EventSubscriber;
use Drupal\Core\Extension\ModuleHandlerInterface;
use Drupal\Core\Routing\RouteMatchInterface;
use League\OpenAPIValidation\PSR7\Exception\NoPath;
use League\OpenAPIValidation\PSR7\Exception\Validation\AddressValidationFailed;
use League\OpenAPIValidation\PSR7\Exception\ValidationFailed;
use League\OpenAPIValidation\PSR7\OperationAddress;
......@@ -16,31 +17,37 @@ use Psr\Log\LoggerInterface;
use Symfony\Bridge\PsrHttpMessage\HttpMessageFactoryInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;
/**
* Response subscriber that validates an Experience Builder API response.
* HTTP message subscriber that validates an Experience Builder API message.
*
* @internal
* This functionality only takes effect in the presence of the
* league/openapi-psr7-validator Composer library with PHP assertions enabled
* for local development or CI purposes.
*
* @see self::isValidationEnabled()
*
* @see \Drupal\jsonapi\EventSubscriber\ResourceResponseValidator
* @internal
*/
final class ApiResponseValidator implements EventSubscriberInterface {
final class ApiMessageValidator implements EventSubscriberInterface {
/**
* The OpenAPI validator.
*
* This property will only be set if the validator library is available.
* Don't access it directly. Use {@see self::getConfiguredValidatorBuilder}
* instead.
*
* @var \League\OpenAPIValidation\PSR7\ValidatorBuilder|null
*/
protected $validator;
private ?ValidatorBuilder $validatorBuilder;
/**
* Constructs an ApiResponseValidator object.
* Constructs an API Message Validator object.
*
* @param \Psr\Log\LoggerInterface $logger
* The Experience Builder logger channel.
......@@ -62,98 +69,142 @@ final class ApiResponseValidator implements EventSubscriberInterface {
) {}
/**
* {@inheritdoc}
*/
public static function getSubscribedEvents(): array {
$events[KernelEvents::RESPONSE][] = ['onResponse'];
return $events;
}
/**
* Sets the validator service if available.
* Sets the OpenAPI validator builder service if available.
*/
public function setValidator(?ValidatorBuilder $validator = NULL): void {
if ($validator) {
$this->validator = $validator;
public function setValidatorBuilder(?ValidatorBuilder $validator = NULL): void {
if ($validator instanceof ValidatorBuilder) {
$this->validatorBuilder = $validator;
}
elseif (class_exists(ValidatorBuilder::class)) {
$this->validator = new ValidatorBuilder();
$this->validatorBuilder = new ValidatorBuilder();
}
}
/**
* Validates Experience Builder API responses.
* Validates Experience Builder API messages.
*
* @param \Symfony\Component\HttpKernel\Event\ResponseEvent $event
* @param \Symfony\Component\HttpKernel\Event\RequestEvent|\Symfony\Component\HttpKernel\Event\ResponseEvent $event
* The event to process.
*
* @throws \League\OpenAPIValidation\PSR7\Exception\ValidationFailed
* See docblock on {@see self::validate()}.
*/
public function onResponse(ResponseEvent $event): void {
$response = $event->getResponse();
if (!$response instanceof JsonResponse) {
public function onMessage(RequestEvent|ResponseEvent $event): void {
if (!$this->shouldValidate()) {
return;
}
if (!str_starts_with($this->currentRouteMatch->getRouteName() ?? '', 'experience_builder.')) {
return;
try {
$validatorBuilder = $this->getConfiguredValidatorBuilder();
// @phpstan-ignore match.unhandled
match (get_class($event)) {
RequestEvent::class => $this->validateRequest($validatorBuilder, $event),
ResponseEvent::class => $this->validateResponse($validatorBuilder, $event),
};
}
catch (NoPath $e) {
// @todo Temporarily log and ignore missing paths. Once 'openapi.yml' is
// is complete, remove this to treat them as failures.
$this->logger->debug($e->getMessage());
}
catch (ValidationFailed $e) {
$this->logFailure($e);
// @todo Surface exception details better for front-end display.
// @see https://www.drupal.org/project/experience_builder/issues/3470321
throw $e;
}
}
// Wraps validation in an assert to prevent execution in production.
assert($this->validateResponse($response, $event->getRequest()), 'An Experience Builder response failed validation (see the logs for details). Report this in the Drupal issue queue at https://www.drupal.org/project/issues/experience_builder');
/**
* Determines whether the message should be validated.
*/
private function shouldValidate(): bool {
return !$this->isProd()
&& $this->isExperienceBuilderMessage()
&& $this->isValidationEnabled();
}
/**
* Validates a response against this module's OpenAPI specification.
*
* @param \Symfony\Component\HttpFoundation\Response $response
* The response to validate.
* @param \Symfony\Component\HttpFoundation\Request $request
* The request containing info about what to validate.
*
* @return bool
* FALSE if the response failed validation, otherwise TRUE.
* Determines whether the application is in production.
*/
protected function validateResponse(Response $response, Request $request) {
// If the validator isn't set, then the validation library is not installed.
if (!$this->validator) {
return TRUE;
}
private function isProd(): bool {
$is_prod = TRUE;
$openapi_spec_file = sprintf(
'%s/%s/openapi.yml',
$this->appRoot,
$this->moduleHandler->getModule('experience_builder')->getPath(),
// Assertions are assumed to be disabled in prod, so this assignment will
// never take place there.
// @phpstan-ignore booleanNot.alwaysTrue
assert(!($is_prod = FALSE));
return $is_prod;
}
/**
* Determines whether a given message is from this module.
*/
private function isExperienceBuilderMessage(): bool {
return str_starts_with(
$this->currentRouteMatch->getRouteName() ?? '',
'experience_builder.',
);
}
$validator = (new ValidatorBuilder())->fromYamlFile($openapi_spec_file)->getResponseValidator();
$operation = new OperationAddress($request->getPathInfo(), strtolower($request->getMethod()));
$psr7_response = $this->httpMessageFactory->createResponse($response);
/**
* Determines whether validation is enabled.
*
* Validation is implicitly enabled if the league/openapi-psr7-validator
* Composer library is present. To add it to your project, require it as a dev
* dependency:
*
* ```
* composer require --dev league/openapi-psr7-validator
* ```
*/
private function isValidationEnabled(): bool {
// The builder won't be set if league/openapi-psr7-validator is absent.
/** @see self::setValidatorBuilder() */
return $this->validatorBuilder instanceof ValidatorBuilder;
}
try {
$validator->validate($operation, $psr7_response);
/**
* Validates a request message.
*
* @throws \League\OpenAPIValidation\PSR7\Exception\ValidationFailed
* If validation fails.
*/
protected function validateRequest(ValidatorBuilder $validatorBuilder, RequestEvent $event): void {
$validator = $validatorBuilder->getRequestValidator();
$this->performXbValidation($validator, $operation, $response);
return TRUE;
}
catch (ValidationFailed $e) {
$message = $e instanceof AddressValidationFailed
// @see https://github.com/thephpleague/openapi-psr7-validator/pull/184
? $e->getVerboseMessage()
: $e->getMessage();
$this->logger->debug($message);
// @todo Before 1.0, stop re-throwing. For now, explicit failures help iterate faster.
throw new ValidationFailed($message, $e->getCode(), $e);
// phpcs:disable
// @phpstan-ignore-next-line
return FALSE;
// phpcs:enable
}
$psr7_request = $this->httpMessageFactory
->createRequest($event->getRequest());
$validator->validate($psr7_request);
}
public static function validateKeys(array $data, string $pattern): void {
foreach (array_keys($data) as $key) {
if (!preg_match("/$pattern/", $key)) {
throw new ValidationFailed(sprintf('Invalid key "%s" found in data array.', $key));
}
/**
* Validates a response message.
*
* @throws \League\OpenAPIValidation\PSR7\Exception\ValidationFailed
* If validation fails.
*/
protected function validateResponse(ValidatorBuilder $validatorBuilder, ResponseEvent $event): void {
$request = $event->getRequest();
$response = $event->getResponse();
if (!$response instanceof JsonResponse) {
return;
}
$validator = $validatorBuilder->getResponseValidator();
$operation = new OperationAddress(
$request->getPathInfo(),
strtolower($request->getMethod()),
);
$psr7_response = $this->httpMessageFactory
->createResponse($response);
$this->performXbValidation($validator, $operation, $response);
$validator->validate($operation, $psr7_response);
}
private function performXbValidation(ResponseValidator $validator, OperationAddress $operation, Response $response): void {
......@@ -176,4 +227,44 @@ final class ApiResponseValidator implements EventSubscriberInterface {
}
}
/**
* Gets the validator builder configured with the module's OpenAPI schema.
*
* @return \League\OpenAPIValidation\PSR7\ValidatorBuilder
* The validator builder configured with the module's OpenAPI schema.
*/
private function getConfiguredValidatorBuilder(): ValidatorBuilder {
$openapi_spec_file = sprintf(
'%s/%s/openapi.yml',
$this->appRoot,
$this->moduleHandler
->getModule('experience_builder')
->getPath(),
);
assert($this->validatorBuilder instanceof ValidatorBuilder);
return $this->validatorBuilder
->fromYamlFile($openapi_spec_file);
}
public function logFailure(ValidationFailed $e): void {
// AddressValidationFailed provides additional helpful details.
// @see https://github.com/thephpleague/openapi-psr7-validator/pull/184
$message = $e instanceof AddressValidationFailed
? $e->getVerboseMessage()
: $e->getMessage();
$this->logger->debug($message);
}
/**
* {@inheritdoc}
*/
public static function getSubscribedEvents(): array {
return [
KernelEvents::REQUEST => 'onMessage',
KernelEvents::RESPONSE => 'onMessage',
];
}
}
......@@ -59,7 +59,7 @@ final class OpenApiSpecValidationTest extends UnitTestCase {
public function testSpecIsValid(): void {
$specification = $this->getSpecification();
$specification->validate();
$this->assertEmpty($specification->getErrors());
$this->assertSame([], $specification->getErrors());
$validator = new Validator();
$open_api_data = $specification->getSerializableData();
$validator->validate($open_api_data, (object) ['$ref' => 'file://' . $this->documentLocation]);
......
// Need to use the React-specific entry point to import createApi
import { createApi } from '@reduxjs/toolkit/query/react';
import type { Component, ComponentsList } from '@/types/Component';
import type { ComponentsList } from '@/types/Component';
import { baseQuery } from '@/services/baseQuery';
// Define a service using a base URL and expected endpoints
......@@ -8,9 +8,6 @@ export const componentApi = createApi({
reducerPath: 'componentsApi',
baseQuery,
endpoints: (builder) => ({
getComponentById: builder.query<Component, string>({
query: (id) => `xb-component/${id}`,
}),
getComponents: builder.query<ComponentsList, void>({
query: () => `xb-components`,
}),
......@@ -19,4 +16,4 @@ export const componentApi = createApi({
// Export hooks for usage in functional components, which are
// auto-generated based on the defined endpoints
export const { useGetComponentByIdQuery, useGetComponentsQuery } = componentApi;
export const { useGetComponentsQuery } = componentApi;
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment