diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 2762405..2b17a49 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -1,7 +1,7 @@ language: "en" reviews: profile: "chill" - request_changes_workflow: false + request_changes_workflow: true high_level_summary: true collapse_walkthrough: false auto_review: diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index c593186..1f768a9 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -39,6 +39,14 @@ public function getConfigTreeBuilder(): TreeBuilder ->end() ->end() ->end() + ->arrayNode('openapi') + ->addDefaultsIfNotSet() + ->children() + ->booleanNode('problem_details') + ->defaultTrue() + ->end() + ->end() + ->end() ->end(); return $treeBuilder; diff --git a/src/DependencyInjection/StixxOpenApiCommandExtension.php b/src/DependencyInjection/StixxOpenApiCommandExtension.php index 50499aa..6b408ea 100644 --- a/src/DependencyInjection/StixxOpenApiCommandExtension.php +++ b/src/DependencyInjection/StixxOpenApiCommandExtension.php @@ -13,15 +13,63 @@ namespace Stixx\OpenApiCommandBundle\DependencyInjection; +use Stixx\OpenApiCommandBundle\Model\ProblemDetails; +use Stixx\OpenApiCommandBundle\Model\ProblemDetailsInvalidRequestBody; +use Stixx\OpenApiCommandBundle\Model\Violation; use Stixx\OpenApiCommandBundle\Responder\ResponderInterface; use Stixx\OpenApiCommandBundle\Validator\ValidatorInterface; use Symfony\Component\Config\FileLocator; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Extension\Extension; +use Symfony\Component\DependencyInjection\Extension\PrependExtensionInterface; use Symfony\Component\DependencyInjection\Loader\PhpFileLoader; +use Symfony\Component\Yaml\Yaml; -final class StixxOpenApiCommandExtension extends Extension +final class StixxOpenApiCommandExtension extends Extension implements PrependExtensionInterface { + public function prepend(ContainerBuilder $container): void + { + $configs = $container->getExtensionConfig($this->getAlias()); + /** @var array{openapi: array{problem_details: bool}} $config */ + $config = $this->processConfiguration(new Configuration(), $configs); + + if (!$config['openapi']['problem_details']) { + return; + } + + $problemDetailsConfigPath = __DIR__.'/../Resources/specifications/nelmio_problem_details.yaml'; + if (!file_exists($problemDetailsConfigPath)) { + return; + } + + $problemDetailsConfig = Yaml::parseFile($problemDetailsConfigPath); + + if (is_array($problemDetailsConfig) && isset($problemDetailsConfig['nelmio_api_doc']) && is_array($problemDetailsConfig['nelmio_api_doc'])) { + /** @var array $nelmioConfig */ + $nelmioConfig = $problemDetailsConfig['nelmio_api_doc']; + $container->prependExtensionConfig('nelmio_api_doc', $nelmioConfig); + } + + $container->prependExtensionConfig('nelmio_api_doc', [ + 'models' => [ + 'names' => [ + [ + 'alias' => 'ProblemDetails', + 'type' => ProblemDetails::class, + ], + [ + 'alias' => 'Violation', + 'type' => Violation::class, + ], + [ + 'alias' => 'ProblemDetailsInvalidRequestBody', + 'type' => ProblemDetailsInvalidRequestBody::class, + ], + ], + ], + ]); + } + public function load(array $configs, ContainerBuilder $container): void { $configuration = new Configuration(); diff --git a/src/Model/ProblemDetails.php b/src/Model/ProblemDetails.php new file mode 100644 index 0000000..23c500a --- /dev/null +++ b/src/Model/ProblemDetails.php @@ -0,0 +1,44 @@ + 'about:blank', + 'title' => 'The request body contains errors.', + 'status' => 400, + 'detail' => 'Validation failed.', + 'violations' => [ + [ + 'propertyPath' => 'foo', + 'message' => 'This value should not be blank.', + 'code' => 'c1ac8c7d-eab5-458f-a950-fcf121d23059', + 'constraint' => 'NotBlank', + ], + ], + ] +)] +final class ProblemDetailsInvalidRequestBody extends ProblemDetails +{ +} diff --git a/src/Model/Violation.php b/src/Model/Violation.php new file mode 100644 index 0000000..f02b440 --- /dev/null +++ b/src/Model/Violation.php @@ -0,0 +1,36 @@ +bootKernel(); + $container = self::getContainer(); + + /** @var ApiDocGenerator $generator */ + $generator = $container->get('nelmio_api_doc.generator.default'); + + // Act + /** @var array{components?: array{responses?: array, schemas?: array}} $spec */ + $spec = json_decode($generator->generate()->toJson(), true); + + // Assert + $this->assertIsArray($spec); + $this->assertArrayHasKey('components', $spec); + $components = $spec['components']; + $this->assertIsArray($components); + + $this->assertArrayHasKey('responses', $components); + $responses = $components['responses']; + $this->assertIsArray($responses); + + $this->assertArrayHasKey('InvalidRequestProblemDetailsResponse', $responses); + $this->assertArrayHasKey('DefaultProblemDetailsResponse', $responses); + + $this->assertArrayHasKey('schemas', $components); + $schemas = $components['schemas']; + $this->assertIsArray($schemas); + // These schemas come from the attributes in the Model classes + $this->assertArrayHasKey('ProblemDetails', $schemas); + $this->assertArrayHasKey('ProblemDetailsInvalidRequestBody', $schemas); + } + + #[WithoutErrorHandler] + public function testProblemDetailsCanBeDisabled(): void + { + // Arrange + $kernel = $this->createKernelWithConfig(static function (App\Kernel $kernel): void { + $kernel->addTestConfig(__DIR__.'/Resources/config/disable_problem_details.php'); + }); + $container = $kernel->getContainer(); + + /** @var ApiDocGenerator $generator */ + $generator = $container->get('nelmio_api_doc.generator.default'); + + set_error_handler(static fn () => true); + // Act + /** @var array{components?: array{responses?: array}} $spec */ + $spec = json_decode($generator->generate()->toJson(), true); + restore_error_handler(); + + // Assert + $this->assertIsArray($spec); + if (isset($spec['components']['responses'])) { + $this->assertArrayNotHasKey('InvalidRequestProblemDetailsResponse', $spec['components']['responses']); + } + } + + #[WithoutErrorHandler] + public function testProblemDetailsCanBeOverriddenByProjectConfig(): void + { + // Arrange + $kernel = $this->createKernelWithConfig(static function (App\Kernel $kernel): void { + $kernel->addTestConfig(__DIR__.'/Resources/config/override_nelmio.php'); + }); + $container = $kernel->getContainer(); + + /** @var ApiDocGenerator $generator */ + $generator = $container->get('nelmio_api_doc.generator.default'); + + set_error_handler(static fn () => true); + // Act + /** @var array{components?: array{responses?: array}} $spec */ + $spec = json_decode($generator->generate()->toJson(), true); + restore_error_handler(); + + // Assert + $this->assertIsArray($spec); + $this->assertArrayHasKey('components', $spec); + $components = $spec['components']; + $this->assertIsArray($components); + + $this->assertArrayHasKey('responses', $components); + $responses = $components['responses']; + $this->assertIsArray($responses); + + $this->assertArrayHasKey('InvalidRequestProblemDetailsResponse', $responses); + $response = $responses['InvalidRequestProblemDetailsResponse']; + $this->assertIsArray($response); + + $this->assertArrayHasKey('description', $response); + $this->assertEquals( + 'Overridden RFC7807 Problem Details', + $response['description'] + ); + + $this->assertArrayNotHasKey('content', $response); + } +} diff --git a/tests/Functional/Resources/config/disable_problem_details.php b/tests/Functional/Resources/config/disable_problem_details.php new file mode 100644 index 0000000..60af1d4 --- /dev/null +++ b/tests/Functional/Resources/config/disable_problem_details.php @@ -0,0 +1,22 @@ +extension('stixx_openapi_command', [ + 'openapi' => [ + 'problem_details' => false, + ], + ]); +}; diff --git a/tests/Functional/Resources/config/override_nelmio.php b/tests/Functional/Resources/config/override_nelmio.php new file mode 100644 index 0000000..1258540 --- /dev/null +++ b/tests/Functional/Resources/config/override_nelmio.php @@ -0,0 +1,28 @@ +extension('nelmio_api_doc', [ + 'documentation' => [ + 'components' => [ + 'responses' => [ + 'InvalidRequestProblemDetailsResponse' => [ + 'description' => 'Overridden RFC7807 Problem Details', + ], + ], + ], + ], + ]); +}; diff --git a/tests/Functional/Resources/specifications/openapi.json b/tests/Functional/Resources/specifications/openapi.json index 5729d83..3da2bfc 100644 --- a/tests/Functional/Resources/specifications/openapi.json +++ b/tests/Functional/Resources/specifications/openapi.json @@ -29,6 +29,12 @@ } } } + }, + "400": { + "$ref": "#/components/responses/InvalidRequestProblemDetailsResponse" + }, + "500": { + "$ref": "#/components/responses/DefaultProblemDetailsResponse" } } } @@ -67,6 +73,15 @@ } } } + }, + "400": { + "$ref": "#/components/responses/InvalidRequestProblemDetailsResponse" + }, + "404": { + "$ref": "#/components/responses/ResourceNotFoundProblemDetailsResponse" + }, + "500": { + "$ref": "#/components/responses/DefaultProblemDetailsResponse" } } }, @@ -86,6 +101,12 @@ "responses": { "204": { "description": "Book deleted" + }, + "404": { + "$ref": "#/components/responses/ResourceNotFoundProblemDetailsResponse" + }, + "500": { + "$ref": "#/components/responses/DefaultProblemDetailsResponse" } } } @@ -93,6 +114,136 @@ }, "components": { "schemas": { + "ProblemDetails": { + "title": "ProblemDetails", + "required": [ + "type", + "title", + "status" + ], + "properties": { + "type": { + "description": "A URI reference [RFC3986] that identifies the problem type.", + "type": "string", + "format": "uri-reference", + "default": "about:blank" + }, + "title": { + "description": "A short, human-readable summary of the problem type.", + "type": "string", + "default": "An error occurred" + }, + "status": { + "description": "The HTTP status code generated by the origin server for this occurrence of the problem.", + "type": "integer", + "default": 400 + }, + "detail": { + "description": "A human-readable explanation specific to this occurrence of the problem.", + "type": "string", + "default": null, + "nullable": true + }, + "instance": { + "description": "A URI reference that identifies the specific occurrence of the problem.", + "type": "string", + "format": "uri", + "default": null, + "nullable": true + } + }, + "type": "object", + "externalDocs": { + "description": "Problem Details for HTTP APIs", + "url": "https://datatracker.ietf.org/doc/html/rfc7807" + } + }, + "Violation": { + "required": [ + "propertyPath", + "message", + "code", + "constraint" + ], + "properties": { + "propertyPath": { + "type": "string" + }, + "message": { + "type": "string" + }, + "code": { + "type": "string" + }, + "constraint": { + "type": "string" + }, + "error": { + "type": "string", + "nullable": true + } + }, + "type": "object" + }, + "ProblemDetailsInvalidRequestBody": { + "title": "InvalidRequestBody", + "required": [ + "violations" + ], + "properties": { + "violations": { + "type": "array", + "maxItems": 100, + "items": { + "$ref": "#/components/schemas/Violation" + } + }, + "type": { + "description": "A URI reference [RFC3986] that identifies the problem type.", + "type": "string", + "format": "uri-reference", + "default": "about:blank" + }, + "title": { + "description": "A short, human-readable summary of the problem type.", + "type": "string", + "default": "An error occurred" + }, + "status": { + "description": "The HTTP status code generated by the origin server for this occurrence of the problem.", + "type": "integer", + "default": 400 + }, + "detail": { + "description": "A human-readable explanation specific to this occurrence of the problem.", + "type": "string", + "default": null, + "nullable": true + }, + "instance": { + "description": "A URI reference that identifies the specific occurrence of the problem.", + "type": "string", + "format": "uri", + "default": null, + "nullable": true + } + }, + "type": "object", + "example": { + "type": "about:blank", + "title": "The request body contains errors.", + "status": 400, + "detail": "Validation failed.", + "violations": [ + { + "propertyPath": "foo", + "message": "This value should not be blank.", + "code": "c1ac8c7d-eab5-458f-a950-fcf121d23059", + "constraint": "NotBlank" + } + ] + } + }, "BookRequest": { "title": "BookRequest", "description": "Request payload for a book", @@ -139,6 +290,141 @@ }, "type": "object" } + }, + "responses": { + "InvalidRequestProblemDetailsResponse": { + "description": "RFC7807 Problem Details", + "content": { + "application/problem+json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/ProblemDetailsInvalidRequestBody" + }, + { + "$ref": "#/components/schemas/ProblemDetails" + } + ] + }, + "examples": { + "invalidJsonInRequestBody": { + "summary": "Invalid JSON in Request Body", + "value": { + "type": "about:blank", + "title": "The request body contains errors", + "status": 400, + "detail": "Validation failed.", + "violations": [ + { + "propertyPath": "foo", + "message": "This value should not be blank.", + "code": "c1ac8c7d-eab5-458f-a950-fcf121d23059", + "constraint": "NotBlank", + "error": "NOT_EQUAL_ERROR" + } + ] + } + }, + "invalidRequestBody": { + "summary": "Invalid Request Body", + "value": { + "type": "about:blank", + "title": "The request body contains errors", + "status": 400, + "detail": "Body does not match schema", + "violations": [ + { + "propertyPath": "body", + "message": "Body does not match schema for content-type \"application/json\" for Request [post /api/example]", + "code": "openapi_request_validation", + "constraint": "openapi_request_validation" + } + ] + } + } + } + } + } + }, + "UnauthorizedProblemDetailsResponse": { + "description": "RFC7807 Unauthorized Problem Details", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + }, + "example": { + "type": "about:blank", + "title": "Unauthorized", + "status": 401, + "detail": "The authentication token is missing or invalid." + } + } + } + }, + "ForbiddenProblemDetailsResponse": { + "description": "RFC7807 Forbidden Problem Details", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + }, + "example": { + "type": "about:blank", + "title": "Forbidden", + "status": 403, + "detail": "You are not allowed to perform this action." + } + } + } + }, + "ResourceNotFoundProblemDetailsResponse": { + "description": "RFC7807 Resource Not Found Problem Details", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + }, + "example": { + "type": "about:blank", + "title": "Resource Not Found", + "status": 404, + "detail": "The requested resource was not found." + } + } + } + }, + "ConflictProblemDetailsResponse": { + "description": "RFC7807 Conflict Problem Details", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + }, + "example": { + "type": "about:blank", + "title": "Conflict", + "status": 409, + "detail": "The resource already exists." + } + } + } + }, + "DefaultProblemDetailsResponse": { + "description": "RFC7807 Default Problem Details", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + }, + "example": { + "type": "about:blank", + "title": "An error occurred", + "status": 500 + } + } + } + } } } -} \ No newline at end of file +} diff --git a/tests/Unit/DependencyInjection/ConfigurationTest.php b/tests/Unit/DependencyInjection/ConfigurationTest.php index 7ab0620..556c821 100644 --- a/tests/Unit/DependencyInjection/ConfigurationTest.php +++ b/tests/Unit/DependencyInjection/ConfigurationTest.php @@ -34,6 +34,9 @@ public function testDefaultConfig(): void 'enabled' => true, 'groups' => ['Default'], ], + 'openapi' => [ + 'problem_details' => true, + ], ]; self::assertSame($expected, $config); } @@ -54,6 +57,15 @@ public function testCustomConfig(): void $config = $processor->processConfiguration($configuration, [$customConfig]); // Assert - self::assertSame($customConfig, $config); + $expected = [ + 'validation' => [ + 'enabled' => false, + 'groups' => ['Custom', 'Special'], + ], + 'openapi' => [ + 'problem_details' => true, + ], + ]; + self::assertSame($expected, $config); } }