From 9ed2ebd1a5a635ba84bbdd2734c048eb734a905b Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Tue, 9 Dec 2025 00:30:53 +1300 Subject: [PATCH] Handle generating request model specs --- src/Appwrite/Platform/Tasks/Specs.php | 29 +++- src/Appwrite/SDK/Specification/Format.php | 58 ++++--- .../SDK/Specification/Format/OpenAPI3.php | 151 ++++++++++++++---- .../SDK/Specification/Format/Swagger2.php | 146 ++++++++++++++--- src/Appwrite/Utopia/Request/Model.php | 10 -- 5 files changed, 298 insertions(+), 96 deletions(-) diff --git a/src/Appwrite/Platform/Tasks/Specs.php b/src/Appwrite/Platform/Tasks/Specs.php index edb1fc56ed..b94a310771 100644 --- a/src/Appwrite/Platform/Tasks/Specs.php +++ b/src/Appwrite/Platform/Tasks/Specs.php @@ -8,6 +8,7 @@ use Appwrite\SDK\Specification\Format\OpenAPI3; use Appwrite\SDK\Specification\Format\Swagger2; use Appwrite\SDK\Specification\Specification; use Appwrite\Utopia\Request as AppwriteRequest; +use Appwrite\Utopia\Request\Model as RequestModel; use Appwrite\Utopia\Response as AppwriteResponse; use Exception; use Swoole\Http\Request as SwooleRequest; @@ -192,7 +193,8 @@ class Specs extends Action foreach ($platforms as $platform) { $routes = []; - $models = []; + $requestModels = []; + $responseModels = []; $services = []; foreach ($appRoutes as $key => $method) { @@ -264,9 +266,9 @@ class Specs extends Action foreach (Config::getParam('services', []) as $service) { if ( - !isset($service['docs']) // Skip service if not part of the public API - || !isset($service['sdk']) + !isset($service['docs']) // Skip service if not part of the public API || !$service['docs'] + || !isset($service['sdk']) || !$service['sdk'] ) { continue; @@ -278,11 +280,21 @@ class Specs extends Action ]; } - $models = $response->getModels(); + $responseModels = $response->getModels(); - foreach ($models as $key => $value) { + foreach ($responseModels as $key => $value) { if ($platform !== APP_PLATFORM_CONSOLE && !$value->isPublic()) { - unset($models[$key]); + unset($responseModels[$key]); + } + } + + foreach ($routes as $route) { + foreach ($route->getParams() as $param) { + $model = $param['model'] ?? null; + if ($model !== null && \class_exists($model)) { + $instance = new $model(); + $requestModels[$instance->getType()] = $instance; + } } } @@ -290,10 +302,11 @@ class Specs extends Action new App('UTC'), $services, $routes, - $models, + $requestModels, + $responseModels, $keys[$platform], $authCounts[$platform] ?? 0, - $platforms[$platform] + $platforms[$platform], ]; foreach (['swagger2', 'open-api3'] as $format) { diff --git a/src/Appwrite/SDK/Specification/Format.php b/src/Appwrite/SDK/Specification/Format.php index aad832a9f4..105865c9f4 100644 --- a/src/Appwrite/SDK/Specification/Format.php +++ b/src/Appwrite/SDK/Specification/Format.php @@ -2,6 +2,7 @@ namespace Appwrite\SDK\Specification; +use Appwrite\Utopia\Request\Model as RequestModel; use Appwrite\Utopia\Response\Model; use Utopia\App; use Utopia\Config\Config; @@ -9,22 +10,6 @@ use Utopia\Route; abstract class Format { - protected App $app; - - /** - * @var array - */ - protected array $routes; - - /** - * @var array - */ - protected array $models; - - protected array $services; - protected array $keys; - protected int $authCount; - protected string $platform; protected array $params = [ 'name' => '', 'description' => '', @@ -51,15 +36,16 @@ abstract class Format ] ]; - public function __construct(App $app, array $services, array $routes, array $models, array $keys, int $authCount, string $platform) - { - $this->app = $app; - $this->services = $services; - $this->routes = $routes; - $this->models = $models; - $this->keys = $keys; - $this->authCount = $authCount; - $this->platform = $platform; + public function __construct( + protected App $app, + protected array $services, + protected array $routes, + protected array $requestModels, + protected array $responseModels, + protected array $keys, + protected int $authCount, + protected string $platform, + ) { } /** @@ -740,7 +726,7 @@ abstract class Format foreach ($types as $ruleType) { if (!in_array($ruleType, ['string', 'integer', 'boolean', 'json', 'float'])) { $usedModels[] = $ruleType; - foreach ($this->models as $m) { + foreach ($this->responseModels as $m) { if ($m->getType() === $ruleType) { $this->getNestedModels($m, $usedModels); } @@ -749,4 +735,24 @@ abstract class Format } } } + + protected function getNestedRequestModels(RequestModel $model, array &$usedModels): void + { + foreach ($model->getRules() as $rule) { + if (!in_array($model->getType(), $usedModels)) { + continue; + } + $types = (array)$rule['type']; + foreach ($types as $ruleType) { + if (!in_array($ruleType, ['string', 'integer', 'boolean', 'json', 'float', 'double', 'datetime', 'payload', 'array', 'enum'])) { + $usedModels[] = $ruleType; + foreach ($this->requestModels as $m) { + if ($m->getType() === $ruleType) { + $this->getNestedRequestModels($m, $usedModels); + } + } + } + } + } + } } diff --git a/src/Appwrite/SDK/Specification/Format/OpenAPI3.php b/src/Appwrite/SDK/Specification/Format/OpenAPI3.php index bfa33e19f3..dc8a9be33c 100644 --- a/src/Appwrite/SDK/Specification/Format/OpenAPI3.php +++ b/src/Appwrite/SDK/Specification/Format/OpenAPI3.php @@ -9,6 +9,7 @@ use Appwrite\SDK\Response; use Appwrite\SDK\Specification\Format; use Appwrite\Template\Template; use Appwrite\Utopia\Database\Validator\Operation; +use Appwrite\Utopia\Request\Model as RequestModel; use Appwrite\Utopia\Response\Model; use Appwrite\Utopia\Response\Model\Any; use Utopia\Database\Database; @@ -91,6 +92,7 @@ class OpenAPI3 extends Format } $usedModels = []; + $usedRequestModels = []; foreach ($this->routes as $route) { $url = \str_replace('/v1', '', $route->getPath()); @@ -259,7 +261,7 @@ class OpenAPI3 extends Format if (\is_array($responseModel)) { foreach ($responseModel as $modelName) { - foreach ($this->models as $value) { + foreach ($this->responseModels as $value) { if ($value->getType() === $modelName) { $usedModels[] = $modelName; break; @@ -278,7 +280,7 @@ class OpenAPI3 extends Format // lets not assume stuff here! if ($response->getCode() !== 204) { $responseData['model'] = '#/components/schemas/' . $responseModel; - foreach ($this->models as $value) { + foreach ($this->responseModels as $value) { if ($value->getType() === $responseModel) { $usedModels[] = $responseModel; break; @@ -299,7 +301,7 @@ class OpenAPI3 extends Format /** @var Response $response */ $model = $response->getModel(); - foreach ($this->models as $value) { + foreach ($this->responseModels as $value) { if (\is_array($model)) { $model = \array_map(fn ($m) => $m === $value->getType() ? $value : $m, $model); } else { @@ -719,37 +721,50 @@ class OpenAPI3 extends Format $bodyRequired[] = $name; } - $body['content'][$consumes[0]]['schema']['properties'][$name] = [ - 'type' => $node['schema']['type'], - 'description' => $node['description'], - 'x-example' => $node['schema']['x-example'] ?? null - ]; + $property = &$body['content'][$consumes[0]]['schema']['properties'][$name]; + $paramModel = $param['model'] ?? null; - if (isset($node['schema']['enum'])) { - /// If the enum flag is Set, add the enum values to the body - $body['content'][$consumes[0]]['schema']['properties'][$name]['enum'] = $node['schema']['enum']; - $body['content'][$consumes[0]]['schema']['properties'][$name]['x-enum-name'] = $node['schema']['x-enum-name'] ?? null; - $body['content'][$consumes[0]]['schema']['properties'][$name]['x-enum-keys'] = $node['schema']['x-enum-keys'] ?? null; - } + if ($paramModel !== null && \class_exists($paramModel)) { + /** @var RequestModel $requestModelInstance */ + $requestModelInstance = new $paramModel(); + $requestModelType = $requestModelInstance->getType(); + $usedRequestModels[] = $requestModelType; - if ($node['schema']['x-upload-id'] ?? false) { - $body['content'][$consumes[0]]['schema']['properties'][$name]['x-upload-id'] = $node['schema']['x-upload-id']; - } + $property = $array + ? ['type' => 'array', 'description' => $node['description'], 'items' => ['$ref' => '#/components/schemas/' . $requestModelType]] + : ['$ref' => '#/components/schemas/' . $requestModelType]; + } else { + $property = [ + 'type' => $node['schema']['type'], + 'description' => $node['description'], + 'x-example' => $node['schema']['x-example'] ?? null + ]; - if (isset($node['default'])) { - $body['content'][$consumes[0]]['schema']['properties'][$name]['default'] = $node['default']; - } + if (isset($node['schema']['enum'])) { + $property['enum'] = $node['schema']['enum']; + $property['x-enum-name'] = $node['schema']['x-enum-name'] ?? null; + $property['x-enum-keys'] = $node['schema']['x-enum-keys'] ?? null; + } - if (\array_key_exists('items', $node['schema'])) { - $body['content'][$consumes[0]]['schema']['properties'][$name]['items'] = $node['schema']['items']; - } + if ($node['schema']['x-upload-id'] ?? false) { + $property['x-upload-id'] = $node['schema']['x-upload-id']; + } - if ($node['x-global'] ?? false) { - $body['content'][$consumes[0]]['schema']['properties'][$name]['x-global'] = true; + if (isset($node['default'])) { + $property['default'] = $node['default']; + } + + if (\array_key_exists('items', $node['schema'])) { + $property['items'] = $node['schema']['items']; + } + + if ($node['x-global'] ?? false) { + $property['x-global'] = true; + } } if ($isNullable) { - $body['content'][$consumes[0]]['schema']['properties'][$name]['x-nullable'] = true; + $property['nullable'] = true; } } @@ -767,11 +782,11 @@ class OpenAPI3 extends Format $output['paths'][$url][\strtolower($route->getMethod())] = $temp; } - foreach ($this->models as $model) { + foreach ($this->responseModels as $model) { $this->getNestedModels($model, $usedModels); } - foreach ($this->models as $model) { + foreach ($this->responseModels as $model) { if (!in_array($model->getType(), $usedModels) && $model->getType() !== 'error') { continue; } @@ -931,6 +946,86 @@ class OpenAPI3 extends Format $output['components']['schemas'][$model->getType()]['example'] = $examples; } + // Generate request model schemas + foreach ($this->requestModels as $model) { + $this->getNestedRequestModels($model, $usedRequestModels); + } + + foreach ($this->requestModels as $model) { + if (!in_array($model->getType(), $usedRequestModels)) { + continue; + } + + $required = $model->getRequired(); + $rules = $model->getRules(); + + $output['components']['schemas'][$model->getType()] = [ + 'description' => $model->getName(), + 'type' => 'object', + ]; + + if (!empty($rules)) { + $output['components']['schemas'][$model->getType()]['properties'] = []; + } + + if (!empty($required)) { + $output['components']['schemas'][$model->getType()]['required'] = $required; + } + + foreach ($rules as $name => $rule) { + $ruleType = $rule['type']; + $isArray = $rule['array'] ?? false; + + [$type, $format] = match ($ruleType) { + 'string', 'datetime', 'payload' => ['string', null], + 'enum' => ['string', null], + 'json' => ['object', null], + 'array' => ['array', null], + 'integer' => ['integer', 'int32'], + 'float' => ['number', 'float'], + 'double' => ['number', 'double'], + 'boolean' => ['boolean', null], + default => ['object', null], + }; + + $property = &$output['components']['schemas'][$model->getType()]['properties'][$name]; + $property = ['type' => $isArray ? 'array' : $type, 'description' => $rule['description'] ?? '', 'x-example' => $rule['example'] ?? null]; + + if ($ruleType === 'json') { + $property['additionalProperties'] = true; + } + + if ($isArray) { + $property['items'] = ['type' => $type]; + if ($format) { + $property['items']['format'] = $format; + } + } elseif ($format) { + $property['format'] = $format; + } + + // Handle nested model references + if ($type === 'object' && $ruleType && $ruleType !== 'json') { + $refKey = $isArray ? 'anyOf' : 'oneOf'; + $property['items'] = \is_array($ruleType) + ? [$refKey => \array_map(fn ($t) => ['$ref' => '#/components/schemas/' . $t], $ruleType)] + : ['$ref' => '#/components/schemas/' . $ruleType]; + } + + if ($ruleType === 'enum' && !empty($rule['enum'])) { + $target = $isArray ? $property['items'] : $property; + $target['enum'] = \array_values($rule['enum']); + $isArray ? $property['items'] = $target : $property = $target; + } + + if (!in_array($name, $required)) { + $property['nullable'] = true; + } + } + + $output['components']['schemas'][$model->getType()]['example'] = \array_map(fn ($r) => $r['example'] ?? null, $rules); + } + \ksort($output['paths']); return $output; diff --git a/src/Appwrite/SDK/Specification/Format/Swagger2.php b/src/Appwrite/SDK/Specification/Format/Swagger2.php index b0deb034b4..c83884b5c4 100644 --- a/src/Appwrite/SDK/Specification/Format/Swagger2.php +++ b/src/Appwrite/SDK/Specification/Format/Swagger2.php @@ -9,6 +9,7 @@ use Appwrite\SDK\Response; use Appwrite\SDK\Specification\Format; use Appwrite\Template\Template; use Appwrite\Utopia\Database\Validator\Operation; +use Appwrite\Utopia\Request\Model as RequestModel; use Appwrite\Utopia\Response\Model; use Appwrite\Utopia\Response\Model\Any; use Utopia\Database\Database; @@ -88,6 +89,7 @@ class Swagger2 extends Format } $usedModels = []; + $usedRequestModels = []; foreach ($this->routes as $route) { /** @var Route $route */ @@ -267,7 +269,7 @@ class Swagger2 extends Format $responseModel = $response->getModel(); if (\is_array($responseModel)) { foreach ($responseModel as $modelName) { - foreach ($this->models as $value) { + foreach ($this->responseModels as $value) { if ($value->getType() === $modelName) { $usedModels[] = $modelName; break; @@ -286,7 +288,7 @@ class Swagger2 extends Format // lets not assume stuff here! if ($response->getCode() !== 204) { $responseData['model'] = '#/definitions/' . $responseModel; - foreach ($this->models as $value) { + foreach ($this->responseModels as $value) { if ($value->getType() === $responseModel) { $usedModels[] = $responseModel; break; @@ -307,7 +309,7 @@ class Swagger2 extends Format /** @var Response $response */ $model = $response->getModel(); - foreach ($this->models as $value) { + foreach ($this->responseModels as $value) { if (\is_array($model)) { $model = \array_map(fn ($m) => $m === $value->getType() ? $value : $m, $model); } else { @@ -707,30 +709,43 @@ class Swagger2 extends Format $bodyRequired[] = $name; } - $body['schema']['properties'][$name] = [ - 'type' => $node['type'], - 'description' => $node['description'], - 'default' => $node['default'] ?? null, - 'x-example' => $node['x-example'] ?? null, - ]; + $property = &$body['schema']['properties'][$name]; + $paramModel = $param['model'] ?? null; - if (isset($node['enum'])) { - /// If the enum flag is Set, add the enum values to the body - $body['schema']['properties'][$name]['enum'] = $node['enum']; - $body['schema']['properties'][$name]['x-enum-name'] = $node['x-enum-name'] ?? null; - $body['schema']['properties'][$name]['x-enum-keys'] = $node['x-enum-keys'] ?? null; - } + if ($paramModel !== null && \class_exists($paramModel)) { + /** @var RequestModel $requestModelInstance */ + $requestModelInstance = new $paramModel(); + $requestModelType = $requestModelInstance->getType(); + $usedRequestModels[] = $requestModelType; - if ($node['x-global'] ?? false) { - $body['schema']['properties'][$name]['x-global'] = true; + $property = $array + ? ['type' => 'array', 'description' => $node['description'], 'items' => ['$ref' => '#/definitions/' . $requestModelType]] + : ['$ref' => '#/definitions/' . $requestModelType]; + } else { + $property = [ + 'type' => $node['type'], + 'description' => $node['description'], + 'default' => $node['default'] ?? null, + 'x-example' => $node['x-example'] ?? null, + ]; + + if (isset($node['enum'])) { + $property['enum'] = $node['enum']; + $property['x-enum-name'] = $node['x-enum-name'] ?? null; + $property['x-enum-keys'] = $node['x-enum-keys'] ?? null; + } + + if ($node['x-global'] ?? false) { + $property['x-global'] = true; + } + + if (\array_key_exists('items', $node)) { + $property['items'] = $node['items']; + } } if ($isNullable) { - $body['schema']['properties'][$name]['x-nullable'] = true; - } - - if (\array_key_exists('items', $node)) { - $body['schema']['properties'][$name]['items'] = $node['items']; + $property['x-nullable'] = true; } } @@ -750,11 +765,11 @@ class Swagger2 extends Format $output['paths'][$url][\strtolower($route->getMethod())] = $temp; } - foreach ($this->models as $model) { + foreach ($this->responseModels as $model) { $this->getNestedModels($model, $usedModels); } - foreach ($this->models as $model) { + foreach ($this->responseModels as $model) { if (!in_array($model->getType(), $usedModels)) { continue; } @@ -927,6 +942,89 @@ class Swagger2 extends Format $output['definitions'][$model->getType()]['example'] = $examples; } + // Generate request model definitions + foreach ($this->requestModels as $model) { + $this->getNestedRequestModels($model, $usedRequestModels); + } + + foreach ($this->requestModels as $model) { + if (!in_array($model->getType(), $usedRequestModels)) { + continue; + } + + $required = $model->getRequired(); + $rules = $model->getRules(); + + $output['definitions'][$model->getType()] = [ + 'description' => $model->getName(), + 'type' => 'object', + ]; + + if (!empty($rules)) { + $output['definitions'][$model->getType()]['properties'] = []; + } + + if (!empty($required)) { + $output['definitions'][$model->getType()]['required'] = $required; + } + + foreach ($rules as $name => $rule) { + $ruleType = $rule['type']; + $isArray = $rule['array'] ?? false; + + [$type, $format] = match ($ruleType) { + 'string', 'datetime' => ['string', null], + 'enum' => ['string', null], + 'json' => ['object', null], + 'array' => ['array', null], + 'integer' => ['integer', 'int32'], + 'float' => ['number', 'float'], + 'double' => ['number', 'double'], + 'boolean' => ['boolean', null], + 'payload' => ['payload', null], + default => ['object', null], + }; + + $property = &$output['definitions'][$model->getType()]['properties'][$name]; + + if ($ruleType === 'json') { + $property = ['type' => $type, 'additionalProperties' => true, 'description' => $rule['description'] ?? '', 'x-example' => $rule['example'] ?? null]; + continue; + } + + $property = ['type' => $isArray ? 'array' : $type, 'description' => $rule['description'] ?? '', 'x-example' => $rule['example'] ?? null]; + + if ($isArray) { + $property['items'] = ['type' => $type]; + if ($format) { + $property['items']['format'] = $format; + } + } elseif ($format) { + $property['format'] = $format; + } + + // Handle nested model references + if ($type === 'object' && $ruleType && $ruleType !== 'json') { + $refKey = $isArray ? 'x-anyOf' : 'x-oneOf'; + $property['items'] = \is_array($ruleType) + ? [$refKey => \array_map(fn ($t) => ['$ref' => '#/definitions/' . $t], $ruleType)] + : ['type' => 'object', '$ref' => '#/definitions/' . $ruleType]; + } + + if ($ruleType === 'enum' && !empty($rule['enum'])) { + $target = $isArray ? $property['items'] : $property; + $target['enum'] = \array_values($rule['enum']); + $isArray ? $property['items'] = $target : $property = $target; + } + + if (!in_array($name, $required)) { + $property['x-nullable'] = true; + } + } + + $output['definitions'][$model->getType()]['example'] = \array_map(fn ($r) => $r['example'] ?? null, $rules); + } + \ksort($output['paths']); return $output; diff --git a/src/Appwrite/Utopia/Request/Model.php b/src/Appwrite/Utopia/Request/Model.php index cdf97d506b..479e040792 100644 --- a/src/Appwrite/Utopia/Request/Model.php +++ b/src/Appwrite/Utopia/Request/Model.php @@ -9,11 +9,8 @@ abstract class Model public const TYPE_FLOAT = 'double'; public const TYPE_BOOLEAN = 'boolean'; public const TYPE_JSON = 'json'; - public const TYPE_MODEL = 'json'; public const TYPE_DATETIME = 'datetime'; public const TYPE_DATETIME_EXAMPLE = '2020-10-15T06:38:00.000+00:00'; - public const TYPE_RELATIONSHIP = 'relationship'; - public const TYPE_PAYLOAD = 'payload'; public const TYPE_ARRAY = 'array'; public const TYPE_ENUM = 'enum'; @@ -22,11 +19,6 @@ abstract class Model */ protected array $rules = []; - /** - * @var array - */ - public array $conditions = []; - /** * Get name * @@ -95,13 +87,11 @@ abstract class Model public function getRequired(): array { $list = []; - foreach ($this->rules as $key => $rule) { if ($rule['required'] ?? false) { $list[] = $key; } } - return $list; } }