diff --git a/.docs/README.md b/.docs/README.md index 5a606fb..cdd97dc 100644 --- a/.docs/README.md +++ b/.docs/README.md @@ -1,10 +1,11 @@ # Contributte OpenApi -Pure PHP OpenAPI 3.0 implementation for Nette Framework. +Pure PHP OpenAPI implementation for Nette Framework, supporting 3.0 and 3.1. ## Content - [Setup](#setup) +- [Supported versions](#supported-versions) - [OpenAPI](#tracy) - [Version validation](#version-validation) - [Tracy](#tracy) @@ -46,6 +47,21 @@ composer require contributte/openapi - [ServerVariable.php](../src/Schema/ServerVariable.php) - [Tag.php](../src/Schema/Tag.php) +## Supported versions + +| OpenAPI | Support | +|---------|---------| +| 3.0 | yes | +| 3.1 | yes | + +`tests/Cases/VersionSupportTest.php` backs both rows with a complete document per version - +`tests/Cases/Schema/examples/complete-3-0.yaml` and `complete-3-1.yaml` - each using every field +its version defines, except where the specification makes two fields mutually exclusive and only +one of them can appear. For each document the test requires that a `fromArray()`/`toArray()` round +trip loses nothing and that `VersionValidator` reports no problem. For the fields a version +introduced, it additionally requires the document to exercise them, so a later version cannot be +called supported while its additions go untested. + ## Version validation The schema classes accept any document, whatever version it declares. `VersionValidator` diff --git a/src/Schema/Header.php b/src/Schema/Header.php index 7e5885e..feda321 100644 --- a/src/Schema/Header.php +++ b/src/Schema/Header.php @@ -29,6 +29,8 @@ class Header /** @var MediaType[]|null */ private ?array $content = null; + private ?VendorExtensions $vendorExtensions = null; + /** * @param mixed[] $data */ @@ -62,6 +64,8 @@ public static function fromArray(array $data): Header $header->setContent($key, MediaType::fromArray($contentData)); } + $header->setVendorExtensions(VendorExtensions::fromArray($data)); + return $header; } @@ -116,6 +120,10 @@ public function toArray(): array $data['content'] = array_map(static fn (MediaType $mediaType): array => $mediaType->toArray(), $this->content); } + if ($this->vendorExtensions !== null) { + $data = array_merge($data, $this->vendorExtensions->toArray()); + } + return $data; } @@ -177,4 +185,14 @@ public function setContent(string $type, MediaType $mediaType): void $this->content[$type] = $mediaType; } + public function getVendorExtensions(): ?VendorExtensions + { + return $this->vendorExtensions; + } + + public function setVendorExtensions(?VendorExtensions $vendorExtensions): void + { + $this->vendorExtensions = $vendorExtensions; + } + } diff --git a/src/Schema/OAuthFlow.php b/src/Schema/OAuthFlow.php index ef3321c..1ecf0d2 100644 --- a/src/Schema/OAuthFlow.php +++ b/src/Schema/OAuthFlow.php @@ -14,6 +14,8 @@ class OAuthFlow /** @var array */ private array $scopes; + private ?VendorExtensions $vendorExtensions = null; + /** * @param array $scopes */ @@ -35,12 +37,15 @@ public function __construct( */ public static function fromArray(array $data): self { - return new self( + $flow = new self( $data['authorizationUrl'] ?? null, $data['tokenUrl'] ?? null, $data['refreshUrl'] ?? null, $data['scopes'], ); + $flow->setVendorExtensions(VendorExtensions::fromArray($data)); + + return $flow; } /** @@ -64,6 +69,10 @@ public function toArray(): array $data['scopes'] = $this->scopes; + if ($this->vendorExtensions !== null) { + $data = array_merge($data, $this->vendorExtensions->toArray()); + } + return $data; } @@ -113,4 +122,14 @@ public function setScopes(array $scopes): void $this->scopes = $scopes; } + public function getVendorExtensions(): ?VendorExtensions + { + return $this->vendorExtensions; + } + + public function setVendorExtensions(?VendorExtensions $vendorExtensions): void + { + $this->vendorExtensions = $vendorExtensions; + } + } diff --git a/src/Schema/SecurityScheme.php b/src/Schema/SecurityScheme.php index b06a3b5..9a3f8c4 100644 --- a/src/Schema/SecurityScheme.php +++ b/src/Schema/SecurityScheme.php @@ -60,6 +60,8 @@ class SecurityScheme private ?string $openIdConnectUrl = null; + private ?VendorExtensions $vendorExtensions = null; + public function __construct(string $type) { $this->setType($type); @@ -79,6 +81,7 @@ public static function fromArray(array $data): SecurityScheme $securityScheme->setBearerFormat($data['bearerFormat'] ?? null); $securityScheme->setFlows(array_map(static fn (array $flow): OAuthFlow => OAuthFlow::fromArray($flow), $data['flows'] ?? [])); $securityScheme->setOpenIdConnectUrl($data['openIdConnectUrl'] ?? null); + $securityScheme->setVendorExtensions(VendorExtensions::fromArray($data)); return $securityScheme; } @@ -119,6 +122,10 @@ public function toArray(): array $data['openIdConnectUrl'] = $this->openIdConnectUrl; } + if ($this->vendorExtensions !== null) { + $data = array_merge($data, $this->vendorExtensions->toArray()); + } + return $data; } @@ -260,6 +267,16 @@ public function setOpenIdConnectUrl(?string $openIdConnectUrl): void $this->openIdConnectUrl = $openIdConnectUrl; } + public function getVendorExtensions(): ?VendorExtensions + { + return $this->vendorExtensions; + } + + public function setVendorExtensions(?VendorExtensions $vendorExtensions): void + { + $this->vendorExtensions = $vendorExtensions; + } + private static function validateFlow(string $flowType, OAuthFlow $flow): void { $needsAuthorizationUrl = in_array($flowType, [ diff --git a/src/Validator/VersionValidator.php b/src/Validator/VersionValidator.php index 8aaf1b3..a877cd6 100644 --- a/src/Validator/VersionValidator.php +++ b/src/Validator/VersionValidator.php @@ -22,7 +22,7 @@ class VersionValidator * Fields and the version that introduced them. A "*" segment matches any key * of a map. */ - private const FIELD_INTRODUCED_IN = [ + public const FIELD_INTRODUCED_IN = [ 'jsonSchemaDialect' => Version::V3_1, 'webhooks' => Version::V3_1, 'info.summary' => Version::V3_1, @@ -34,7 +34,7 @@ class VersionValidator * Field values and the version that introduced them, as * "document path => [value => version]". */ - private const VALUE_INTRODUCED_IN = [ + public const VALUE_INTRODUCED_IN = [ 'components.securitySchemes.*.type' => [ SecurityScheme::TYPE_MUTUAL_TLS => Version::V3_1, ], diff --git a/tests/Cases/Schema/HeaderTest.php b/tests/Cases/Schema/HeaderTest.php index d322e9f..398c5d1 100644 --- a/tests/Cases/Schema/HeaderTest.php +++ b/tests/Cases/Schema/HeaderTest.php @@ -33,6 +33,19 @@ public function testContent(): void Assert::same($expectedData, Header::fromArray($expectedData)->toArray()); } + public function testVendorExtensions(): void + { + $expectedData = [ + 'description' => 'The number of allowed requests in the current period', + 'x-internal' => true, + ]; + + $header = Header::fromArray($expectedData); + + Assert::same(true, $header->getVendorExtensions()?->getExtension('x-internal')); + Assert::same($expectedData, $header->toArray()); + } + } (new HeaderTest())->run(); diff --git a/tests/Cases/Schema/OAuthFlowTest.php b/tests/Cases/Schema/OAuthFlowTest.php index de83157..76d15e4 100644 --- a/tests/Cases/Schema/OAuthFlowTest.php +++ b/tests/Cases/Schema/OAuthFlowTest.php @@ -65,6 +65,20 @@ public function testClientCredentialsFlowWithoutAuthorizationUrl(): void Assert::same($data, $flow->toArray()); } + public function testVendorExtensions(): void + { + $expectedData = [ + 'authorizationUrl' => 'https://example.com/authorization', + 'scopes' => ['read' => 'Read access'], + 'x-internal' => true, + ]; + + $flow = OAuthFlow::fromArray($expectedData); + + Assert::same(true, $flow->getVendorExtensions()?->getExtension('x-internal')); + Assert::same($expectedData, $flow->toArray()); + } + /** * The OpenAPI Specification marks `scopes` as REQUIRED on the OAuth Flow Object (the map MAY * be empty, but the key MUST be present). fromArray() must not silently manufacture it - a diff --git a/tests/Cases/Schema/SecuritySchemeTest.php b/tests/Cases/Schema/SecuritySchemeTest.php index f8b5919..554bcc0 100644 --- a/tests/Cases/Schema/SecuritySchemeTest.php +++ b/tests/Cases/Schema/SecuritySchemeTest.php @@ -116,6 +116,21 @@ public function testOptional(): void Assert::same($expected, SecurityScheme::fromArray($array)->toArray()); } + public function testVendorExtensions(): void + { + $expectedData = [ + 'type' => SecurityScheme::TYPE_API_KEY, + 'name' => 'api_key', + 'in' => SecurityScheme::IN_HEADER, + 'x-internal' => true, + ]; + + $securityScheme = SecurityScheme::fromArray($expectedData); + + Assert::same(true, $securityScheme->getVendorExtensions()?->getExtension('x-internal')); + Assert::same($expectedData, $securityScheme->toArray()); + } + public function testInvalidType(): void { Assert::exception(static function (): void { diff --git a/tests/Cases/Schema/examples/complete-3-0.yaml b/tests/Cases/Schema/examples/complete-3-0.yaml new file mode 100644 index 0000000..9438391 --- /dev/null +++ b/tests/Cases/Schema/examples/complete-3-0.yaml @@ -0,0 +1,341 @@ +openapi: "3.0.3" +info: + title: Complete 3.0 document + description: Uses every field OpenAPI 3.0 defines, except where two fields exclude each other. + termsOfService: https://example.com/terms + contact: + name: API Support + url: https://example.com/support + email: support@example.com + license: + name: MIT + url: https://opensource.org/licenses/MIT + version: 1.0.0 +servers: + - url: https://{region}.example.com/{version} + description: Regional server + variables: + region: + enum: + - eu + - us + default: eu + description: Data region + version: + default: v1 +security: + - api_key: [] + - petstore_auth: + - read:pets + - write:pets +tags: + - name: pets + description: Everything about pets + externalDocs: + description: Pet documentation + url: https://example.com/docs/pets +externalDocs: + description: Full documentation + url: https://example.com/docs +x-vendor-flag: true +paths: + /pets/{petId}: + summary: A single pet + description: Operations on one pet + servers: + - url: https://pets.example.com + parameters: + - name: petId + in: path + description: Pet identifier + required: true + deprecated: false + style: simple + explode: false + schema: + type: string + example: abc123 + get: + tags: + - pets + summary: Read a pet + description: Returns a single pet + externalDocs: + description: Operation documentation + url: https://example.com/docs/read-pet + operationId: readPet + deprecated: true + parameters: + - name: verbose + in: query + description: Return the long form + required: false + allowEmptyValue: true + allowReserved: true + style: form + explode: true + schema: + type: boolean + examples: + enabled: + summary: Verbose enabled + description: Ask for the long form + value: true + - name: X-Trace + in: header + required: false + style: simple + schema: + type: string + - name: session + in: cookie + required: false + style: form + content: + application/json: + schema: + type: object + responses: + '200': + description: The pet + headers: + X-Rate-Limit: + description: Calls left this hour + required: false + deprecated: false + style: simple + explode: false + schema: + type: integer + example: 42 + x-internal: true + X-Trace-Id: + description: Trace identifier + required: false + style: simple + schema: + type: string + examples: + main: + summary: Main trace + value: trace-12345 + X-Session-Info: + description: Session information + required: false + content: + application/json: + schema: + type: object + content: + application/json: + schema: + type: object + example: + id: abc123 + examples: + first: + summary: The first pet + value: + id: abc123 + links: + owner: + operationId: readPet + parameters: + ownerId: $response.body#/ownerId + requestBody: $response.body + description: The owner of this pet + server: + url: https://owners.example.com + description: Owner service + default: + description: Unexpected error + callbacks: + petStatus: + '{$request.body#/callbackUrl}': + post: + requestBody: + description: Status update + required: true + content: + application/json: + schema: + type: object + responses: + '200': + description: Acknowledged + security: + - api_key: [] + servers: + - url: https://pets.example.com/v1 + put: + operationId: replacePet + requestBody: + description: The replacement pet + required: true + content: + application/json: + schema: + type: object + responses: + '200': + description: The replaced pet + post: + operationId: uploadPetPhoto + requestBody: + description: A multipart upload + required: true + content: + multipart/form-data: + schema: + type: object + properties: + profile: + type: object + avatar: + type: string + format: binary + encoding: + avatar: + contentType: image/png + headers: + X-Checksum: + description: Upload checksum + schema: + type: string + style: form + explode: false + allowReserved: false + responses: + '201': + description: Photo stored + delete: + operationId: deletePet + responses: + '204': + description: Deleted + options: + operationId: describePet + responses: + '200': + description: Allowed methods + head: + operationId: checkPet + responses: + '200': + description: Pet exists + patch: + operationId: updatePet + requestBody: + content: + application/merge-patch+json: + schema: + type: object + responses: + '200': + description: The updated pet + trace: + operationId: tracePet + responses: + '200': + description: Trace result +components: + schemas: + Pet: + type: object + required: + - id + properties: + id: + type: string + tag: + type: string + responses: + NotFound: + description: The pet does not exist + content: + application/json: + schema: + type: object + parameters: + PetIdParameter: + name: petId + in: path + required: true + schema: + type: string + examples: + PetExample: + summary: An example pet + description: A pet with the minimum fields + value: + id: abc123 + requestBodies: + PetBody: + description: A pet to store + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + headers: + XRateLimit: + description: Calls left this hour + schema: + type: integer + securitySchemes: + api_key: + type: apiKey + description: Key based authentication + name: api_key + in: header + x-internal: true + basic_auth: + type: http + description: Basic authentication + scheme: basic + bearer_auth: + type: http + scheme: bearer + bearerFormat: JWT + petstore_auth: + type: oauth2 + description: OAuth 2 authentication + flows: + implicit: + authorizationUrl: https://example.com/oauth/authorize + refreshUrl: https://example.com/oauth/refresh + scopes: + read:pets: Read pets + x-internal: true + password: + tokenUrl: https://example.com/oauth/token + refreshUrl: https://example.com/oauth/refresh + scopes: + read:pets: Read pets + clientCredentials: + tokenUrl: https://example.com/oauth/token + scopes: + write:pets: Write pets + authorizationCode: + authorizationUrl: https://example.com/oauth/authorize + tokenUrl: https://example.com/oauth/token + refreshUrl: https://example.com/oauth/refresh + scopes: + read:pets: Read pets + write:pets: Write pets + openid_auth: + type: openIdConnect + openIdConnectUrl: https://example.com/.well-known/openid-configuration + links: + OwnerLink: + operationRef: '#/paths/~1pets~1{petId}/get' + parameters: + ownerId: $response.body#/ownerId + description: The owner of this pet + callbacks: + PetStatusCallback: + '{$request.body#/callbackUrl}': + post: + responses: + '200': + description: Acknowledged diff --git a/tests/Cases/Schema/examples/complete-3-1.yaml b/tests/Cases/Schema/examples/complete-3-1.yaml new file mode 100644 index 0000000..21b9912 --- /dev/null +++ b/tests/Cases/Schema/examples/complete-3-1.yaml @@ -0,0 +1,375 @@ +openapi: "3.1.0" +jsonSchemaDialect: https://spec.openapis.org/oas/3.1/dialect/base +info: + title: Complete 3.1 document + summary: A complete 3.1 document. + description: Uses every field OpenAPI 3.1 defines, except where two fields exclude each other. + termsOfService: https://example.com/terms + contact: + name: API Support + url: https://example.com/support + email: support@example.com + license: + name: MIT + identifier: MIT + version: 1.0.0 +servers: + - url: https://{region}.example.com/{version} + description: Regional server + variables: + region: + enum: + - eu + - us + default: eu + description: Data region + version: + default: v1 +security: + - api_key: [] + - petstore_auth: + - read:pets + - write:pets +tags: + - name: pets + description: Everything about pets + externalDocs: + description: Pet documentation + url: https://example.com/docs/pets +externalDocs: + description: Full documentation + url: https://example.com/docs +x-vendor-flag: true +webhooks: + petCreated: + post: + requestBody: + content: + application/json: + schema: + type: object + const: null + responses: + '200': + description: Acknowledged + petDeleted: + $ref: '#/components/pathItems/petEvent' + summary: Reference summary + description: Reference description +paths: + /pets/{petId}: + summary: A single pet + description: Operations on one pet + servers: + - url: https://pets.example.com + parameters: + - name: petId + in: path + description: Pet identifier + required: true + deprecated: false + style: simple + explode: false + schema: + type: string + example: abc123 + get: + tags: + - pets + summary: Read a pet + description: Returns a single pet + externalDocs: + description: Operation documentation + url: https://example.com/docs/read-pet + operationId: readPet + deprecated: true + parameters: + - name: verbose + in: query + description: Return the long form + required: false + allowEmptyValue: true + allowReserved: true + style: form + explode: true + schema: + type: boolean + examples: + enabled: + summary: Verbose enabled + description: Ask for the long form + value: true + - name: X-Trace + in: header + required: false + style: simple + schema: + type: string + - name: session + in: cookie + required: false + style: form + content: + application/json: + schema: + type: object + responses: + '200': + description: The pet + headers: + X-Rate-Limit: + description: Calls left this hour + required: false + deprecated: false + style: simple + explode: false + schema: + type: integer + example: 42 + x-internal: true + X-Trace-Id: + description: Trace identifier + required: false + style: simple + schema: + type: string + examples: + main: + summary: Main trace + value: trace-12345 + X-Session-Info: + description: Session information + required: false + content: + application/json: + schema: + type: object + content: + application/json: + schema: + type: [object, "null"] + properties: + history: + type: array + prefixItems: + - type: string + unevaluatedProperties: false + contentMediaType: application/json + example: + id: abc123 + examples: + first: + summary: The first pet + value: + id: abc123 + links: + owner: + operationId: readPet + parameters: + ownerId: $response.body#/ownerId + requestBody: $response.body + description: The owner of this pet + server: + url: https://owners.example.com + description: Owner service + default: + description: Unexpected error + callbacks: + petStatus: + '{$request.body#/callbackUrl}': + post: + requestBody: + description: Status update + required: true + content: + application/json: + schema: + type: object + responses: + '200': + description: Acknowledged + security: + - api_key: [] + servers: + - url: https://pets.example.com/v1 + put: + operationId: replacePet + requestBody: + description: The replacement pet + required: true + content: + application/json: + schema: + type: object + responses: + '200': + description: The replaced pet + post: + operationId: uploadPetPhoto + requestBody: + description: A multipart upload + required: true + content: + multipart/form-data: + schema: + type: object + properties: + profile: + type: object + avatar: + type: string + format: binary + encoding: + avatar: + contentType: image/png + headers: + X-Checksum: + description: Upload checksum + schema: + type: string + style: form + explode: false + allowReserved: false + responses: + '201': + description: Photo stored + delete: + operationId: deletePet + responses: + '204': + description: Deleted + options: + operationId: describePet + responses: + '200': + description: Allowed methods + head: + operationId: checkPet + responses: + '200': + description: Pet exists + patch: + operationId: updatePet + requestBody: + content: + application/merge-patch+json: + schema: + type: object + responses: + '200': + description: The updated pet + trace: + operationId: tracePet + responses: + '200': + description: Trace result +components: + pathItems: + petEvent: + post: + responses: + '200': + description: Acknowledged + schemas: + Pet: + type: object + required: + - id + properties: + id: + type: string + tag: + type: string + responses: + NotFound: + description: The pet does not exist + content: + application/json: + schema: + type: object + parameters: + PetIdParameter: + name: petId + in: path + required: true + schema: + type: string + examples: + PetExample: + summary: An example pet + description: A pet with the minimum fields + value: + id: abc123 + requestBodies: + PetBody: + description: A pet to store + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + headers: + XRateLimit: + description: Calls left this hour + schema: + type: integer + securitySchemes: + api_key: + type: apiKey + description: Key based authentication + name: api_key + in: header + x-internal: true + basic_auth: + type: http + description: Basic authentication + scheme: basic + bearer_auth: + type: http + scheme: bearer + bearerFormat: JWT + mtls: + type: mutualTLS + description: Client certificate authentication + petstore_auth: + type: oauth2 + description: OAuth 2 authentication + flows: + implicit: + authorizationUrl: https://example.com/oauth/authorize + refreshUrl: https://example.com/oauth/refresh + scopes: + read:pets: Read pets + x-internal: true + password: + tokenUrl: https://example.com/oauth/token + refreshUrl: https://example.com/oauth/refresh + scopes: + read:pets: Read pets + clientCredentials: + tokenUrl: https://example.com/oauth/token + scopes: + write:pets: Write pets + authorizationCode: + authorizationUrl: https://example.com/oauth/authorize + tokenUrl: https://example.com/oauth/token + refreshUrl: https://example.com/oauth/refresh + scopes: + read:pets: Read pets + write:pets: Write pets + openid_auth: + type: openIdConnect + openIdConnectUrl: https://example.com/.well-known/openid-configuration + links: + OwnerLink: + operationRef: '#/paths/~1pets~1{petId}/get' + parameters: + ownerId: $response.body#/ownerId + description: The owner of this pet + callbacks: + PetStatusCallback: + '{$request.body#/callbackUrl}': + post: + responses: + '200': + description: Acknowledged diff --git a/tests/Cases/VersionSupportTest.php b/tests/Cases/VersionSupportTest.php new file mode 100644 index 0000000..87099ab --- /dev/null +++ b/tests/Cases/VersionSupportTest.php @@ -0,0 +1,186 @@ + file name". + private const COMPLETE_DOCUMENTS = [ + Version::V3_0 => 'complete-3-0.yaml', + Version::V3_1 => 'complete-3-1.yaml', + ]; + + /** + * @return array + */ + public function provideCompleteDocuments(): array + { + $rows = []; + + foreach (self::COMPLETE_DOCUMENTS as $version => $file) { + $rows[$version] = [$version, $file]; + } + + return $rows; + } + + /** + * @dataProvider provideCompleteDocuments + */ + public function testCompleteDocument(string $version, string $file): void + { + $rawData = Yaml::parseFile(self::DOCUMENT_DIRECTORY . $file); + + $openApi = OpenApi::fromArray($rawData); + + self::assertSameDataStructure($rawData, $openApi->toArray(), $version); + + Assert::same( + [], + array_map(strval(...), (new VersionValidator())->validate($openApi)), + sprintf('document of version %s raises no version problem', $version) + ); + + $expectedPaths = self::expectedPaths($version); + + if ($expectedPaths === []) { + return; + } + + $rawData['openapi'] = Version::SUPPORTED[0]; + $reported = array_map( + static fn (Problem $problem): string => $problem->getPath(), + (new VersionValidator())->validate(OpenApi::fromArray($rawData)) + ); + + $uncovered = array_values(array_filter( + $expectedPaths, + static function (string $pattern) use ($reported): bool { + foreach ($reported as $path) { + if (self::matchesPath($pattern, $path)) { + return false; + } + } + + return true; + } + )); + + Assert::same( + [], + $uncovered, + sprintf('document of version %s exercises every field that version introduced', $version) + ); + } + + /** + * @param mixed[] $expected + * @param mixed[] $actual + */ + private static function assertSameDataStructure(array $expected, array $actual, string $version): void + { + Assert::same( + self::recursiveSort($expected), + self::recursiveSort($actual), + sprintf('document of version %s survives a round trip', $version) + ); + } + + /** + * Key order carries no meaning in an OpenAPI document, so both sides are sorted before + * they are compared. + * + * @param mixed[] $data + * @return mixed[] + */ + private static function recursiveSort(array $data): array + { + foreach ($data as $key => $value) { + if (!is_array($value)) { + continue; + } + + $data[$key] = self::recursiveSort($value); + } + + unset($value); + ksort($data); + + return $data; + } + + /** + * Rule paths a document of this version must exercise: everything introduced after the + * oldest supported version, up to and including this one. The validator's tables are the + * list of what the library knows about versions, so they double as the coverage list. + * + * Both tables contribute "(path, introducedIn)" pairs to a flat list rather than a + * path-keyed map, so a path that carries several values introduced in different versions + * cannot have one overwrite another before the version filter runs. + * + * @return string[] + */ + private static function expectedPaths(string $version): array + { + $oldest = Version::SUPPORTED[0]; + + $pairs = []; + + foreach (VersionValidator::FIELD_INTRODUCED_IN as $path => $introduced) { + $pairs[] = [$path, $introduced]; + } + + foreach (VersionValidator::VALUE_INTRODUCED_IN as $path => $values) { + foreach ($values as $introduced) { + $pairs[] = [$path, $introduced]; + } + } + + $paths = []; + + foreach ($pairs as [$path, $introduced]) { + if (Version::isBefore($oldest, $introduced) && !Version::isBefore($version, $introduced)) { + $paths[$path] = true; + } + } + + $paths = array_keys($paths); + sort($paths); + + return $paths; + } + + /** + * Whether a concrete document path matches a rule path, whose "*" segment stands for any + * single key of a map. + */ + private static function matchesPath(string $pattern, string $path): bool + { + $regex = '#^' . str_replace('\*', '[^.]+', preg_quote($pattern, '#')) . '$#'; + + return preg_match($regex, $path) === 1; + } + +} + +(new VersionSupportTest())->run();