From 2357fafbb084be0f9eda7b5c1a659704fed65b28 Mon Sep 17 00:00:00 2001 From: Martin Rademacher Date: Sat, 23 Mar 2024 19:35:46 +1300 Subject: [PATCH] Add validation rule that example/examples are mutually exclusive and add examples property to Schema (3.1.0 only) (#1561) --- composer.json | 11 ++- phpstan-baseline.neon | 4 +- src/Annotations/AbstractAnnotation.php | 34 ++++++++- src/Annotations/Components.php | 2 +- src/Annotations/Examples.php | 1 + src/Annotations/HeaderParameter.php | 2 + src/Annotations/JsonContent.php | 12 +--- src/Annotations/License.php | 4 +- src/Annotations/MediaType.php | 8 +-- src/Annotations/OpenApi.php | 2 +- src/Annotations/Operation.php | 1 + src/Annotations/Parameter.php | 4 +- src/Annotations/PathParameter.php | 2 + src/Annotations/QueryParameter.php | 2 + src/Annotations/Schema.php | 54 +++++++++++---- src/Annotations/XmlContent.php | 7 +- src/Attributes/Components.php | 2 +- src/Attributes/JsonContent.php | 2 +- src/Attributes/MediaType.php | 2 +- src/Attributes/ParameterTrait.php | 2 +- src/Attributes/Schema.php | 5 +- src/Attributes/XmlContent.php | 2 +- src/Processors/AugmentProperties.php | 6 +- tests/Analysers/ReflectionAnalyserTest.php | 5 +- tests/Annotations/AbstractAnnotationTest.php | 17 +++++ tests/Annotations/AttributesSyncTest.php | 12 +++- tests/Annotations/OpenApiTest.php | 4 +- tests/Fixtures/Apis/Attributes/basic.php | 2 +- tests/Fixtures/BadExampleParameter.php | 19 +++++ ...ce.yaml => AttributeInheritance3.0.0.yaml} | 0 .../Scratch/AttributeInheritance3.1.0.yaml | 39 +++++++++++ .../{ClassRef.yaml => ClassRef3.0.0.yaml} | 0 tests/Fixtures/Scratch/ClassRef3.1.0.yaml | 18 +++++ ...yaml => ComplexCustomAttributes3.0.0.yaml} | 0 .../Scratch/ComplexCustomAttributes3.1.0.yaml | 63 +++++++++++++++++ ...a.yaml => CustomAttributeSchema3.0.0.yaml} | 0 .../Scratch/CustomAttributeSchema3.1.0.yaml | 15 ++++ ...ibutes.yaml => CustomAttributes3.0.0.yaml} | 0 .../Scratch/CustomAttributes3.1.0.yaml | 29 ++++++++ ...yaml => CustomPropertyAttribute3.0.0.yaml} | 0 .../Scratch/CustomPropertyAttribute3.1.0.yaml | 20 ++++++ tests/Fixtures/Scratch/Docblocks.php | 16 +++-- tests/Fixtures/Scratch/Docblocks3.0.0.yaml | 69 +++++++++++++++++++ .../{Docblocks.yaml => Docblocks3.1.0.yaml} | 13 ++-- ...plicateRef.yaml => DuplicateRef3.0.0.yaml} | 0 tests/Fixtures/Scratch/DuplicateRef3.1.0.yaml | 29 ++++++++ tests/Fixtures/Scratch/Examples.php | 59 ++++++++++++++++ tests/Fixtures/Scratch/Examples3.0.0.yaml | 35 ++++++++++ tests/Fixtures/Scratch/Examples3.1.0.yaml | 39 +++++++++++ ...lusiveMinMax31.php => ExclusiveMinMax.php} | 7 +- .../Scratch/ExclusiveMinMax3.0.0.yaml | 43 ++++++++++++ ...inMax31.yaml => ExclusiveMinMax3.1.0.yaml} | 4 +- ...MergeTraits.yaml => MergeTraits3.0.0.yaml} | 0 tests/Fixtures/Scratch/MergeTraits3.1.0.yaml | 38 ++++++++++ ...ded.yaml => MergeTraitsExtended3.0.0.yaml} | 0 .../Scratch/MergeTraitsExtended3.1.0.yaml | 55 +++++++++++++++ ...aml => MultiplePathsForEndpoint3.0.0.yaml} | 0 .../MultiplePathsForEndpoint3.1.0.yaml | 33 +++++++++ ...l => NestedAdditionalProperties3.0.0.yaml} | 0 .../NestedAdditionalProperties3.1.0.yaml | 19 +++++ ...stedSchema.yaml => NestedSchema3.0.0.yaml} | 0 tests/Fixtures/Scratch/NestedSchema3.1.0.yaml | 30 ++++++++ .../{NullRef.yaml => NullRef3.0.0.yaml} | 0 .../{NullRef31.yaml => NullRef3.1.0.yaml} | 0 tests/Fixtures/Scratch/NullRef31.php | 57 --------------- tests/Fixtures/Scratch/Nullable.php | 1 - tests/Fixtures/Scratch/Nullable3.0.0.yaml | 57 +++++++++++++++ .../{Nullable.yaml => Nullable3.1.0.yaml} | 0 ...ontent.yaml => ParameterContent3.0.0.yaml} | 0 .../Scratch/ParameterContent3.1.0.yaml | 26 +++++++ ...operty.yaml => PromotedProperty3.0.0.yaml} | 0 .../Scratch/PromotedProperty3.1.0.yaml | 26 +++++++ ...nce.yaml => PropertyInheritance3.0.0.yaml} | 0 .../Scratch/PropertyInheritance3.1.0.yaml | 22 ++++++ ...RequestBody.yaml => RequestBody3.0.0.yaml} | 0 tests/Fixtures/Scratch/RequestBody3.1.0.yaml | 35 ++++++++++ ...on.yaml => ThirdPartyAnnotation3.0.0.yaml} | 0 .../Scratch/ThirdPartyAnnotation3.1.0.yaml | 18 +++++ .../Scratch/{Types31.php => Types.php} | 6 +- .../Scratch/{Types31.yaml => Types3.1.0.yaml} | 4 +- tests/Fixtures/Scratch/UsingRefs.php | 4 +- .../{UsingRefs.yaml => UsingRefs3.0.0.yaml} | 9 ++- tests/Fixtures/Scratch/UsingRefs3.1.0.yaml | 26 +++++++ tests/OpenApiTestCase.php | 11 ++- tests/Processors/ExpandEnumsTest.php | 8 +-- tests/ScratchTest.php | 35 +++++++--- tests/SerializerTest.php | 2 +- 87 files changed, 1084 insertions(+), 166 deletions(-) create mode 100644 tests/Fixtures/BadExampleParameter.php rename tests/Fixtures/Scratch/{AttributeInheritance.yaml => AttributeInheritance3.0.0.yaml} (100%) create mode 100644 tests/Fixtures/Scratch/AttributeInheritance3.1.0.yaml rename tests/Fixtures/Scratch/{ClassRef.yaml => ClassRef3.0.0.yaml} (100%) create mode 100644 tests/Fixtures/Scratch/ClassRef3.1.0.yaml rename tests/Fixtures/Scratch/{ComplexCustomAttributes.yaml => ComplexCustomAttributes3.0.0.yaml} (100%) create mode 100644 tests/Fixtures/Scratch/ComplexCustomAttributes3.1.0.yaml rename tests/Fixtures/Scratch/{CustomAttributeSchema.yaml => CustomAttributeSchema3.0.0.yaml} (100%) create mode 100644 tests/Fixtures/Scratch/CustomAttributeSchema3.1.0.yaml rename tests/Fixtures/Scratch/{CustomAttributes.yaml => CustomAttributes3.0.0.yaml} (100%) create mode 100644 tests/Fixtures/Scratch/CustomAttributes3.1.0.yaml rename tests/Fixtures/Scratch/{CustomPropertyAttribute.yaml => CustomPropertyAttribute3.0.0.yaml} (100%) create mode 100644 tests/Fixtures/Scratch/CustomPropertyAttribute3.1.0.yaml create mode 100644 tests/Fixtures/Scratch/Docblocks3.0.0.yaml rename tests/Fixtures/Scratch/{Docblocks.yaml => Docblocks3.1.0.yaml} (90%) rename tests/Fixtures/Scratch/{DuplicateRef.yaml => DuplicateRef3.0.0.yaml} (100%) create mode 100644 tests/Fixtures/Scratch/DuplicateRef3.1.0.yaml create mode 100644 tests/Fixtures/Scratch/Examples.php create mode 100644 tests/Fixtures/Scratch/Examples3.0.0.yaml create mode 100644 tests/Fixtures/Scratch/Examples3.1.0.yaml rename tests/Fixtures/Scratch/{ExclusiveMinMax31.php => ExclusiveMinMax.php} (89%) create mode 100644 tests/Fixtures/Scratch/ExclusiveMinMax3.0.0.yaml rename tests/Fixtures/Scratch/{ExclusiveMinMax31.yaml => ExclusiveMinMax3.1.0.yaml} (91%) rename tests/Fixtures/Scratch/{MergeTraits.yaml => MergeTraits3.0.0.yaml} (100%) create mode 100644 tests/Fixtures/Scratch/MergeTraits3.1.0.yaml rename tests/Fixtures/Scratch/{MergeTraitsExtended.yaml => MergeTraitsExtended3.0.0.yaml} (100%) create mode 100644 tests/Fixtures/Scratch/MergeTraitsExtended3.1.0.yaml rename tests/Fixtures/Scratch/{MultiplePathsForEndpoint.yaml => MultiplePathsForEndpoint3.0.0.yaml} (100%) create mode 100644 tests/Fixtures/Scratch/MultiplePathsForEndpoint3.1.0.yaml rename tests/Fixtures/Scratch/{NestedAdditionalProperties.yaml => NestedAdditionalProperties3.0.0.yaml} (100%) create mode 100644 tests/Fixtures/Scratch/NestedAdditionalProperties3.1.0.yaml rename tests/Fixtures/Scratch/{NestedSchema.yaml => NestedSchema3.0.0.yaml} (100%) create mode 100644 tests/Fixtures/Scratch/NestedSchema3.1.0.yaml rename tests/Fixtures/Scratch/{NullRef.yaml => NullRef3.0.0.yaml} (100%) rename tests/Fixtures/Scratch/{NullRef31.yaml => NullRef3.1.0.yaml} (100%) delete mode 100644 tests/Fixtures/Scratch/NullRef31.php create mode 100644 tests/Fixtures/Scratch/Nullable3.0.0.yaml rename tests/Fixtures/Scratch/{Nullable.yaml => Nullable3.1.0.yaml} (100%) rename tests/Fixtures/Scratch/{ParameterContent.yaml => ParameterContent3.0.0.yaml} (100%) create mode 100644 tests/Fixtures/Scratch/ParameterContent3.1.0.yaml rename tests/Fixtures/Scratch/{PromotedProperty.yaml => PromotedProperty3.0.0.yaml} (100%) create mode 100644 tests/Fixtures/Scratch/PromotedProperty3.1.0.yaml rename tests/Fixtures/Scratch/{PropertyInheritance.yaml => PropertyInheritance3.0.0.yaml} (100%) create mode 100644 tests/Fixtures/Scratch/PropertyInheritance3.1.0.yaml rename tests/Fixtures/Scratch/{RequestBody.yaml => RequestBody3.0.0.yaml} (100%) create mode 100644 tests/Fixtures/Scratch/RequestBody3.1.0.yaml rename tests/Fixtures/Scratch/{ThirdPartyAnnotation.yaml => ThirdPartyAnnotation3.0.0.yaml} (100%) create mode 100644 tests/Fixtures/Scratch/ThirdPartyAnnotation3.1.0.yaml rename tests/Fixtures/Scratch/{Types31.php => Types.php} (87%) rename tests/Fixtures/Scratch/{Types31.yaml => Types3.1.0.yaml} (88%) rename tests/Fixtures/Scratch/{UsingRefs.yaml => UsingRefs3.0.0.yaml} (73%) create mode 100644 tests/Fixtures/Scratch/UsingRefs3.1.0.yaml diff --git a/composer.json b/composer.json index 52936db00..ef43831c2 100644 --- a/composer.json +++ b/composer.json @@ -84,7 +84,9 @@ "testlegacy": "Run tests using the legacy TokenAnalyser", "testall": "Run all tests (test + testlegacy)", "analyse": "Run static analysis (phpstan/psalm)", - "spectral": "Run spectral lint over all .yaml files in the Examples folder", + "spectral-examples": "Run spectral lint over all .yaml files in the Examples folder", + "spectral-scratch": "Run spectral lint over all .yaml files in the tests/Fixtures/Scratch folder", + "spectral": "Run all spectral tests", "docs:gen": "Rebuild reference documentation", "docs:dev": "Run dev server for local development of gh-pages", "docs:build": "Re-build static gh-pages" @@ -105,7 +107,12 @@ "export XDEBUG_MODE=off && phpstan analyse --memory-limit=2G", "export XDEBUG_MODE=off && psalm" ], - "spectral": "for ff in `find Examples -name '*.yaml'`; do spectral lint $ff; done", + "spectral-examples": "for ff in `find Examples -name '*.yaml'`; do spectral lint $ff; done", + "spectral-scratch": "for ff in `find tests/Fixtures/Scratch -name '*.yaml'`; do spectral lint $ff; done", + "spectral": [ + "@spectral-examples", + "@spectral-scratch" + ], "docs:gen": [ "@php tools/refgen.php", "@php tools/procgen.php" diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 4fbce65e8..530bc44cf 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -86,12 +86,12 @@ parameters: path: src/Processors/DocBlockDescriptions.php - - message: "#^Property OpenApi\\\\Annotations\\\\JsonContent\\:\\:\\$examples \\(array\\\\) does not accept string\\.$#" + message: "#^Property OpenApi\\\\Annotations\\\\Schema\\:\\:\\$examples \\(array\\\\) does not accept string\\.$#" count: 1 path: src/Processors/MergeJsonContent.php - - message: "#^Property OpenApi\\\\Annotations\\\\XmlContent\\:\\:\\$examples \\(array\\\\) does not accept string\\.$#" + message: "#^Property OpenApi\\\\Annotations\\\\Schema\\:\\:\\$examples \\(array\\\\) does not accept string\\.$#" count: 1 path: src/Processors/MergeXmlContent.php diff --git a/src/Annotations/AbstractAnnotation.php b/src/Annotations/AbstractAnnotation.php index f0fe751dc..b81c111b9 100644 --- a/src/Annotations/AbstractAnnotation.php +++ b/src/Annotations/AbstractAnnotation.php @@ -165,6 +165,16 @@ public function __set(string $property, $value): void $this->_context->logger->warning('Ignoring unexpected property "' . $property . '" for ' . $this->identity() . ', expecting "' . implode('", "', array_keys($fields)) . '" in ' . $this->_context); } + /** + * Check if one of the given version numbers matches the current OpenAPI version. + * + * @param string|array $versions One or more version numbers + */ + public function isOpenApiVersion($versions): bool + { + return $this->_context->isVersion($versions); + } + /** * Merge given annotations to their mapped properties configured in static::$_nested. * @@ -350,7 +360,7 @@ public function jsonSerialize() if (isset($data->ref)) { // Only specific https://github.com/OAI/OpenAPI-Specification/blob/3.1.0/versions/3.1.0.md#reference-object $ref = ['$ref' => $data->ref]; - if ($this->_context->version === OpenApi::VERSION_3_1_0) { + if ($this->isOpenApiVersion(OpenApi::VERSION_3_1_0)) { foreach (['summary', 'description'] as $prop) { if (property_exists($this, $prop)) { if (!Generator::isDefault($this->{$prop})) { @@ -361,7 +371,7 @@ public function jsonSerialize() } if (property_exists($this, 'nullable') && $this->nullable === true) { $ref = ['oneOf' => [$ref]]; - if ($this->_context->version == OpenApi::VERSION_3_1_0) { + if ($this->isOpenApiVersion(OpenApi::VERSION_3_1_0)) { $ref['oneOf'][] = ['type' => 'null']; } else { $ref['nullable'] = $data->nullable; @@ -381,7 +391,18 @@ public function jsonSerialize() $data = (object) $ref; } - if ($this->_context->version === OpenApi::VERSION_3_1_0) { + if ($this->isOpenApiVersion(OpenApi::VERSION_3_0_0)) { + if (isset($data->exclusiveMinimum) && is_numeric($data->exclusiveMinimum)) { + $data->minimum = $data->exclusiveMinimum; + $data->exclusiveMinimum = true; + } + if (isset($data->exclusiveMaximum) && is_numeric($data->exclusiveMaximum)) { + $data->maximum = $data->exclusiveMaximum; + $data->exclusiveMaximum = true; + } + } + + if ($this->isOpenApiVersion(OpenApi::VERSION_3_1_0)) { if (isset($data->nullable)) { if (true === $data->nullable) { if (isset($data->oneOf)) { @@ -540,6 +561,13 @@ public function validate(array $stack = [], array $skip = [], string $ref = '', } $stack[] = $this; + if (property_exists($this, 'example') && property_exists($this, 'examples')) { + if (!Generator::isDefault($this->example) && !Generator::isDefault($this->examples)) { + $valid = false; + $this->_context->logger->warning($this->identity() . ': "example" and "examples" are mutually exclusive'); + } + } + return self::_validate($this, $stack, $skip, $ref, $context) ? $valid : false; } diff --git a/src/Annotations/Components.php b/src/Annotations/Components.php index e09a3d497..0b01cae00 100644 --- a/src/Annotations/Components.php +++ b/src/Annotations/Components.php @@ -54,7 +54,7 @@ class Components extends AbstractAnnotation /** * Reusable Examples. * - * @var Examples[] + * @var array */ public $examples = Generator::UNDEFINED; diff --git a/src/Annotations/Examples.php b/src/Annotations/Examples.php index 086c3c9a8..3532bb1e5 100644 --- a/src/Annotations/Examples.php +++ b/src/Annotations/Examples.php @@ -82,6 +82,7 @@ class Examples extends AbstractAnnotation public static $_parents = [ Components::class, + Schema::class, Parameter::class, PathParameter::class, MediaType::class, diff --git a/src/Annotations/HeaderParameter.php b/src/Annotations/HeaderParameter.php index 439a2c018..6b946eabf 100644 --- a/src/Annotations/HeaderParameter.php +++ b/src/Annotations/HeaderParameter.php @@ -6,6 +6,8 @@ namespace OpenApi\Annotations; +use OpenApi\Annotations as OA; + /** * A `@OA\Request` header parameter. * diff --git a/src/Annotations/JsonContent.php b/src/Annotations/JsonContent.php index 357db5ce6..bb6267eae 100644 --- a/src/Annotations/JsonContent.php +++ b/src/Annotations/JsonContent.php @@ -6,7 +6,7 @@ namespace OpenApi\Annotations; -use OpenApi\Generator; +use OpenApi\Annotations as OA; /** * Shorthand for a json response. @@ -17,16 +17,6 @@ */ class JsonContent extends Schema { - /** - * An associative array of Examples attributes. - * - * The keys represent the name of the example and the values are instances of the Examples attribute. - * Each example is used to show how the content of the request or response should look like. - * - * @var array - */ - public $examples = Generator::UNDEFINED; - /** * @inheritdoc */ diff --git a/src/Annotations/License.php b/src/Annotations/License.php index ac04bc68d..8f4f78b73 100644 --- a/src/Annotations/License.php +++ b/src/Annotations/License.php @@ -76,7 +76,7 @@ public function jsonSerialize() { $data = parent::jsonSerialize(); - if ($this->_context->isVersion(OpenApi::VERSION_3_0_0)) { + if ($this->isOpenApiVersion(OpenApi::VERSION_3_0_0)) { unset($data->identifier); } @@ -90,7 +90,7 @@ public function validate(array $stack = [], array $skip = [], string $ref = '', { $valid = parent::validate($stack, $skip, $ref, $context); - if ($this->_context->isVersion(OpenApi::VERSION_3_1_0)) { + if ($this->isOpenApiVersion(OpenApi::VERSION_3_1_0)) { if (!Generator::isDefault($this->url) && $this->identifier !== Generator::UNDEFINED) { $this->_context->logger->warning($this->identity() . ' url and identifier are mutually exclusive'); $valid = false; diff --git a/src/Annotations/MediaType.php b/src/Annotations/MediaType.php index 76348a4a0..be104325e 100644 --- a/src/Annotations/MediaType.php +++ b/src/Annotations/MediaType.php @@ -45,13 +45,11 @@ class MediaType extends AbstractAnnotation /** * Examples of the media type. * - * Each example object should match the media type and specified schema if present. + * Each example should contain a value in the correct format as specified in the parameter encoding. * The examples object is mutually exclusive of the example object. + * Furthermore, if referencing a schema which contains an example, the examples value shall override the example provided by the schema. * - * Furthermore, if referencing a schema which contains an example, - * the examples value shall override the example provided by the schema. - * - * @var array + * @var array */ public $examples = Generator::UNDEFINED; diff --git a/src/Annotations/OpenApi.php b/src/Annotations/OpenApi.php index 267d607c1..4288464ec 100644 --- a/src/Annotations/OpenApi.php +++ b/src/Annotations/OpenApi.php @@ -262,7 +262,7 @@ public function jsonSerialize() { $data = parent::jsonSerialize(); - if (false === $this->_context->isVersion(OpenApi::VERSION_3_1_0)) { + if (false === $this->isOpenApiVersion(OpenApi::VERSION_3_1_0)) { unset($data->webhooks); } diff --git a/src/Annotations/Operation.php b/src/Annotations/Operation.php index e4d1e9acd..bc089dc17 100644 --- a/src/Annotations/Operation.php +++ b/src/Annotations/Operation.php @@ -7,6 +7,7 @@ namespace OpenApi\Annotations; use OpenApi\Generator; +use OpenApi\Annotations as OA; /** * Base class for `@OA\Get`, `@OA\Post`, `@OA\Put`, etc. diff --git a/src/Annotations/Parameter.php b/src/Annotations/Parameter.php index 6e8334dbe..417d5c9ed 100644 --- a/src/Annotations/Parameter.php +++ b/src/Annotations/Parameter.php @@ -147,13 +147,13 @@ class Parameter extends AbstractAnnotation public $example = Generator::UNDEFINED; /** - * Examples of the media type. + * Examples of the parameter. * * Each example should contain a value in the correct format as specified in the parameter encoding. * The examples object is mutually exclusive of the example object. * Furthermore, if referencing a schema which contains an example, the examples value shall override the example provided by the schema. * - * @var array + * @var array */ public $examples = Generator::UNDEFINED; diff --git a/src/Annotations/PathParameter.php b/src/Annotations/PathParameter.php index 4e3d42c8b..e8d157373 100644 --- a/src/Annotations/PathParameter.php +++ b/src/Annotations/PathParameter.php @@ -6,6 +6,8 @@ namespace OpenApi\Annotations; +use OpenApi\Annotations as OA; + /** * A `@OA\Request` path parameter. * diff --git a/src/Annotations/QueryParameter.php b/src/Annotations/QueryParameter.php index 6ee3e47f8..311341a07 100644 --- a/src/Annotations/QueryParameter.php +++ b/src/Annotations/QueryParameter.php @@ -6,6 +6,8 @@ namespace OpenApi\Annotations; +use OpenApi\Annotations as OA; + /** * A `@OA\Request` query parameter. * diff --git a/src/Annotations/Schema.php b/src/Annotations/Schema.php index 610263e82..6a8bbcf28 100644 --- a/src/Annotations/Schema.php +++ b/src/Annotations/Schema.php @@ -57,7 +57,8 @@ class Schema extends AbstractAnnotation /** * The maximum number of properties allowed in an object instance. - * An object instance is valid against this property if its number of properties is less than, or equal to, the value of this attribute. + * An object instance is valid against this property if its number of properties is less than, or equal to, the + * value of this attribute. * * @var int */ @@ -65,7 +66,8 @@ class Schema extends AbstractAnnotation /** * The minimum number of properties allowed in an object instance. - * An object instance is valid against this property if its number of properties is greater than, or equal to, the value of this attribute. + * An object instance is valid against this property if its number of properties is greater than, or equal to, the + * value of this attribute. * * @var int */ @@ -121,9 +123,9 @@ class Schema extends AbstractAnnotation * - ssv: space separated values foo bar. * - tsv: tab separated values foo\tbar. * - pipes: pipe separated values foo|bar. - * - multi: corresponds to multiple parameter instances instead of multiple values for a single instance foo=bar&foo=baz. - * This is valid only for parameters of type query or formData. - * Default value is csv. + * - multi: corresponds to multiple parameter instances instead of multiple values for a single instance + * foo=bar&foo=baz. This is valid only for parameters of type query or formData. Default + * value is csv. * * @var string */ @@ -179,7 +181,8 @@ class Schema extends AbstractAnnotation /** * The maximum length of a string property. * - * A string instance is valid against this property if its length is less than, or equal to, the value of this attribute. + * A string instance is valid against this property if its length is less than, or equal to, the value of this + * attribute. * * @see [JSON schema validation](http://json-schema.org/latest/json-schema-validation.html#anchor26) * @@ -190,7 +193,8 @@ class Schema extends AbstractAnnotation /** * The minimum length of a string property. * - * A string instance is valid against this property if its length is greater than, or equal to, the value of this attribute. + * A string instance is valid against this property if its length is greater than, or equal to, the value of this + * attribute. * * @see [JSON schema validation](http://json-schema.org/latest/json-schema-validation.html#anchor29) * @@ -208,7 +212,8 @@ class Schema extends AbstractAnnotation /** * The maximum number of items allowed in an array property. * - * An array instance is valid against this property if its number of items is less than, or equal to, the value of this attribute. + * An array instance is valid against this property if its number of items is less than, or equal to, the value of + * this attribute. * * @see [JSON schema validation](http://json-schema.org/latest/json-schema-validation.html#anchor42) * @@ -219,7 +224,8 @@ class Schema extends AbstractAnnotation /** * The minimum number of items allowed in an array property. * - * An array instance is valid against this property if its number of items is greater than, or equal to, the value of this attribute. + * An array instance is valid against this property if its number of items is greater than, or equal to, the value + * of this attribute. * * @see [JSON schema validation](http://json-schema.org/latest/json-schema-validation.html#anchor45) * @@ -241,7 +247,8 @@ class Schema extends AbstractAnnotation /** * A collection of allowable values for a property. * - * A property instance is valid against this attribute if its value is one of the values specified in this collection. + * A property instance is valid against this attribute if its value is one of the values specified in this + * collection. * * @see [JSON schema validation](http://json-schema.org/latest/json-schema-validation.html#anchor76) * @@ -319,6 +326,19 @@ class Schema extends AbstractAnnotation */ public $example = Generator::UNDEFINED; + /** + * Examples of the schema. + * + * Each example should contain a value in the correct format as specified in the parameter encoding. + * The examples object is mutually exclusive of the example object. + * Furthermore, if referencing a schema which contains an example, the examples value shall override the example provided by the schema. + * + * @since 3.1.0 + * + * @var array + */ + public $examples = Generator::UNDEFINED; + /** * Allows sending a null value for the defined schema. * Default value is false. @@ -439,6 +459,7 @@ class Schema extends AbstractAnnotation Items::class => 'items', Property::class => ['properties', 'property'], ExternalDocumentation::class => 'externalDocs', + Examples::class => ['examples', 'example'], Xml::class => 'xml', AdditionalProperties::class => 'additionalProperties', Attachable::class => ['attachables'], @@ -463,8 +484,9 @@ public function jsonSerialize() { $data = parent::jsonSerialize(); - if (isset($data->const)) { - if ($this->_context->isVersion(OpenApi::VERSION_3_0_0)) { + if ($this->isOpenApiVersion(OpenApi::VERSION_3_0_0)) { + unset($data->examples); + if (isset($data->const)) { $data->enum = [$data->const]; unset($data->const); } @@ -484,6 +506,14 @@ public function validate(array $stack = [], array $skip = [], string $ref = '', return false; } + if ($this->isOpenApiVersion(OpenApi::VERSION_3_0_0)) { + if (!Generator::isDefault($this->examples)) { + $this->_context->logger->warning($this->identity() . ' is only allowed for ' . OpenApi::VERSION_3_1_0); + + return false; + } + } + return parent::validate($stack, $skip, $ref, $context); } } diff --git a/src/Annotations/XmlContent.php b/src/Annotations/XmlContent.php index 59f765117..c1813babf 100644 --- a/src/Annotations/XmlContent.php +++ b/src/Annotations/XmlContent.php @@ -6,7 +6,7 @@ namespace OpenApi\Annotations; -use OpenApi\Generator; +use OpenApi\Annotations as OA; /** * Shorthand for a xml response. @@ -17,11 +17,6 @@ */ class XmlContent extends Schema { - /** - * @var array - */ - public $examples = Generator::UNDEFINED; - /** * @inheritdoc */ diff --git a/src/Attributes/Components.php b/src/Attributes/Components.php index ca88fe19e..853717e16 100644 --- a/src/Attributes/Components.php +++ b/src/Attributes/Components.php @@ -16,7 +16,7 @@ class Components extends \OpenApi\Annotations\Components * @param Response[]|null $responses * @param Parameter[]|null $parameters * @param RequestBody[]|null $requestBodies - * @param Examples[]|null $examples + * @param array|null $examples * @param Header[]|null $headers * @param SecurityScheme[]|null $securitySchemes * @param Link[]|null $links diff --git a/src/Attributes/JsonContent.php b/src/Attributes/JsonContent.php index 8ab81a0e2..d6797830a 100644 --- a/src/Attributes/JsonContent.php +++ b/src/Attributes/JsonContent.php @@ -14,7 +14,7 @@ class JsonContent extends \OpenApi\Annotations\JsonContent /** * @param string|non-empty-array|null $type * @param string|class-string|object|null $ref - * @param array $examples + * @param array $examples * @param string[] $required * @param Property[] $properties * @param int|float $maximum diff --git a/src/Attributes/MediaType.php b/src/Attributes/MediaType.php index 4f307d450..705cfcee8 100644 --- a/src/Attributes/MediaType.php +++ b/src/Attributes/MediaType.php @@ -12,7 +12,7 @@ class MediaType extends \OpenApi\Annotations\MediaType { /** - * @param array $examples + * @param array $examples * @param array $encoding * @param array|null $x * @param Attachable[]|null $attachables diff --git a/src/Attributes/ParameterTrait.php b/src/Attributes/ParameterTrait.php index ea5fa34b6..e994d16a4 100644 --- a/src/Attributes/ParameterTrait.php +++ b/src/Attributes/ParameterTrait.php @@ -12,7 +12,7 @@ trait ParameterTrait { /** * @param string|class-string|object|null $ref - * @param array $examples + * @param array $examples * @param array|JsonContent|XmlContent|Attachable|null $content * @param array|null $x * @param Attachable[]|null $attachables diff --git a/src/Attributes/Schema.php b/src/Attributes/Schema.php index ee55639b0..8a7e8cffc 100644 --- a/src/Attributes/Schema.php +++ b/src/Attributes/Schema.php @@ -6,6 +6,7 @@ namespace OpenApi\Attributes; +use OpenApi\Annotations\Examples; use OpenApi\Generator; #[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::TARGET_PROPERTY | \Attribute::IS_REPEATABLE)] @@ -19,6 +20,7 @@ class Schema extends \OpenApi\Annotations\Schema * @param int|float $maximum * @param int|float $minimum * @param array|class-string|null $enum + * @param array $examples * @param array $allOf * @param array $anyOf * @param array $oneOf @@ -57,6 +59,7 @@ public function __construct( ?Xml $xml = null, ?ExternalDocumentation $externalDocs = null, mixed $example = Generator::UNDEFINED, + ?array $examples = null, ?bool $nullable = null, ?bool $deprecated = null, ?array $allOf = null, @@ -105,7 +108,7 @@ public function __construct( 'const' => $const, 'x' => $x ?? Generator::UNDEFINED, 'attachables' => $attachables ?? Generator::UNDEFINED, - 'value' => $this->combine($items, $discriminator, $externalDocs, $attachables), + 'value' => $this->combine($items, $discriminator, $externalDocs, $examples, $attachables), ]); } } diff --git a/src/Attributes/XmlContent.php b/src/Attributes/XmlContent.php index f6b2ecadf..201277b0c 100644 --- a/src/Attributes/XmlContent.php +++ b/src/Attributes/XmlContent.php @@ -14,7 +14,7 @@ class XmlContent extends \OpenApi\Annotations\XmlContent /** * @param string|non-empty-array|null $type * @param string|class-string|object|null $ref - * @param array $examples + * @param array $examples * @param string[] $required * @param int|float $maximum * @param int|float $minimum diff --git a/src/Processors/AugmentProperties.php b/src/Processors/AugmentProperties.php index 793b465b7..76413b0f7 100644 --- a/src/Processors/AugmentProperties.php +++ b/src/Processors/AugmentProperties.php @@ -144,7 +144,11 @@ protected function augmentType(Analysis $analysis, OA\Property $property, Contex $property->minimum = 0; } elseif ($type === 'non-zero-int') { $property->type = 'integer'; - $property->not = ['const' => 0]; + if ($property->isOpenApiVersion(OA\OpenApi::VERSION_3_1_0)) { + $property->not = ['const' => 0]; + } else { + $property->not = ['enum' => [0]]; + } } } diff --git a/tests/Analysers/ReflectionAnalyserTest.php b/tests/Analysers/ReflectionAnalyserTest.php index c547c9007..98d689de1 100644 --- a/tests/Analysers/ReflectionAnalyserTest.php +++ b/tests/Analysers/ReflectionAnalyserTest.php @@ -11,7 +11,6 @@ use OpenApi\Analysers\AttributeAnnotationFactory; use OpenApi\Analysers\DocBlockAnnotationFactory; use OpenApi\Analysers\ReflectionAnalyser; -use OpenApi\Analysers\TokenAnalyser; use OpenApi\Analysis; use OpenApi\Annotations as OA; use OpenApi\Context; @@ -184,9 +183,7 @@ public function testApiMixedBasic(AnalyserInterface $analyser): void */ public function testPhp8PromotedProperties(): void { - if ($this->getAnalyzer() instanceof TokenAnalyser) { - $this->markTestSkipped(); - } + $this->skipLegacy(); $analysis = $this->analysisFromFixtures(['PHP/Php8PromotedProperties.php']); $schemas = $analysis->getAnnotationsOfType(OA\Schema::class, true); diff --git a/tests/Annotations/AbstractAnnotationTest.php b/tests/Annotations/AbstractAnnotationTest.php index 43ed5522b..5f6efd95f 100644 --- a/tests/Annotations/AbstractAnnotationTest.php +++ b/tests/Annotations/AbstractAnnotationTest.php @@ -142,6 +142,23 @@ public function testIsRoot(): void $this->assertFalse((new OA\AdditionalProperties([]))->isRoot(OA\Schema::class)); $this->assertTrue((new SubSchema([]))->isRoot(OA\Schema::class)); } + + /** + * @requires PHP 8.1 + */ + public function testValidateExamples(): void + { + $this->skipLegacy(); + + $analysis = $this->analysisFromFixtures(['BadExampleParameter.php']); + $analysis->process((new Generator())->getProcessors()); + + $this->assertOpenApiLogEntryContains('Required @OA\PathItem() not found'); + $this->assertOpenApiLogEntryContains('Required @OA\Info() not found'); + $this->assertOpenApiLogEntryContains('"example" and "examples" are mutually exclusive'); + + $analysis->validate(); + } } class SubSchema extends OA\Schema diff --git a/tests/Annotations/AttributesSyncTest.php b/tests/Annotations/AttributesSyncTest.php index 16675d69e..44132b89c 100644 --- a/tests/Annotations/AttributesSyncTest.php +++ b/tests/Annotations/AttributesSyncTest.php @@ -64,13 +64,19 @@ public function testParameterCompleteness(string $annotation): void } if (!$found) { // exclusions... - if ($attributeRC->isSubclassOf(OA\Operation::class) && 'method' == $propertyName) { + if ($attributeRC->isSubclassOf(OA\Operation::class) && in_array($propertyName, ['method'])) { continue; } - if ($attributeRC->isSubclassOf(OA\Attachable::class) && 'x' == $propertyName) { + if ($attributeRC->isSubclassOf(OA\Attachable::class) && in_array($propertyName, ['x'])) { continue; } - if ($attributeRC->isSubclassOf(OA\AdditionalProperties::class) && 'additionalProperties' == $propertyName) { + if ($attributeRC->isSubclassOf(OA\AdditionalProperties::class) && in_array($propertyName, ['additionalProperties', 'examples'])) { + continue; + } + if ($attributeRC->isSubclassOf(OA\Items::class) && in_array($propertyName, ['examples'])) { + continue; + } + if ($attributeRC->isSubclassOf(OA\Property::class) && in_array($propertyName, ['examples'])) { continue; } if (in_array($propertyName, static::$SCHEMA_EXCLUSIONS)) { diff --git a/tests/Annotations/OpenApiTest.php b/tests/Annotations/OpenApiTest.php index 1473608ea..25e6e80ba 100644 --- a/tests/Annotations/OpenApiTest.php +++ b/tests/Annotations/OpenApiTest.php @@ -17,7 +17,7 @@ public function testValidVersion(): void $this->assertOpenApiLogEntryContains('Required @OA\Info() not found'); $openapi = new OA\OpenApi(['_context' => $this->getContext()]); - $openapi->openapi = '3.0.0'; + $openapi->openapi = OA\OpenApi::VERSION_3_0_0; $openapi->validate(); } @@ -26,7 +26,7 @@ public function testValidVersion310(): void $this->assertOpenApiLogEntryContains("At least one of 'Required @OA\PathItem(), @OA\Components() or @OA\Webhook() not found'"); $openapi = new OA\OpenApi(['_context' => $this->getContext()]); - $openapi->openapi = '3.1.0'; + $openapi->openapi = OA\OpenApi::VERSION_3_1_0; $openapi->validate(); } diff --git a/tests/Fixtures/Apis/Attributes/basic.php b/tests/Fixtures/Apis/Attributes/basic.php index bd87d7770..1f91612ae 100644 --- a/tests/Fixtures/Apis/Attributes/basic.php +++ b/tests/Fixtures/Apis/Attributes/basic.php @@ -12,7 +12,7 @@ /** * The Spec. */ -#[OAT\OpenApi(openapi: '3.1.0', security: [['bearerAuth' => []]])] +#[OAT\OpenApi(openapi: OAT\OpenApi::VERSION_3_1_0, security: [['bearerAuth' => []]])] #[OAT\Info( version: '1.0.0', title: 'Basic single file API', diff --git a/tests/Fixtures/BadExampleParameter.php b/tests/Fixtures/BadExampleParameter.php new file mode 100644 index 000000000..649098254 --- /dev/null +++ b/tests/Fixtures/BadExampleParameter.php @@ -0,0 +1,19 @@ +getAnalyzer() instanceof TokenAnalyser) { + $this->markTestSkipped(); + } + } + public function getContext(array $properties = [], ?string $version = OA\OpenApi::DEFAULT_VERSION): Context { return new Context( @@ -235,7 +243,8 @@ public function analysisFromFixtures(array $files, array $processors = [], ?Anal (new Generator($this->getTrackingLogger())) ->setAnalyser($analyzer ?: $this->getAnalyzer()) - ->setProcessors($processors) + // run at least MergeIntoOpenApi to have a valid OpenApi version set + ->setProcessors($processors ?: [new MergeIntoOpenApi()]) ->generate($this->fixtures($files), $analysis, false); return $analysis; diff --git a/tests/Processors/ExpandEnumsTest.php b/tests/Processors/ExpandEnumsTest.php index 1af5af41d..8350f3163 100644 --- a/tests/Processors/ExpandEnumsTest.php +++ b/tests/Processors/ExpandEnumsTest.php @@ -6,7 +6,6 @@ namespace OpenApi\Tests\Processors; -use OpenApi\Analysers\TokenAnalyser; use OpenApi\Annotations as OA; use OpenApi\Generator; use OpenApi\Processors\ExpandEnums; @@ -17,15 +16,16 @@ use OpenApi\Tests\Fixtures\PHP\Enums\TypeEnumStringBacked; use OpenApi\Tests\OpenApiTestCase; +/** + * @requires PHP 8.1 + */ class ExpandEnumsTest extends OpenApiTestCase { public function setUp(): void { parent::setUp(); - if (PHP_VERSION_ID < 80100 || $this->getAnalyzer() instanceof TokenAnalyser) { - $this->markTestSkipped(); - } + $this->skipLegacy(); } public function testExpandUnitEnum(): void diff --git a/tests/ScratchTest.php b/tests/ScratchTest.php index 916a44905..8a6e01874 100644 --- a/tests/ScratchTest.php +++ b/tests/ScratchTest.php @@ -6,6 +6,7 @@ namespace OpenApi\Tests; +use OpenApi\Annotations as OA; use OpenApi\Generator; class ScratchTest extends OpenApiTestCase @@ -19,11 +20,27 @@ public function scratchTests(): iterable continue; } - yield $name => [ - $this->fixture("Scratch/$name.php"), - $this->fixture("Scratch/$name.yaml"), - [], + $scratch = $this->fixture("Scratch/$name.php"); + $specs = [ + $this->fixture("Scratch/{$name}3.1.0.yaml") => OA\OpenApi::VERSION_3_1_0, + $this->fixture("Scratch/{$name}3.0.0.yaml") => OA\OpenApi::VERSION_3_0_0, ]; + + $expectedLogs = [ + 'Examples-3.0.0' => ['@OA\Schema() is only allowed for 3.1.0'], + ]; + + foreach ($specs as $spec => $version) { + if (file_exists($spec)) { + $dataSet = "$name-$version"; + yield $dataSet => [ + $scratch, + $spec, + $version, + array_key_exists($dataSet, $expectedLogs) ? $expectedLogs[$dataSet] : [], + ]; + } + } } } @@ -34,21 +51,19 @@ public function scratchTests(): iterable * * @requires PHP 8.1 */ - public function testScratch(string $scratch, string $spec, array $expectedLog): void + public function testScratch(string $scratch, string $spec, string $version, array $expectedLogs): void { - foreach ($expectedLog as $logLine) { + foreach ($expectedLogs as $logLine) { $this->assertOpenApiLogEntryContains($logLine); } require_once $scratch; $openapi = (new Generator($this->getTrackingLogger())) + ->setVersion($version) ->generate([$scratch]); - if (!file_exists($spec)) { - file_put_contents($spec, $openapi->toYaml()); - } - + // file_put_contents($spec, $openapi->toYaml()); $this->assertSpecEquals($openapi, file_get_contents($spec)); } } diff --git a/tests/SerializerTest.php b/tests/SerializerTest.php index e475a11c3..c1fff64a1 100644 --- a/tests/SerializerTest.php +++ b/tests/SerializerTest.php @@ -49,7 +49,7 @@ private function getExpected(): OA\OpenApi $path->post->responses = [$resp, $respRange]; $expected = new OA\OpenApi(['_context' => $this->getContext()]); - $expected->openapi = '3.0.0'; + $expected->openapi = OA\OpenApi::VERSION_3_0_0; $expected->paths = [ $path, ];