From a656699fa7199643f34a207103deb38e15e0af37 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Wed, 13 Jul 2022 21:34:56 +1200 Subject: [PATCH] Code cleanup --- app/controllers/api/graphql.php | 2 +- app/init.php | 45 +- src/Appwrite/GraphQL/Builder.php | 849 ------------------ .../{ => Promises}/CoroutinePromise.php | 11 +- .../CoroutinePromiseAdapter.php | 50 +- src/Appwrite/GraphQL/Resolvers.php | 259 ++++++ src/Appwrite/GraphQL/SchemaBuilder.php | 336 +++++++ src/Appwrite/GraphQL/TypeMapper.php | 124 +++ src/Appwrite/GraphQL/TypeRegistry.php | 206 +++++ src/Appwrite/GraphQL/Types/InputFile.php | 32 +- src/Appwrite/GraphQL/Types/Json.php | 14 +- tests/unit/GraphQL/BuilderTest.php | 15 +- 12 files changed, 1011 insertions(+), 932 deletions(-) delete mode 100644 src/Appwrite/GraphQL/Builder.php rename src/Appwrite/GraphQL/{ => Promises}/CoroutinePromise.php (95%) rename src/Appwrite/GraphQL/{ => Promises}/CoroutinePromiseAdapter.php (70%) create mode 100644 src/Appwrite/GraphQL/Resolvers.php create mode 100644 src/Appwrite/GraphQL/SchemaBuilder.php create mode 100644 src/Appwrite/GraphQL/TypeMapper.php create mode 100644 src/Appwrite/GraphQL/TypeRegistry.php diff --git a/app/controllers/api/graphql.php b/app/controllers/api/graphql.php index f07a2117c4..484e8fa3c6 100644 --- a/app/controllers/api/graphql.php +++ b/app/controllers/api/graphql.php @@ -1,7 +1,7 @@ Type::boolean(), - Model::TYPE_STRING => Type::string(), - Model::TYPE_INTEGER => Type::int(), - Model::TYPE_FLOAT => Type::float(), - Model::TYPE_JSON => self::json(), - Response::MODEL_NONE => self::json(), - Response::MODEL_ANY => self::json(), - ]; - - self::$defaultDocumentArgs = [ - 'id' => [ - 'id' => [ - 'type' => Type::string(), - ], - ], - 'list' => [ - 'limit' => [ - 'type' => Type::int(), - 'defaultValue' => 25, - ], - 'offset' => [ - 'type' => Type::int(), - 'defaultValue' => 0, - ], - 'cursor' => [ - 'type' => Type::string(), - 'defaultValue' => '', - ], - 'cursorDirection' => [ - 'type' => Type::string(), - 'defaultValue' => Database::CURSOR_AFTER, - ], - 'orderAttributes' => [ - 'type' => Type::listOf(Type::string()), - 'defaultValue' => [], - ], - 'orderType' => [ - 'type' => Type::listOf(Type::string()), - 'defaultValue' => [], - ], - ], - 'mutate' => [ - 'read' => [ - 'type' => Type::listOf(Type::string()), - 'defaultValue' => ["role:member"], - ], - 'write' => [ - 'type' => Type::listOf(Type::string()), - 'defaultValue' => ["role:member"], - ], - ], - ]; - } - - public static function json(): Json - { - if (is_null(self::$jsonType)) { - self::$jsonType = new Json(); - } - return self::$jsonType; - } - - public static function inputFile(): InputFile - { - if (is_null(self::$inputFile)) { - self::$inputFile = new InputFile(); - } - return self::$inputFile; - } - - /** - * Create a GraphQL type from a Utopia Model - * - * @param Model $model - * @param array $models - * @return Type - */ - private static function getModelTypeMapping(Model $model, array $models): Type - { - if (isset(self::$typeMapping[$model->getType()])) { - return self::$typeMapping[$model->getType()]; - } - - $rules = $model->getRules(); - $name = $model->getType(); - $fields = []; - - if ($model->isAny()) { - $fields['data'] = [ - 'type' => Type::string(), - 'description' => 'Data field', - 'resolve' => fn($object, $args, $context, $info) => \json_encode($object, JSON_FORCE_OBJECT), - ]; - } - - foreach ($rules as $key => $props) { - $escapedKey = str_replace('$', '_', $key); - - $types = \is_array($props['type']) - ? $props['type'] - : [$props['type']]; - - foreach ($types as $type) { - if (isset(self::$typeMapping[$type])) { - $type = self::$typeMapping[$type]; - } else { - try { - $complexModel = $models[$type]; - $type = self::getModelTypeMapping($complexModel, $models); - } catch (\Exception $e) { - Console::error("Could not find model for : {$type}"); - } - } - - if ($props['array']) { - $type = Type::listOf($type); - } - - $fields[$escapedKey] = [ - 'type' => $type, - 'description' => $props['description'], - 'resolve' => fn($object, $args, $context, $info) => $object[$key], - ]; - } - } - $objectType = [ - 'name' => $name, - 'fields' => $fields - ]; - - self::$typeMapping[$name] = new ObjectType($objectType); - - return self::$typeMapping[$name]; - } - - /** - * Map a Utopia\Validator to a valid GraphQL Type - * - * @param App $utopia - * @param Validator|callable $validator - * @param bool $required - * @param array $injections - * @return Type - * @throws \Exception - */ - private static function getParameterType( - App $utopia, - Validator|callable $validator, - bool $required, - array $injections - ): Type { - $validator = \is_callable($validator) - ? \call_user_func_array($validator, $utopia->getResources($injections)) - : $validator; - - switch ((!empty($validator)) ? \get_class($validator) : '') { - case 'Appwrite\Auth\Validator\Password': - case 'Appwrite\Event\Validator\Event': - case 'Appwrite\Network\Validator\CNAME': - case 'Appwrite\Network\Validator\Domain': - case 'Appwrite\Network\Validator\Email': - case 'Appwrite\Network\Validator\Host': - case 'Appwrite\Network\Validator\IP': - case 'Appwrite\Network\Validator\Origin': - case 'Appwrite\Network\Validator\URL': - case 'Appwrite\Task\Validator\Cron': - case 'Appwrite\Utopia\Database\Validator\CustomId': - case 'Utopia\Database\Validator\Key': - case 'Utopia\Database\Validator\CustomId': - case 'Utopia\Database\Validator\UID': - case 'Utopia\Validator\HexColor': - case 'Utopia\Validator\Length': - case 'Utopia\Validator\Text': - case 'Utopia\Validator\WhiteList': - default: - $type = Type::string(); - break; - case 'Utopia\Validator\Boolean': - $type = Type::boolean(); - break; - case 'Utopia\Validator\ArrayList': - $type = Type::listOf(self::getParameterType( - $utopia, - $validator->getValidator(), - $required, - $injections - )); - break; - case 'Utopia\Validator\Numeric': - case 'Utopia\Validator\Integer': - case 'Utopia\Validator\Range': - $type = Type::int(); - break; - case 'Utopia\Validator\FloatValidator': - $type = Type::float(); - break; - case 'Utopia\Database\Validator\Authorization': - case 'Utopia\Database\Validator\Permissions': - $type = Type::listOf(Type::string()); - break; - case 'Utopia\Validator\Assoc': - case 'Utopia\Validator\JSON': - $type = self::json(); - break; - case 'Utopia\Storage\Validator\File': - $type = self::inputFile(); - break; - } - - if ($required) { - $type = Type::nonNull($type); - } - - return $type; - } - - /** - * Map an Attribute type to a valid GraphQL Type - * - * @param string $type - * @param bool $array - * @param bool $required - * @return Type - * @throws \Exception - */ - private static function getAttributeType(string $type, bool $array, bool $required): Type - { - if ($array) { - return Type::listOf(self::getAttributeType($type, false, $required)); - } - - $type = match ($type) { - 'boolean' => Type::boolean(), - 'integer' => Type::int(), - 'double' => Type::float(), - default => Type::string(), - }; - - if ($required) { - $type = Type::nonNull($type); - } - - return $type; - } - - /** - * @throws \Exception - */ - public static function buildSchema( - App $utopia, - Database $dbForProject, - Document $user, - ): Schema { - App::setResource('current', fn() => $utopia); - - $start = microtime(true); - $register = $utopia->getResource('register'); - $envVersion = App::getEnv('_APP_VERSION'); - $schemaVersion = $register->has('apiSchemaVersion') ? $register->get('apiSchemaVersion') : ''; - $collectionSchemaDirty = $register->has('schemaDirty') && $register->get('schemaDirty'); - $apiSchemaDirty = \version_compare($envVersion, $schemaVersion, "!="); - - if ( - !$collectionSchemaDirty - && !$apiSchemaDirty - && $register->has('fullSchema') - ) { - $timeElapsedMillis = (microtime(true) - $start) * 1000; - $timeElapsedMillis = \number_format((float) $timeElapsedMillis, 3, '.', ''); - Console::info('[INFO] Fetched GraphQL Schema in ' . $timeElapsedMillis . 'ms'); - - return $register->get('fullSchema'); - } - - if ($register->has('apiSchema') && !$apiSchemaDirty) { - $apiSchema = $register->get('apiSchema'); - } else { - $apiSchema = self::buildAPISchema($utopia); - $register->set('apiSchema', fn() => $apiSchema); - $register->set('apiSchemaVersion', fn() => $envVersion); - } - - if ($register->has('collectionSchema') && !$collectionSchemaDirty) { - $collectionSchema = $register->get('collectionSchema'); - } else { - $collectionSchema = self::buildCollectionSchema($utopia, $dbForProject, $user); - $register->set('collectionSchema', fn() => $collectionSchema); - $register->set('schemaDirty', fn() => false); - } - - $queryFields = \array_merge_recursive($apiSchema['query'], $collectionSchema['query']); - $mutationFields = \array_merge_recursive($apiSchema['mutation'], $collectionSchema['mutation']); - - ksort($queryFields); - ksort($mutationFields); - - $timeElapsedMillis = (microtime(true) - $start) * 1000; - $timeElapsedMillis = \number_format((float) $timeElapsedMillis, 3, '.', ''); - Console::info('[INFO] Built GraphQL Schema in ' . $timeElapsedMillis . 'ms'); - - $schema = new Schema([ - 'query' => new ObjectType([ - 'name' => 'Query', - 'fields' => $queryFields - ]), - 'mutation' => new ObjectType([ - 'name' => 'Mutation', - 'fields' => $mutationFields - ]) - ]); - - $register->set('fullSchema', fn() => $schema); - - return $schema; - } - - /** - * This function iterates all API routes and builds a - * GraphQL schema defining types (and resolvers) for all response models - * - * @param App $utopia - * @return array - * @throws \Exception - */ - public static function buildAPISchema(App $utopia): array - { - $start = microtime(true); - - self::init(); - $queryFields = []; - $mutationFields = []; - $response = new Response(new SwooleResponse()); - $models = $response->getModels(); - - foreach (App::getRoutes() as $method => $routes) { - foreach ($routes as $route) { - /** @var Route $route */ - - if (str_starts_with($route->getPath(), '/v1/mock/')) { - continue; - } - - $namespace = $route->getLabel('sdk.namespace', ''); - $methodName = $namespace . \ucfirst($route->getLabel('sdk.method', '')); - $responseModelNames = $route->getLabel('sdk.response.model', "none"); - - $responseModels = \is_array($responseModelNames) - ? \array_map(static fn($m) => $models[$m], $responseModelNames) - : [$models[$responseModelNames]]; - - foreach ($responseModels as $responseModel) { - $type = self::getModelTypeMapping($responseModel, $models); - $description = $route->getDesc(); - $args = []; - - foreach ($route->getParams() as $key => $value) { - $argType = self::getParameterType( - $utopia, - $value['validator'], - !$value['optional'], - $value['injections'] - ); - $args[$key] = [ - 'type' => $argType, - 'description' => $value['description'], - 'defaultValue' => $value['default'] - ]; - } - - $field = [ - 'type' => $type, - 'description' => $description, - 'args' => $args, - 'resolve' => self::resolveAPIRequest($utopia, $route) - ]; - - switch ($method) { - case 'GET': - $queryFields[$methodName] = $field; - break; - case 'POST': - case 'PUT': - case 'PATCH': - case 'DELETE': - $mutationFields[$methodName] = $field; - break; - default: - throw new \Exception("Unsupported method: $method"); - } - } - } - } - $timeElapsedMillis = (microtime(true) - $start) * 1000; - $timeElapsedMillis = \number_format((float) $timeElapsedMillis, 3, '.', ''); - Console::info("[INFO] Built GraphQL REST API Schema in ${timeElapsedMillis}ms"); - - return [ - 'query' => $queryFields, - 'mutation' => $mutationFields - ]; - } - - /** - * @param App $utopia - * @param ?Route $route - * @return callable - */ - private static function resolveAPIRequest( - App $utopia, - ?Route $route, - ): callable { - return fn($type, $args, $context, $info) => new CoroutinePromise( - function (callable $resolve, callable $reject) use ($utopia, $route, $args, $context, $info) { - // Mutate the original request object to match route - $utopia = $utopia->getResource('current', true); - $request = $utopia->getResource('request', true); - $response = $utopia->getResource('response', true); - $swoole = $request->getSwoole(); - $swoole->server['request_method'] = $route->getMethod(); - $swoole->server['request_uri'] = $route->getPath(); - $swoole->server['path_info'] = $route->getPath(); - - switch ($route->getMethod()) { - case 'GET': - $swoole->get = $args; - break; - default: - $swoole->post = $args; - break; - } - - self::resolve($utopia, $request, $response, $resolve, $reject); - } - ); - } - - /** - * This function iterates all a projects attributes and builds - * GraphQL queries and mutations for the collections they make up. - * - * @param App $utopia - * @param Database $dbForProject - * @param Document|null $user - * @return array - * @throws \Exception - */ - public static function buildCollectionSchema( - App $utopia, - Database $dbForProject, - ?Document $user = null, - ): array { - $start = microtime(true); - - $userId = $user?->getId(); - $collections = []; - $queryFields = []; - $mutationFields = []; - $limit = 1000; - $offset = 0; - $count = 0; - - $wg = new WaitGroup(); - - while ( - !empty($attrs = Authorization::skip(fn() => $dbForProject->find( - 'attributes', - limit: $limit, - offset: $offset - ))) - ) { - $wg->add(); - $count += count($attrs); - go(function () use ($utopia, $dbForProject, &$collections, &$queryFields, &$mutationFields, $limit, &$offset, $attrs, $userId, $wg) { - foreach ($attrs as $attr) { - if ($attr->getAttribute('status') !== 'available') { - continue; - } - $databaseId = $attr->getAttribute('databaseId'); - $collectionId = $attr->getAttribute('collectionId'); - $key = $attr->getAttribute('key'); - $type = $attr->getAttribute('type'); - $array = $attr->getAttribute('array'); - $required = $attr->getAttribute('required'); - $escapedKey = str_replace('$', '_', $key); - $collections[$collectionId][$escapedKey] = [ - 'type' => self::getAttributeType($type, $array, $required), - ]; - } - - foreach ($collections as $collectionId => $attributes) { - $objectType = new ObjectType([ - 'name' => $collectionId, - 'fields' => \array_merge(["_id" => ['type' => Type::string()]], $attributes), - ]); - - $attributes = \array_merge( - $attributes, - self::$defaultDocumentArgs['mutate'] - ); - - $queryFields[$collectionId . 'Get'] = [ - 'type' => $objectType, - 'args' => self::$defaultDocumentArgs['id'], - 'resolve' => self::resolveDocumentGet($utopia, $dbForProject, $databaseId, $collectionId) - ]; - $queryFields[$collectionId . 'List'] = [ - 'type' => $objectType, - 'args' => self::$defaultDocumentArgs['list'], - 'resolve' => self::resolveDocumentList($utopia, $dbForProject, $databaseId, $collectionId) - ]; - $mutationFields[$collectionId . 'Create'] = [ - 'type' => $objectType, - 'args' => $attributes, - 'resolve' => self::resolveDocumentMutate($utopia, $dbForProject, $databaseId, $collectionId, 'POST') - ]; - $mutationFields[$collectionId . 'Update'] = [ - 'type' => $objectType, - 'args' => $attributes, - 'resolve' => self::resolveDocumentMutate($utopia, $dbForProject, $databaseId, $collectionId, 'PATCH') - ]; - $mutationFields[$collectionId . 'Delete'] = [ - 'type' => $objectType, - 'args' => self::$defaultDocumentArgs['id'], - 'resolve' => self::resolveDocumentDelete($utopia, $dbForProject, $databaseId, $collectionId) - ]; - } - $wg->done(); - }); - $offset += $limit; - } - $wg->wait(); - - $timeElapsedMillis = (microtime(true) - $start) * 1000; - $timeElapsedMillis = \number_format((float) $timeElapsedMillis, 3, '.', ''); - Console::info('[INFO] Built GraphQL Project Collection Schema in ' . $timeElapsedMillis . 'ms (' . $count . ' attributes)'); - - return [ - 'query' => $queryFields, - 'mutation' => $mutationFields - ]; - } - - private static function resolveDocumentGet( - App $utopia, - Database $dbForProject, - string $databaseId, - string $collectionId - ): callable { - return fn($type, $args, $context, $info) => new CoroutinePromise( - function (callable $resolve, callable $reject) use ($utopia, $dbForProject, $databaseId, $collectionId, $type, $args) { - try { - $utopia = $utopia->getResource('current', true); - $request = $utopia->getResource('request', true); - $response = $utopia->getResource('response', true); - $swoole = $request->getSwoole(); - $swoole->post = [ - 'databaseId' => $databaseId, - 'collectionId' => $collectionId, - 'documentId' => $args['id'], - ]; - $swoole->server['request_method'] = 'GET'; - $swoole->server['request_uri'] = "/v1/databases/$databaseId/collections/$collectionId/documents/{$args['id']}"; - $swoole->server['path_info'] = "/v1/databases/$databaseId/collections/$collectionId/documents/{$args['id']}"; - - self::resolve($utopia, $request, $response, $resolve, $reject); - } catch (\Throwable $e) { - $reject($e); - return; - } - } - ); - } - - private static function resolveDocumentList( - App $utopia, - Database $dbForProject, - string $databaseId, - string $collectionId, - ): callable { - return fn($type, $args, $context, $info) => new CoroutinePromise( - function (callable $resolve, callable $reject) use ($utopia, $dbForProject, $databaseId, $collectionId, $type, $args) { - $utopia = $utopia->getResource('current', true); - $request = $utopia->getResource('request', true); - $response = $utopia->getResource('response', true); - $swoole = $request->getSwoole(); - $swoole->post = [ - 'databaseId' => $databaseId, - 'collectionId' => $collectionId, - 'limit' => $args['limit'], - 'offset' => $args['offset'], - 'cursor' => $args['cursor'], - 'cursorDirection' => $args['cursorDirection'], - 'orderAttributes' => $args['orderAttributes'], - 'orderType' => $args['orderType'], - ]; - $swoole->server['request_method'] = 'GET'; - $swoole->server['request_uri'] = "/v1/databases/$databaseId/collections/$collectionId/documents"; - $swoole->server['path_info'] = "/v1/databases/$databaseId/collections/$collectionId/documents"; - - self::resolve($utopia, $request, $response, $resolve, $reject); - } - ); - } - - private static function resolveDocumentMutate( - App $utopia, - Database $dbForProject, - string $databaseId, - string $collectionId, - string $method, - ): callable { - return fn($type, $args, $context, $info) => new CoroutinePromise( - function (callable $resolve, callable $reject) use ($utopia, $dbForProject, $databaseId, $collectionId, $method, $type, $args) { - $utopia = $utopia->getResource('current', true); - $request = $utopia->getResource('request', true); - $response = $utopia->getResource('response', true); - $swoole = $request->getSwoole(); - - $id = $args['id'] ?? 'unique()'; - $read = $args['read']; - $write = $args['write']; - - unset($args['id']); - unset($args['read']); - unset($args['write']); - - // Order must be the same as the route params - $swoole->post = [ - 'databaseId' => $databaseId, - 'documentId' => $id, - 'collectionId' => $collectionId, - 'data' => $args, - 'read' => $read, - 'write' => $write, - ]; - $swoole->server['request_method'] = $method; - $swoole->server['request_uri'] = "/v1/databases/$databaseId/collections/$collectionId/documents"; - $swoole->server['path_info'] = "/v1/databases/$databaseId/collections/$collectionId/documents"; - - self::resolve($utopia, $request, $response, $resolve, $reject); - } - ); - } - - private static function resolveDocumentDelete( - App $utopia, - Database $dbForProject, - string $databaseId, - string $collectionId - ): callable { - return fn($type, $args, $context, $info) => new CoroutinePromise( - function (callable $resolve, callable $reject) use ($utopia, $dbForProject, $databaseId, $collectionId, $type, $args) { - $utopia = $utopia->getResource('current', true); - $request = $utopia->getResource('request', true); - $response = $utopia->getResource('response', true); - $swoole = $request->getSwoole(); - $swoole->post = [ - 'databaseId' => $databaseId, - 'collectionId' => $collectionId, - 'documentId' => $args['id'], - ]; - $swoole->server['request_method'] = 'DELETE'; - $swoole->server['request_uri'] = "/v1/databases/$databaseId/collections/$collectionId/documents/{$args['id']}"; - $swoole->server['path_info'] = "/v1/databases/$databaseId/collections/$collectionId/documents/{$args['id']}"; - - self::resolve($utopia, $request, $response, $resolve, $reject); - } - ); - } - - /** - * @param App $utopia - * @param callable $resolve - * @param callable $reject - * @return void - * @throws \Exception - */ - private static function resolve( - App $utopia, - Request $request, - Response $response, - callable $resolve, - callable $reject, - ): void { - // Drop json content type so post args are used directly - if ($request->getHeader('content-type') === 'application/json') { - unset($request->getSwoole()->header['content-type']); - } - - $request = new Request($request->getSwoole()); - $utopia->setResource('request', fn() => $request); - - $response->setContentType(Response::CONTENT_TYPE_NULL); - - try { - // Set route to null so match doesn't early return the GraphQL route - // Then get the inner route by matching the mutated request - $route = $utopia->setRoute(null)->match($request); - - $utopia->execute($route, $request); - } catch (\Throwable $e) { - $reject($e); - return; - } - - $payload = $response->getPayload(); - - if ($response->getStatusCode() < 200 || $response->getStatusCode() >= 400) { - $reject(new GQLException($payload['message'], $response->getStatusCode())); - return; - } - - if (\array_key_exists('$id', $payload)) { - $payload['_id'] = $payload['$id']; - } - - $resolve($payload); - } - - /** - * @throws \Exception - */ - private static function applyChange(array $collectionSchema, array $change): array - { - $collectionId = $change['data']['collectionId']; - $get = $collectionSchema['query'][$collectionId . 'Get']; - $list = $collectionSchema['query'][$collectionId . 'List']; - $create = $collectionSchema['mutation'][$collectionId . 'Create']; - $update = $collectionSchema['mutation'][$collectionId . 'Update']; - $delete = $collectionSchema['mutation'][$collectionId . 'Delete']; - - switch ($change['type']) { - case 'create': - $collectionSchema['query'][$collectionId . 'Get'] = self::addAttribute($get, $change['data']); - $collectionSchema['query'][$collectionId . 'List'] = self::addAttribute($list, $change['data']); - $collectionSchema['mutation'][$collectionId . 'Create'] = self::addAttribute($create, $change['data']); - $collectionSchema['mutation'][$collectionId . 'Update'] = self::addAttribute($update, $change['data']); - $collectionSchema['mutation'][$collectionId . 'Delete'] = self::addAttribute($delete, $change['data']); - break; - case 'delete': - $collectionSchema['query'][$collectionId . 'Get'] = self::removeAttribute($get, $change['data']); - $collectionSchema['query'][$collectionId . 'List'] = self::removeAttribute($list, $change['data']); - $collectionSchema['mutation'][$collectionId . 'Create'] = self::removeAttribute($create, $change['data']); - $collectionSchema['mutation'][$collectionId . 'Update'] = self::removeAttribute($update, $change['data']); - $collectionSchema['mutation'][$collectionId . 'Delete'] = self::removeAttribute($delete, $change['data']); - break; - default: - throw new \Exception('Unknown change type'); - } - - return $collectionSchema; - } - - /** - * @param mixed $root - * @param array $attribute - * @return array - * @throws \Exception - */ - private static function addAttribute(array $root, array $attribute): array - { - $databaseId = $attribute['databaseId']; - $collectionId = $attribute['collectionId']; - $key = $attribute['key']; - $type = $attribute['type']; - $array = $attribute['array']; - $required = $attribute['required']; - $escapedKey = str_replace('$', '_', $key); - - /** @var ObjectType $rootType */ - $rootType = $root['type']; - $rootFields = $rootType->config['fields']; - $rootFields[$escapedKey] = [ - 'type' => self::getAttributeType($type, $array, $required), - ]; - $root['type'] = new ObjectType([ - 'name' => $collectionId, - 'fields' => $rootFields, - ]); - - return $root; - } - - /** - * @param array $root - * @param array $attribute - * @return array - */ - private static function removeAttribute(array $root, array $attribute): array - { - $databaseId = $attribute['databaseId']; - $collectionId = $attribute['collectionId']; - $key = $attribute['key']; - $escapedKey = str_replace('$', '_', $key); - - /** @var ObjectType $rootType */ - $rootType = $root['type']; - $rootFields = $rootType->config['fields']; - - unset($rootFields[$escapedKey]); - - $root['type'] = new ObjectType([ - 'name' => $collectionId, - 'fields' => $rootFields, - ]); - - return $root; - } -} diff --git a/src/Appwrite/GraphQL/CoroutinePromise.php b/src/Appwrite/GraphQL/Promises/CoroutinePromise.php similarity index 95% rename from src/Appwrite/GraphQL/CoroutinePromise.php rename to src/Appwrite/GraphQL/Promises/CoroutinePromise.php index 0eb067a5b7..ac2f0d155a 100644 --- a/src/Appwrite/GraphQL/CoroutinePromise.php +++ b/src/Appwrite/GraphQL/Promises/CoroutinePromise.php @@ -1,11 +1,11 @@ setResult($value); $this->setState(self::STATE_REJECTED); }; - - go(function () use ($executor, $resolve, $reject) { + \go(function () use ($executor, $resolve, $reject) { try { $executor($resolve, $reject); } catch (\Throwable $exception) { @@ -143,7 +142,7 @@ class CoroutinePromise foreach ($promises as $promise) { if (!$promise instanceof CoroutinePromise) { $channel->close(); - throw new \RuntimeException('Not an Appwrite\GraphQL\CoroutinePromise'); + throw new InvariantViolation('Expected instance of CoroutinePromise, got ' . Utils::printSafe($promise)); } $promise->then(function ($value) use ($key, &$result, $channel) { $result[$key] = $value; diff --git a/src/Appwrite/GraphQL/CoroutinePromiseAdapter.php b/src/Appwrite/GraphQL/Promises/CoroutinePromiseAdapter.php similarity index 70% rename from src/Appwrite/GraphQL/CoroutinePromiseAdapter.php rename to src/Appwrite/GraphQL/Promises/CoroutinePromiseAdapter.php index ef7767d79d..b54d3e7639 100644 --- a/src/Appwrite/GraphQL/CoroutinePromiseAdapter.php +++ b/src/Appwrite/GraphQL/Promises/CoroutinePromiseAdapter.php @@ -1,6 +1,6 @@ then($onFulfilled, $onRejected), $this); } + /** + * Create a new promise with the given resolver function. + * + * @param callable $resolver + * @return Promise + */ public function create(callable $resolver): Promise { $promise = new CoroutinePromise(function ($resolve, $reject) use ($resolver) { @@ -43,6 +67,12 @@ class CoroutinePromiseAdapter implements PromiseAdapter return new Promise($promise, $this); } + /** + * Create a new promise that is fulfilled with the given value. + * + * @param $value + * @return Promise + */ public function createFulfilled($value = null): Promise { $promise = new CoroutinePromise(function ($resolve, $reject) use ($value) { @@ -52,6 +82,12 @@ class CoroutinePromiseAdapter implements PromiseAdapter return new Promise($promise, $this); } + /** + * Create a new promise that is rejected with the given reason. + * + * @param $reason + * @return Promise + */ public function createRejected($reason): Promise { $promise = new CoroutinePromise(function ($resolve, $reject) use ($reason) { @@ -61,6 +97,12 @@ class CoroutinePromiseAdapter implements PromiseAdapter return new Promise($promise, $this); } + /** + * Create a new promise that resolves when all passed in promises resolve. + * + * @param array $promisesOrValues + * @return Promise + */ public function all(array $promisesOrValues): Promise { $all = new CoroutinePromise(function (callable $resolve, callable $reject) use ($promisesOrValues) { diff --git a/src/Appwrite/GraphQL/Resolvers.php b/src/Appwrite/GraphQL/Resolvers.php new file mode 100644 index 0000000000..c017ca3382 --- /dev/null +++ b/src/Appwrite/GraphQL/Resolvers.php @@ -0,0 +1,259 @@ + new CoroutinePromise( + function (callable $resolve, callable $reject) use ($utopia, $route, $args, $context, $info) { + $utopia = $utopia->getResource('current', true); + $request = $utopia->getResource('request', true); + $response = $utopia->getResource('response', true); + $swoole = $request->getSwoole(); + $swoole->server['request_method'] = $route->getMethod(); + $swoole->server['request_uri'] = $route->getPath(); + $swoole->server['path_info'] = $route->getPath(); + + switch ($route->getMethod()) { + case 'GET': + $swoole->get = $args; + break; + default: + $swoole->post = $args; + break; + } + + self::resolve($utopia, $request, $response, $resolve, $reject); + } + ); + } + + /** + * Create a resolver for getting a document in a specified database and collection. + * + * @param App $utopia + * @param Database $dbForProject + * @param string $databaseId + * @param string $collectionId + * @return callable + */ + public static function resolveDocumentGet( + App $utopia, + Database $dbForProject, + string $databaseId, + string $collectionId + ): callable { + return fn($type, $args, $context, $info) => new CoroutinePromise( + function (callable $resolve, callable $reject) use ($utopia, $dbForProject, $databaseId, $collectionId, $type, $args) { + $utopia = $utopia->getResource('current', true); + $request = $utopia->getResource('request', true); + $response = $utopia->getResource('response', true); + $swoole = $request->getSwoole(); + $swoole->post = [ + 'databaseId' => $databaseId, + 'collectionId' => $collectionId, + 'documentId' => $args['id'], + ]; + $swoole->server['request_method'] = 'GET'; + $swoole->server['request_uri'] = "/v1/databases/$databaseId/collections/$collectionId/documents/{$args['id']}"; + $swoole->server['path_info'] = "/v1/databases/$databaseId/collections/$collectionId/documents/{$args['id']}"; + + self::resolve($utopia, $request, $response, $resolve, $reject); + } + ); + } + + /** + * Create a resolver for listing documents in a specified database and collection. + * + * @param App $utopia + * @param Database $dbForProject + * @param string $databaseId + * @param string $collectionId + * @return callable + */ + public static function resolveDocumentList( + App $utopia, + Database $dbForProject, + string $databaseId, + string $collectionId, + ): callable { + return fn($type, $args, $context, $info) => new CoroutinePromise( + function (callable $resolve, callable $reject) use ($utopia, $dbForProject, $databaseId, $collectionId, $type, $args) { + $utopia = $utopia->getResource('current', true); + $request = $utopia->getResource('request', true); + $response = $utopia->getResource('response', true); + $swoole = $request->getSwoole(); + $swoole->post = [ + 'databaseId' => $databaseId, + 'collectionId' => $collectionId, + 'limit' => $args['limit'], + 'offset' => $args['offset'], + 'cursor' => $args['cursor'], + 'cursorDirection' => $args['cursorDirection'], + 'orderAttributes' => $args['orderAttributes'], + 'orderType' => $args['orderType'], + ]; + $swoole->server['request_method'] = 'GET'; + $swoole->server['request_uri'] = "/v1/databases/$databaseId/collections/$collectionId/documents"; + $swoole->server['path_info'] = "/v1/databases/$databaseId/collections/$collectionId/documents"; + + self::resolve($utopia, $request, $response, $resolve, $reject); + } + ); + } + + /** + * Create a resolver for mutating a document in a specified database and collection. + * + * @param App $utopia + * @param Database $dbForProject + * @param string $databaseId + * @param string $collectionId + * @param string $method + * @return callable + */ + public static function resolveDocumentMutate( + App $utopia, + Database $dbForProject, + string $databaseId, + string $collectionId, + string $method, + ): callable { + return fn($type, $args, $context, $info) => new CoroutinePromise( + function (callable $resolve, callable $reject) use ($utopia, $dbForProject, $databaseId, $collectionId, $method, $type, $args) { + $utopia = $utopia->getResource('current', true); + $request = $utopia->getResource('request', true); + $response = $utopia->getResource('response', true); + $swoole = $request->getSwoole(); + + $id = $args['id'] ?? 'unique()'; + $read = $args['read']; + $write = $args['write']; + + unset($args['id']); + unset($args['read']); + unset($args['write']); + + // Order must be the same as the route params + $swoole->post = [ + 'databaseId' => $databaseId, + 'documentId' => $id, + 'collectionId' => $collectionId, + 'data' => $args, + 'read' => $read, + 'write' => $write, + ]; + $swoole->server['request_method'] = $method; + $swoole->server['request_uri'] = "/v1/databases/$databaseId/collections/$collectionId/documents"; + $swoole->server['path_info'] = "/v1/databases/$databaseId/collections/$collectionId/documents"; + + self::resolve($utopia, $request, $response, $resolve, $reject); + } + ); + } + + /** + * Create a resolver for deleting a document in a specified database and collection. + * + * @param App $utopia + * @param Database $dbForProject + * @param string $databaseId + * @param string $collectionId + * @return callable + */ + public static function resolveDocumentDelete( + App $utopia, + Database $dbForProject, + string $databaseId, + string $collectionId + ): callable { + return fn($type, $args, $context, $info) => new CoroutinePromise( + function (callable $resolve, callable $reject) use ($utopia, $dbForProject, $databaseId, $collectionId, $type, $args) { + $utopia = $utopia->getResource('current', true); + $request = $utopia->getResource('request', true); + $response = $utopia->getResource('response', true); + $swoole = $request->getSwoole(); + $swoole->post = [ + 'databaseId' => $databaseId, + 'collectionId' => $collectionId, + 'documentId' => $args['id'], + ]; + $swoole->server['request_method'] = 'DELETE'; + $swoole->server['request_uri'] = "/v1/databases/$databaseId/collections/$collectionId/documents/{$args['id']}"; + $swoole->server['path_info'] = "/v1/databases/$databaseId/collections/$collectionId/documents/{$args['id']}"; + + self::resolve($utopia, $request, $response, $resolve, $reject); + } + ); + } + + /** + * @param App $utopia + * @param Request $request + * @param Response $response + * @param callable $resolve + * @param callable $reject + * @return void + * @throws Exception + */ + public static function resolve( + App $utopia, + Request $request, + Response $response, + callable $resolve, + callable $reject, + ): void { + // Drop json content type so post args are used directly + if ($request->getHeader('content-type') === 'application/json') { + unset($request->getSwoole()->header['content-type']); + } + + $request = new Request($request->getSwoole()); + $utopia->setResource('request', fn() => $request); + $response->setContentType(Response::CONTENT_TYPE_NULL); + + try { + // Set route to null so match doesn't early return the GraphQL route + // Then get the inner route by matching the mutated request + $route = $utopia->setRoute(null)->match($request); + + $utopia->execute($route, $request); + } catch (\Throwable $e) { + $reject($e); + return; + } + + $payload = $response->getPayload(); + + if ($response->getStatusCode() < 200 || $response->getStatusCode() >= 400) { + $reject(new GQLException($payload['message'], $response->getStatusCode())); + return; + } + + if (\array_key_exists('$id', $payload)) { + $payload['_id'] = $payload['$id']; + } + + $resolve($payload); + } +} diff --git a/src/Appwrite/GraphQL/SchemaBuilder.php b/src/Appwrite/GraphQL/SchemaBuilder.php new file mode 100644 index 0000000000..6eafbceec2 --- /dev/null +++ b/src/Appwrite/GraphQL/SchemaBuilder.php @@ -0,0 +1,336 @@ + $utopia); + + $start = microtime(true); + $register = $utopia->getResource('register'); + $envVersion = App::getEnv('_APP_VERSION'); + $schemaVersion = $register->has('apiSchemaVersion') ? $register->get('apiSchemaVersion') : ''; + $collectionSchemaDirty = $register->has('schemaDirty') && $register->get('schemaDirty'); + $apiSchemaDirty = \version_compare($envVersion, $schemaVersion, "!="); + + if ( + !$collectionSchemaDirty + && !$apiSchemaDirty + && $register->has('fullSchema') + ) { + self::printBuildTimeFrom($start); + + return $register->get('fullSchema'); + } + + $apiSchema = self::getApiSchema($utopia, $register, $apiSchemaDirty, $envVersion); + $collectionSchema = self::getCollectionSchema($utopia, $register, $dbForProject, $collectionSchemaDirty); + $schema = self::collateSchema($apiSchema, $collectionSchema); + + self::printBuildTimeFrom($start); + + $register->set('fullSchema', fn() => $schema); + + return $schema; + } + + /** + * This function iterates all API routes and builds a GraphQL + * schema defining types and resolvers for all response models + * + * @param App $utopia + * @return array + * @throws \Exception + */ + public static function buildAPISchema(App $utopia): array + { + $start = microtime(true); + $queryFields = []; + $mutationFields = []; + $response = new Response(new SwooleResponse()); + $models = $response->getModels(); + + TypeRegistry::init($models); + + foreach (App::getRoutes() as $method => $routes) { + foreach ($routes as $route) { + /** @var Route $route */ + + if (str_starts_with($route->getPath(), '/v1/mock/')) { + continue; + } + $namespace = $route->getLabel('sdk.namespace', ''); + $methodName = $namespace . \ucfirst($route->getLabel('sdk.method', '')); + $responseModelNames = $route->getLabel('sdk.response.model', "none"); + $responseModels = \is_array($responseModelNames) + ? \array_map(static fn($m) => $models[$m], $responseModelNames) + : [$models[$responseModelNames]]; + + foreach ($responseModels as $responseModel) { + $type = TypeRegistry::get($responseModel->getType()); + $description = $route->getDesc(); + $args = []; + + foreach ($route->getParams() as $key => $value) { + $argType = TypeMapper::typeFromParameter( + $utopia, + $value['validator'], + !$value['optional'], + $value['injections'] + ); + $args[$key] = [ + 'type' => $argType, + 'description' => $value['description'], + 'defaultValue' => $value['default'] + ]; + } + + $field = [ + 'type' => $type, + 'description' => $description, + 'args' => $args, + 'resolve' => Resolvers::resolveAPIRequest($utopia, $route) + ]; + + switch ($method) { + case 'GET': + $queryFields[$methodName] = $field; + break; + case 'POST': + case 'PUT': + case 'PATCH': + case 'DELETE': + $mutationFields[$methodName] = $field; + break; + default: + throw new \Exception("Unsupported method: $method"); + } + } + } + } + + $timeElapsedMillis = (microtime(true) - $start) * 1000; + $timeElapsedMillis = \number_format((float)$timeElapsedMillis, 3, '.', ''); + Console::info("[INFO] Built GraphQL REST API Schema in ${timeElapsedMillis}ms"); + + return [ + 'query' => $queryFields, + 'mutation' => $mutationFields + ]; + } + + /** + * Iterates all a projects attributes and builds GraphQL queries and mutations for the collections they make up. + * + * @param App $utopia + * @param Database $dbForProject + * @return array + * @throws \Exception + */ + public static function buildCollectionSchema( + App $utopia, + Database $dbForProject + ): array { + $start = microtime(true); + + $collections = []; + $queryFields = []; + $mutationFields = []; + $limit = 1000; + $offset = 0; + $count = 0; + + $wg = new WaitGroup(); + + while ( + !empty($attrs = Authorization::skip(fn() => $dbForProject->find( + 'attributes', + limit: $limit, + offset: $offset + ))) + ) { + $wg->add(); + $count += count($attrs); + \go(function () use ($utopia, $dbForProject, &$collections, &$queryFields, &$mutationFields, $limit, &$offset, $attrs, $wg) { + foreach ($attrs as $attr) { + if ($attr->getAttribute('status') !== 'available') { + continue; + } + $databaseId = $attr->getAttribute('databaseId'); + $collectionId = $attr->getAttribute('collectionId'); + $key = $attr->getAttribute('key'); + $type = $attr->getAttribute('type'); + $array = $attr->getAttribute('array'); + $required = $attr->getAttribute('required'); + $escapedKey = str_replace('$', '_', $key); + $collections[$collectionId][$escapedKey] = [ + 'type' => TypeMapper::typeFromAttribute($type, $array, $required), + ]; + } + + foreach ($collections as $collectionId => $attributes) { + $objectType = new ObjectType([ + 'name' => $collectionId, + 'fields' => \array_merge( + ["_id" => ['type' => Type::string()]], + $attributes + ), + ]); + $attributes = \array_merge( + $attributes, + TypeRegistry::defaultArgsFor('mutate') + ); + $queryFields[$collectionId . 'Get'] = [ + 'type' => $objectType, + 'args' => TypeRegistry::defaultArgsFor('id'), + 'resolve' => Resolvers::resolveDocumentGet( + $utopia, + $dbForProject, + $databaseId, + $collectionId + ) + ]; + $queryFields[$collectionId . 'List'] = [ + 'type' => $objectType, + 'args' => TypeRegistry::defaultArgsFor('list'), + 'resolve' => Resolvers::resolveDocumentList( + $utopia, + $dbForProject, + $databaseId, + $collectionId + ) + ]; + $mutationFields[$collectionId . 'Create'] = [ + 'type' => $objectType, + 'args' => $attributes, + 'resolve' => Resolvers::resolveDocumentMutate( + $utopia, + $dbForProject, + $databaseId, + $collectionId, + 'POST' + ) + ]; + $mutationFields[$collectionId . 'Update'] = [ + 'type' => $objectType, + 'args' => $attributes, + 'resolve' => Resolvers::resolveDocumentMutate( + $utopia, + $dbForProject, + $databaseId, + $collectionId, + 'PATCH' + ) + ]; + $mutationFields[$collectionId . 'Delete'] = [ + 'type' => $objectType, + 'args' => TypeRegistry::defaultArgsFor('id'), + 'resolve' => Resolvers::resolveDocumentDelete( + $utopia, + $dbForProject, + $databaseId, + $collectionId + ) + ]; + } + $wg->done(); + }); + $offset += $limit; + } + $wg->wait(); + + $timeElapsedMillis = (microtime(true) - $start) * 1000; + $timeElapsedMillis = \number_format((float)$timeElapsedMillis, 3, '.', ''); + Console::info('[INFO] Built GraphQL Project Collection Schema in ' . $timeElapsedMillis . 'ms (' . $count . ' attributes)'); + + return [ + 'query' => $queryFields, + 'mutation' => $mutationFields + ]; + } + + private static function getApiSchema( + App $utopia, + Registry $register, + bool $apiSchemaDirty, + string $envVersion + ): array { + if ($register->has('apiSchema') && !$apiSchemaDirty) { + $apiSchema = $register->get('apiSchema'); + } else { + $apiSchema = self::buildAPISchema($utopia); + $register->set('apiSchema', fn() => $apiSchema); + $register->set('apiSchemaVersion', fn() => $envVersion); + } + return $apiSchema; + } + + private static function getCollectionSchema( + App $utopia, + Registry $register, + Database $dbForProject, + bool $collectionSchemaDirty + ): array { + if ($register->has('collectionSchema') && !$collectionSchemaDirty) { + $collectionSchema = $register->get('collectionSchema'); + } else { + $collectionSchema = self::buildCollectionSchema($utopia, $dbForProject); + $register->set('collectionSchema', fn() => $collectionSchema); + $register->set('schemaDirty', fn() => false); + } + return $collectionSchema; + } + + private static function collateSchema( + array $apiSchema, + array $collectionSchema + ): Schema { + $queryFields = \array_merge_recursive($apiSchema['query'], $collectionSchema['query']); + $mutationFields = \array_merge_recursive($apiSchema['mutation'], $collectionSchema['mutation']); + + \ksort($queryFields); + \ksort($mutationFields); + + return new Schema([ + 'query' => new ObjectType([ + 'name' => 'Query', + 'fields' => $queryFields + ]), + 'mutation' => new ObjectType([ + 'name' => 'Mutation', + 'fields' => $mutationFields + ]) + ]); + } + + /** + * @param $start + * @return void + */ + private static function printBuildTimeFrom($start): void + { + $timeElapsedMillis = (\microtime(true) - $start) * 1000; + $timeElapsedMillis = \number_format((float)$timeElapsedMillis, 3, '.', ''); + Console::info('[INFO] Built GraphQL Schema in ' . $timeElapsedMillis . 'ms'); + } +} diff --git a/src/Appwrite/GraphQL/TypeMapper.php b/src/Appwrite/GraphQL/TypeMapper.php new file mode 100644 index 0000000000..fcdbedb43f --- /dev/null +++ b/src/Appwrite/GraphQL/TypeMapper.php @@ -0,0 +1,124 @@ +getResources($injections)) + : $validator; + + switch ((!empty($validator)) ? \get_class($validator) : '') { + case 'Appwrite\Auth\Validator\Password': + case 'Appwrite\Event\Validator\Event': + case 'Appwrite\Network\Validator\CNAME': + case 'Appwrite\Network\Validator\Domain': + case 'Appwrite\Network\Validator\Email': + case 'Appwrite\Network\Validator\Host': + case 'Appwrite\Network\Validator\IP': + case 'Appwrite\Network\Validator\Origin': + case 'Appwrite\Network\Validator\URL': + case 'Appwrite\Task\Validator\Cron': + case 'Appwrite\Utopia\Database\Validator\CustomId': + case 'Utopia\Database\Validator\Key': + case 'Utopia\Database\Validator\CustomId': + case 'Utopia\Database\Validator\UID': + case 'Utopia\Validator\HexColor': + case 'Utopia\Validator\Length': + case 'Utopia\Validator\Text': + case 'Utopia\Validator\WhiteList': + default: + $type = Type::string(); + break; + case 'Utopia\Validator\Boolean': + $type = Type::boolean(); + break; + case 'Utopia\Validator\ArrayList': + /** @noinspection PhpPossiblePolymorphicInvocationInspection */ + $type = Type::listOf(self::typeFromParameter( + $utopia, + $validator->getValidator(), + $required, + $injections + )); + break; + case 'Utopia\Validator\Numeric': + case 'Utopia\Validator\Integer': + case 'Utopia\Validator\Range': + $type = Type::int(); + break; + case 'Utopia\Validator\FloatValidator': + $type = Type::float(); + break; + case 'Utopia\Database\Validator\Authorization': + case 'Utopia\Database\Validator\Permissions': + $type = Type::listOf(Type::string()); + break; + case 'Utopia\Validator\Assoc': + case 'Utopia\Validator\JSON': + $type = TypeRegistry::json(); + break; + case 'Utopia\Storage\Validator\File': + $type = TypeRegistry::inputFile(); + break; + } + + if ($required) { + $type = Type::nonNull($type); + } + + return $type; + } + + /** + * Map an {@see Attribute} to a GraphQL Type + * + * @param string $type + * @param bool $array + * @param bool $required + * @return Type + * @throws Exception + */ + public static function typeFromAttribute(string $type, bool $array, bool $required): Type + { + if ($array) { + return Type::listOf(self::typeFromAttribute($type, false, $required)); + } + + $type = match ($type) { + 'boolean' => Type::boolean(), + 'integer' => Type::int(), + 'double' => Type::float(), + default => Type::string(), + }; + + if ($required) { + $type = Type::nonNull($type); + } + + return $type; + } +} diff --git a/src/Appwrite/GraphQL/TypeRegistry.php b/src/Appwrite/GraphQL/TypeRegistry.php new file mode 100644 index 0000000000..848220d2ac --- /dev/null +++ b/src/Appwrite/GraphQL/TypeRegistry.php @@ -0,0 +1,206 @@ + Type::boolean(), + Model::TYPE_STRING => Type::string(), + Model::TYPE_INTEGER => Type::int(), + Model::TYPE_FLOAT => Type::float(), + Model::TYPE_JSON => self::json(), + Response::MODEL_NONE => self::json(), + Response::MODEL_ANY => self::json(), + ]; + self::$defaultDocumentArgs = [ + 'id' => [ + 'id' => [ + 'type' => Type::string(), + ], + ], + 'list' => [ + 'limit' => [ + 'type' => Type::int(), + 'defaultValue' => 25, + ], + 'offset' => [ + 'type' => Type::int(), + 'defaultValue' => 0, + ], + 'cursor' => [ + 'type' => Type::string(), + 'defaultValue' => '', + ], + 'cursorDirection' => [ + 'type' => Type::string(), + 'defaultValue' => Database::CURSOR_AFTER, + ], + 'orderAttributes' => [ + 'type' => Type::listOf(Type::string()), + 'defaultValue' => [], + ], + 'orderType' => [ + 'type' => Type::listOf(Type::string()), + 'defaultValue' => [], + ], + ], + 'mutate' => [ + 'read' => [ + 'type' => Type::listOf(Type::string()), + 'defaultValue' => ["role:member"], + ], + 'write' => [ + 'type' => Type::listOf(Type::string()), + 'defaultValue' => ["role:member"], + ], + ], + ]; + + self::$models = $models; + } + + /** + * Check if a type exists in the registry. + * + * @param string $type + * @return bool + */ + public static function has(string $type): bool + { + return isset(self::$typeMapping[$type]); + } + + /** + * Get a type from the registry, creating it if it does not already exist. + * + * @param string $name + * @return Type + */ + public static function get(string $name): Type + { + if (self::has($name)) { + return self::$typeMapping[$name]; + } + + $fields = []; + + $model = self::$models[$name]; + + if ($model->isAny()) { + $fields['data'] = [ + 'type' => Type::string(), + 'description' => 'Data field', + 'resolve' => fn($object, $args, $context, $info) => \json_encode($object, JSON_FORCE_OBJECT), + ]; + } + + foreach ($model->getRules() as $key => $props) { + $escapedKey = str_replace('$', '_', $key); + + $types = \is_array($props['type']) + ? $props['type'] + : [$props['type']]; + + foreach ($types as $type) { + if (self::has($type)) { + $type = self::$typeMapping[$type]; + } else { + try { + $complexModel = self::$models[$type]; + $type = self::get($complexModel); + } catch (\Exception) { + Console::error('Could not find model for ' . $type); + } + } + + if ($props['array']) { + $type = Type::listOf($type); + } + + $fields[$escapedKey] = [ + 'type' => $type, + 'description' => $props['description'], + 'resolve' => fn($object, $args, $context, $info) => $object[$key], + ]; + } + } + $objectType = [ + 'name' => $name, + 'fields' => $fields + ]; + + self::set($name, new ObjectType($objectType)); + + return self::$typeMapping[$name]; + } + + /** + * Set a type in the registry. + * + * @param string $type + * @param Type $typeObject + */ + public static function set(string $type, Type $typeObject): void + { + self::$typeMapping[$type] = $typeObject; + } + + /** + * Get the registered default arguments for a given key. + * + * @param string $key + * @return array + */ + public static function defaultArgsFor(string $key): array + { + if (isset(self::$defaultDocumentArgs[$key])) { + return self::$defaultDocumentArgs[$key]; + } + return []; + } + + /** + * Get the JSON type. + * + * @return Json + */ + public static function json(): Json + { + if (\is_null(self::$jsonType)) { + self::$jsonType = new Json(); + } + return self::$jsonType; + } + + /** + * Get the InputFile type. + * + * @return InputFile + */ + public static function inputFile(): InputFile + { + if (\is_null(self::$inputFile)) { + self::$inputFile = new InputFile(); + } + return self::$inputFile; + } +} diff --git a/src/Appwrite/GraphQL/Types/InputFile.php b/src/Appwrite/GraphQL/Types/InputFile.php index 83c29649f1..17ee6a0061 100644 --- a/src/Appwrite/GraphQL/Types/InputFile.php +++ b/src/Appwrite/GraphQL/Types/InputFile.php @@ -1,7 +1,5 @@ kind, $valueNode); } diff --git a/src/Appwrite/GraphQL/Types/Json.php b/src/Appwrite/GraphQL/Types/Json.php index c78bca0fe3..c430543c9b 100644 --- a/src/Appwrite/GraphQL/Types/Json.php +++ b/src/Appwrite/GraphQL/Types/Json.php @@ -17,22 +17,14 @@ class Json extends ScalarType public $name = 'Json'; public $description = 'The `JSON` scalar type represents JSON values as specified by - [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf).'; + [ECMA-404](https://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf).'; - public function __construct(?string $name = null) - { - if ($name) { - $this->name = $name; - } - parent::__construct(); - } - - public function parseValue($value) + public function serialize($value) { return $this->identity($value); } - public function serialize($value) + public function parseValue($value) { return $this->identity($value); } diff --git a/tests/unit/GraphQL/BuilderTest.php b/tests/unit/GraphQL/BuilderTest.php index ad2b03c83a..46cc8d6b5c 100644 --- a/tests/unit/GraphQL/BuilderTest.php +++ b/tests/unit/GraphQL/BuilderTest.php @@ -3,7 +3,8 @@ namespace Appwrite\Tests; use Appwrite\Event\Event; -use Appwrite\GraphQL\Builder; +use Appwrite\GraphQL\SchemaBuilder; +use Appwrite\GraphQL\TypeRegistry; use Appwrite\Utopia\Response; use Appwrite\Utopia\Response\Model; use PHPUnit\Framework\TestCase; @@ -12,20 +13,20 @@ use Utopia\App; class BuilderTest extends TestCase { - /** - * @var Response - */ - protected $response = null; + protected ?Response $response = null; public function setUp(): void { $this->response = new Response(new SwooleResponse()); - Builder::init(); + TypeRegistry::init($this->response->getModels()); } + /** + * @throws \Exception + */ public function testCreateTypeMapping() { $model = $this->response->getModel(Response::MODEL_COLLECTION); - $typeMapping = Builder::getModelTypeMapping($model, $this->response); + $typeMapping = TypeRegistry::get($model->getType()); } }