From affd5876abf11fcc88208f05e5a2cf84d97c5308 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Wed, 22 Apr 2026 14:49:35 +0530 Subject: [PATCH] Add spec enum service overlap validation --- src/Appwrite/Platform/Tasks/Specs.php | 141 ++++++++++++++++++++++++ tests/unit/Platform/Tasks/SpecsTest.php | 122 ++++++++++++++++++++ 2 files changed, 263 insertions(+) create mode 100644 tests/unit/Platform/Tasks/SpecsTest.php diff --git a/src/Appwrite/Platform/Tasks/Specs.php b/src/Appwrite/Platform/Tasks/Specs.php index 2c03ad3108..e9fa15e6c7 100644 --- a/src/Appwrite/Platform/Tasks/Specs.php +++ b/src/Appwrite/Platform/Tasks/Specs.php @@ -297,6 +297,146 @@ class Specs extends Action ]; } + protected function verifyParsedSpec(array $spec): void + { + $services = []; + foreach ($spec['tags'] ?? [] as $tag) { + if (!\is_array($tag)) { + continue; + } + + $service = $tag['name'] ?? null; + if (!\is_string($service) || $service === '') { + continue; + } + + $services[$this->normalizeSdkName($service)] = $service; + } + + if (empty($services)) { + return; + } + + $enums = []; + $this->collectSpecEnumNames($spec, $enums); + + if (empty($enums)) { + return; + } + + $overlaps = []; + foreach ($services as $normalized => $service) { + if (!isset($enums[$normalized])) { + continue; + } + + foreach ($enums[$normalized] as $enum) { + $overlaps[] = "{$enum} (service '{$service}', enum '{$enum}')"; + } + } + + if (!empty($overlaps)) { + throw new \RuntimeException( + 'Spec service names must not overlap enum names. Overlaps: ' + . \implode(', ', \array_unique($overlaps)) + ); + } + } + + private function collectSpecEnumNames(array $node, array &$enums, ?string $fallbackName = null): void + { + if (isset($node['enum']) && \is_array($node['enum'])) { + $enumName = $this->getExplicitSpecEnumName($node) + ?? $this->getFallbackSpecEnumName($node, $fallbackName); + + if (!\is_null($enumName)) { + $this->addSpecEnumName($enums, $enumName); + } + } + + if ( + isset($node['items']) + && \is_array($node['items']) + && isset($node['items']['enum']) + && \is_array($node['items']['enum']) + ) { + $enumName = $this->getExplicitSpecEnumName($node['items']) + ?? $this->getExplicitSpecEnumName($node) + ?? $this->getFallbackSpecEnumName($node, $fallbackName); + + if (!\is_null($enumName)) { + $this->addSpecEnumName($enums, $enumName); + } + } + + $explicitEnumName = $this->getExplicitSpecEnumName($node); + if (!\is_null($explicitEnumName)) { + $this->addSpecEnumName($enums, $explicitEnumName); + } + + foreach ($node as $key => $value) { + if (!\is_array($value)) { + continue; + } + + $this->collectSpecEnumNames( + $value, + $enums, + $this->getChildSpecEnumFallbackName($node, $key, $value, $fallbackName) + ); + } + } + + private function addSpecEnumName(array &$enums, string $name): void + { + $enums[$this->normalizeSdkName($name)][] = $this->formatSdkName($name); + } + + private function getExplicitSpecEnumName(array $node): ?string + { + $enumName = $node['x-enum-name'] ?? null; + + return \is_string($enumName) && $enumName !== '' ? $enumName : null; + } + + private function getFallbackSpecEnumName(array $node, ?string $fallbackName): ?string + { + $name = $node['name'] ?? $fallbackName; + + return \is_string($name) && $name !== '' ? $name : null; + } + + private function getChildSpecEnumFallbackName( + array $parent, + int|string $key, + array $child, + ?string $fallbackName + ): ?string { + if (isset($child['name']) && \is_string($child['name']) && $child['name'] !== '') { + return $child['name']; + } + + if ($key === 'schema' || $key === 'items') { + return $this->getFallbackSpecEnumName($parent, $fallbackName); + } + + if (\is_string($key) && !\in_array($key, ['components', 'content', 'definitions', 'parameters', 'paths', 'properties', 'responses'], true)) { + return $key; + } + + return $fallbackName; + } + + private function formatSdkName(string $name): string + { + return \str_replace(' ', '', \ucwords(\str_replace(['-', '_', '/'], ' ', $name))); + } + + private function normalizeSdkName(string $name): string + { + return \strtolower((string) \preg_replace('/[^a-z0-9]/i', '', $name)); + } + public function getSDKPlatformsForRouteSecurity(array $routeSecurity): array { $sdkPlatforms = []; @@ -483,6 +623,7 @@ class Specs extends Action try { $parsedSpecs = $specs->parse(); + $this->verifyParsedSpec($parsedSpecs); } catch (\RuntimeException $e) { throw new \RuntimeException("Spec generation failed for {$platform} ({$format}): " . $e->getMessage(), 0, $e); } diff --git a/tests/unit/Platform/Tasks/SpecsTest.php b/tests/unit/Platform/Tasks/SpecsTest.php new file mode 100644 index 0000000000..a10c66bf81 --- /dev/null +++ b/tests/unit/Platform/Tasks/SpecsTest.php @@ -0,0 +1,122 @@ +verifyParsedSpec($spec); + } +} + +class SpecsTest extends TestCase +{ + private TestSpecs $specs; + + protected function setUp(): void + { + $this->specs = new TestSpecs(); + } + + public function testVerifyParsedSpecFailsOnServiceEnumNameOverlap(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Locale (service \'locale\', enum \'Locale\')'); + + $this->specs->verify([ + 'tags' => [ + [ + 'name' => 'locale', + 'description' => 'Locale APIs', + ], + ], + 'paths' => [ + '/account/sessions/oauth2/{provider}' => [ + 'get' => [ + 'parameters' => [ + [ + 'name' => 'provider', + 'schema' => [ + 'type' => 'string', + 'enum' => ['en'], + 'x-enum-name' => 'Locale', + ], + ], + ], + ], + ], + ], + ]); + } + + public function testVerifyParsedSpecFailsOnDerivedServiceEnumNameOverlap(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Locale (service \'locale\', enum \'Locale\')'); + + $this->specs->verify([ + 'tags' => [ + [ + 'name' => 'locale', + 'description' => 'Locale APIs', + ], + ], + 'paths' => [ + '/projects/{projectId}/templates/email/{type}/{locale}' => [ + 'patch' => [ + 'parameters' => [ + [ + 'name' => 'payload', + 'in' => 'body', + 'schema' => [ + 'type' => 'object', + 'properties' => [ + 'locale' => [ + 'type' => 'string', + 'enum' => ['en'], + 'x-enum-name' => null, + ], + ], + ], + ], + ], + ], + ], + ], + ]); + } + + public function testVerifyParsedSpecAllowsDistinctServiceAndEnumNames(): void + { + $this->specs->verify([ + 'tags' => [ + [ + 'name' => 'locale', + 'description' => 'Locale APIs', + ], + ], + 'paths' => [ + '/projects/{projectId}/templates/email/{type}/{locale}' => [ + 'patch' => [ + 'parameters' => [ + [ + 'name' => 'locale', + 'schema' => [ + 'type' => 'string', + 'enum' => ['en'], + 'x-enum-name' => 'EmailTemplateLocale', + ], + ], + ], + ], + ], + ], + ]); + + $this->addToAssertionCount(1); + } +}