diff --git a/app/config/errors.php b/app/config/errors.php index b7b5c1ca3..121ddc14f 100644 --- a/app/config/errors.php +++ b/app/config/errors.php @@ -491,5 +491,10 @@ return [ 'name' => Exception::DOMAIN_VERIFICATION_FAILED, 'description' => 'Domain verification for the requested domain has failed.', 'code' => 401, - ] + ], + Exception::GRAPHQL_NO_QUERY => [ + 'name' => Exception::GRAPHQL_NO_QUERY, + 'description' => 'Query is required and can be provided via parameter or as the raw body if the content-type header is application/graphql.', + 'code' => 400, + ], ]; \ No newline at end of file diff --git a/app/controllers/api/graphql.php b/app/controllers/api/graphql.php index 5f1e29d90..319cb0baf 100644 --- a/app/controllers/api/graphql.php +++ b/app/controllers/api/graphql.php @@ -1,6 +1,6 @@ param('variables', [], new JSON(), 'Variables to use in the operation', true) ->inject('request') ->inject('response') - ->inject('utopia') - ->inject('dbForProject') ->inject('promiseAdapter') ->inject('gqlSchema') ->action(Closure::fromCallable('graphqlRequest')); @@ -71,8 +69,6 @@ function graphqlRequest( $variables, $request, $response, - $utopia, - $dbForProject, $promiseAdapter, $gqlSchema ) @@ -89,24 +85,23 @@ function graphqlRequest( $query = $request->getSwoole()->rawContent(); } if (empty($query)) { - throw new Exception('Query is empty', Response::STATUS_CODE_BAD_REQUEST); + throw new Exception('No query supplied.', 400, Exception::GRAPHQL_NO_QUERY); } $debugFlags = App::isDevelopment() ? DebugFlag::INCLUDE_DEBUG_MESSAGE | DebugFlag::INCLUDE_TRACE | DebugFlag::RETHROW_INTERNAL_EXCEPTIONS : DebugFlag::NONE; - $validations = array_merge( - GraphQL::getStandardValidationRules(), - [ - new QueryComplexity(App::getEnv('_APP_GRAPHQL_MAX_QUERY_COMPLEXITY', 200)), - new QueryDepth(App::getEnv('_APP_GRAPHQL_MAX_QUERY_DEPTH', 3)), - ] - ); + // Roughly equivalent to 200 REST requests of work per GraphQL request + $maxComplexity = App::getEnv('_APP_GRAPHQL_MAX_QUERY_COMPLEXITY', 200); - if (App::isProduction()) { - $validations[] = new DisableIntrospection(); - } + // Maximum nested query depth. Limited to 3 as we don't have more than 3 levels of data relationships + $maxDepth = App::getEnv('_APP_GRAPHQL_MAX_QUERY_DEPTH', 3); + + $validations = GraphQL::getStandardValidationRules(); + $validations[] = new QueryComplexity($maxComplexity); + $validations[] = new QueryDepth($maxDepth); + $validations[] = new DisableIntrospection(); $promise = GraphQL::promiseToExecute( $promiseAdapter, @@ -117,23 +112,25 @@ function graphqlRequest( validationRules: $validations ); - // Blocking wait while queries resolve asynchronously + // Blocking wait while queries resolve $wg = new WaitGroup(); $wg->add(); - $promise->then(function ($result) use ($response, $debugFlags, $wg) { - $result = $result->toArray($debugFlags); - \var_dump("Result:" . $result); - if (isset($result['errors'])) { - $response->json(['data' => [], ...$result]); + $promise->then( + function ($result) use ($response, $debugFlags, $wg) { + $result = $result->toArray($debugFlags); + \var_dump("Result:" . $result); + if (isset($result['errors'])) { + $response->json(['data' => [], ...$result]); + $wg->done(); + return; + } + $response->json(['data' => $result]); + $wg->done(); + }, + function ($error) use ($response, $wg) { + $response->text(\json_encode(['errors' => [\json_encode($error)]])); $wg->done(); - return; } - $response->json(['data' => $result]); - $wg->done(); - }, - function ($error) use ($response, $wg) { - $response->text(\json_encode(['errors' => [\json_encode($error)]])); - $wg->done(); - }); + ); $wg->wait(); } diff --git a/app/init.php b/app/init.php index be75c3f2a..f575d8dde 100644 --- a/app/init.php +++ b/app/init.php @@ -865,7 +865,7 @@ App::setResource('promiseAdapter', function ($register) { return $register->get('promiseAdapter'); }, ['register']); -App::setResource('gqlSchema', function ($utopia, $request, $response, $register, $dbForProject) { - return Builder::buildSchema($utopia, $request, $response, $register, $dbForProject); -}, ['utopia', 'request', 'response', 'register', 'dbForProject']); +App::setResource('gqlSchema', function ($utopia, $request, $response, $register, $dbForProject, $user) { + return Builder::buildSchema($utopia, $request, $response, $register, $dbForProject, $user); +}, ['utopia', 'request', 'response', 'register', 'dbForProject', 'user']); diff --git a/src/Appwrite/Extend/Exception.php b/src/Appwrite/Extend/Exception.php index 4f396990f..2a91e602c 100644 --- a/src/Appwrite/Extend/Exception.php +++ b/src/Appwrite/Extend/Exception.php @@ -29,6 +29,7 @@ class Exception extends \Exception * - Keys * - Platform * - Domain + * - GraphQL */ /** General */ @@ -160,6 +161,8 @@ class Exception extends \Exception const DOMAIN_ALREADY_EXISTS = 'domain_already_exists'; const DOMAIN_VERIFICATION_FAILED = 'domain_verification_failed'; + /** GraphqQL */ + const GRAPHQL_NO_QUERY = 'graphql_no_query'; private $type = ''; diff --git a/src/Appwrite/GraphQL/Builder.php b/src/Appwrite/GraphQL/Builder.php index f9913efb8..9a87887c0 100644 --- a/src/Appwrite/GraphQL/Builder.php +++ b/src/Appwrite/GraphQL/Builder.php @@ -6,11 +6,13 @@ use Appwrite\GraphQL\Types\JsonType; use Appwrite\Utopia\Request; use Appwrite\Utopia\Response; use Appwrite\Utopia\Response\Model; +use Appwrite\Utopia\Response\Model\User; use GraphQL\Error\Error; use GraphQL\Error\FormattedError; use GraphQL\Type\Definition\ObjectType; use GraphQL\Type\Definition\Type; use GraphQL\Type\Schema; +use Swoole\Coroutine\WaitGroup; use Utopia\App; use Utopia\CLI\Console; use Utopia\Database\Database; @@ -20,6 +22,8 @@ use Utopia\Registry\Registry; use Utopia\Route; use Utopia\Validator; +use function \Co\go; + class Builder { protected static ?JsonType $jsonParser = null; @@ -100,7 +104,7 @@ class Builder $fields[$escapedKey] = [ 'type' => $type, 'description' => $props['description'], - 'resolve' => fn ($object, $args, $context, $info) => new CoroutinePromise( + 'resolve' => fn($object, $args, $context, $info) => new CoroutinePromise( fn($resolve, $reject) => $resolve($object[$key]) ), ]; @@ -268,11 +272,12 @@ class Builder Request $request, Response $response, Registry &$register, - Database $dbForProject + Database $dbForProject, + Document $user, ): Schema { $apiSchema = self::buildAPISchema($utopia, $request, $response, $register); - $db = self::buildCollectionsSchema($utopia, $request, $response, $dbForProject); + $db = self::buildCollectionsSchema($utopia, $request, $response, $dbForProject, $user); $queryFields = \array_merge_recursive($apiSchema['query'], $db['query']); $mutationFields = \array_merge_recursive($apiSchema['mutation'], $db['mutation']); @@ -304,121 +309,127 @@ class Builder * @throws \Exception */ public static function buildCollectionsSchema( - App $utopia, - Request $request, - Response $response, - Database $dbForProject + App $utopia, + Request $request, + Response $response, + Database $dbForProject, + ?Document $user = null, ): array { $start = microtime(true); + $userId = $user?->getId(); $collections = []; $queryFields = []; $mutationFields = []; - $limit = 1000 * swoole_cpu_num(); + $limit = 1000; $offset = 0; + $wg = new WaitGroup(); while (!empty($attrs = Authorization::skip(fn() => $dbForProject->find( 'attributes', limit: $limit, offset: $offset )))) { - //go(function() use ($utopia, $request, $response, $dbForProject, &$collections, &$queryFields, &$mutationFields, $limit, &$offset, $attrs) { - foreach ($attrs as $attr) { - $collectionId = $attr->getAttribute('collectionId'); - if ($attr->getAttribute('status') !== 'available') { - continue; + $wg->add(); + go(function () use ($utopia, $request, $response, $dbForProject, &$collections, &$queryFields, &$mutationFields, $limit, &$offset, $attrs, $userId, $wg) { + foreach ($attrs as $attr) { + $collectionId = $attr->getAttribute('collectionId'); + if ($attr->getAttribute('status') !== 'available') { + continue; + } + $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::getAttributeArgType($type, $array, $required), + 'resolve' => fn($object, $args, $context, $info) => $object->then(function ($obj) use ($key) { + return $obj['result'][$key]; + }), + ]; } - $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::getAttributeArgType($type, $array, $required), - 'resolve' => fn ($object, $args, $context, $info) => $object->then(function ($obj) use ($key) { - return $obj['result'][$key]; - }), - ]; - } - foreach ($collections as $collectionId => $attributes) { - $objectType = new ObjectType([ - 'name' => $collectionId, - 'fields' => $attributes - ]); - $idArgs = [ - 'id' => [ - 'type' => Type::string() - ] - ]; - $listArgs = [ - 'limit' => [ - 'type' => Type::int(), - 'defaultValue' => $limit, - ], - 'offset' => [ - 'type' => Type::int(), - 'defaultValue' => 0, - ], - 'cursor' => [ - 'type' => Type::string(), - 'defaultValue' => null, - ], - 'orderAttributes' => [ + foreach ($collections as $collectionId => $attributes) { + $objectType = new ObjectType([ + 'name' => $collectionId, + 'fields' => $attributes + ]); + $idArgs = [ + 'id' => [ + 'type' => Type::string() + ] + ]; + $listArgs = [ + 'limit' => [ + 'type' => Type::int(), + 'defaultValue' => $limit, + ], + 'offset' => [ + 'type' => Type::int(), + 'defaultValue' => 0, + ], + 'cursor' => [ + 'type' => Type::string(), + 'defaultValue' => null, + ], + 'orderAttributes' => [ + 'type' => Type::listOf(Type::string()), + 'defaultValue' => [], + ], + 'orderType' => [ + 'type' => Type::listOf(Type::string()), + 'defaultValue' => [], + ] + ]; + + $attributes['read'] = [ 'type' => Type::listOf(Type::string()), - 'defaultValue' => [], - ], - 'orderType' => [ + 'defaultValue' => ["user:$userId"], + 'resolve' => function ($object, $args, $context, $info) use ($collectionId) { + return $object->getAttribute('$read'); + } + ]; + $attributes['write'] = [ 'type' => Type::listOf(Type::string()), - 'defaultValue' => [], - ] - ]; - - $attributes['read'] = [ - 'type' => Type::listOf(Type::string()), - 'resolve' => function ($object, $args, $context, $info) use ($collectionId) { - return $object->getAttribute('$read'); - } - ]; - $attributes['write'] = [ - 'type' => Type::listOf(Type::string()), - 'resolve' => function ($object, $args, $context, $info) use ($collectionId) { - return $object->getAttribute('$write'); - } - ]; - - $queryFields[$collectionId . 'Get'] = [ - 'type' => $objectType, - 'args' => $idArgs, - 'resolve' => self::queryGet($utopia, $request, $response, $dbForProject, $collectionId) - ]; - $queryFields[$collectionId . 'List'] = [ - 'type' => $objectType, - 'args' => $listArgs, - 'resolve' => self::queryList($collectionId, $dbForProject) - ]; - $mutationFields[$collectionId . 'Create'] = [ - 'type' => $objectType, - 'args' => $attributes, - 'resolve' => self::mutateCreate($collectionId, $dbForProject) - ]; - $mutationFields[$collectionId . 'Update'] = [ - 'type' => $objectType, - 'args' => $attributes, - 'resolve' => self::mutateUpdate($collectionId, $dbForProject) - ]; - $mutationFields[$collectionId . 'Delete'] = [ - 'type' => $objectType, - 'args' => $idArgs, - 'resolve' => self::mutateDelete($collectionId, $dbForProject) - ]; - } - //}); + 'defaultValue' => ["user:$userId"], + 'resolve' => function ($object, $args, $context, $info) use ($collectionId) { + return $object->getAttribute('$write'); + } + ]; + $queryFields[$collectionId . 'Get'] = [ + 'type' => $objectType, + 'args' => $idArgs, + 'resolve' => self::queryGet($utopia, $request, $response, $dbForProject, $collectionId) + ]; + $queryFields[$collectionId . 'List'] = [ + 'type' => $objectType, + 'args' => $listArgs, + 'resolve' => self::queryList($collectionId, $dbForProject) + ]; + $mutationFields[$collectionId . 'Create'] = [ + 'type' => $objectType, + 'args' => $attributes, + 'resolve' => self::mutateCreate($collectionId, $dbForProject) + ]; + $mutationFields[$collectionId . 'Update'] = [ + 'type' => $objectType, + 'args' => $attributes, + 'resolve' => self::mutateUpdate($collectionId, $dbForProject) + ]; + $mutationFields[$collectionId . 'Delete'] = [ + 'type' => $objectType, + 'args' => $idArgs, + 'resolve' => self::mutateDelete($collectionId, $dbForProject) + ]; + } + $wg->done(); + }); $offset += $limit; } - + $wg->wait(); $time_elapsed_secs = (microtime(true) - $start) * 1000; Console::info("[INFO] Built GraphQL Project Collection Schema in ${time_elapsed_secs}ms"); @@ -455,6 +466,7 @@ class Builder $utopia ->setRoute($route) ->execute($route, $request); + $resolve($response->getPayload()); } catch (\Throwable $e) { $reject($e); diff --git a/src/Appwrite/GraphQL/CoroutinePromise.php b/src/Appwrite/GraphQL/CoroutinePromise.php index 493d602da..c4d489988 100644 --- a/src/Appwrite/GraphQL/CoroutinePromise.php +++ b/src/Appwrite/GraphQL/CoroutinePromise.php @@ -25,9 +25,6 @@ class CoroutinePromise if (\is_null($executor)) { return; } - if (!\extension_loaded('swoole')) { - throw new \RuntimeException('Swoole ext missing!'); - } $resolve = function ($value) { $this->setResult($value); $this->setState(self::STATE_FULFILLED); @@ -141,11 +138,9 @@ class CoroutinePromise foreach ($promises as $promise) { if (!$promise instanceof CoroutinePromise) { $channel->close(); - throw new \RuntimeException( - 'Supported only Appwrite\GraphQL\SwoolePromise instance' - ); + throw new \RuntimeException('Not an Appwrite\GraphQL\CoroutinePromise'); } - $promise->then(function ($value) use ($key, $result, $channel) { + $promise->then(function ($value) use ($key, &$result, $channel) { $result[$key] = $value; $channel->push(true); return $value; @@ -166,6 +161,7 @@ class CoroutinePromise $reject($firstError); return; } + $resolve($result); }); } diff --git a/src/Appwrite/GraphQL/CoroutinePromiseAdapter.php b/src/Appwrite/GraphQL/CoroutinePromiseAdapter.php index 464c0a8ca..f57221e58 100644 --- a/src/Appwrite/GraphQL/CoroutinePromiseAdapter.php +++ b/src/Appwrite/GraphQL/CoroutinePromiseAdapter.php @@ -68,27 +68,22 @@ class CoroutinePromiseAdapter implements PromiseAdapter $result = []; foreach ($promisesOrValues as $index => $promiseOrValue) { - go(function ($index, $promiseOrValue, $all, $total, &$count, $result) { - if (!($promiseOrValue instanceof CoroutinePromise)) { - $result[$index] = $promiseOrValue; + if (!($promiseOrValue instanceof Promise)) { + $result[$index] = $promiseOrValue; + $count++; + break; + } + $result[$index] = null; + $promiseOrValue->then( + function ($value) use ($index, &$count, $total, &$result, $all): void { + $result[$index] = $value; $count++; - return; - } - $result[$index] = null; - $promiseOrValue->then( - function ($value) use ($index, &$count, $total, &$result, $all): void { - $result[$index] = $value; - if ($count++ === $total) { - $all->resolve($result); - } - }, - [$all, 'reject'] - ); - }, $index, $promiseOrValue, $all, $total, $count, $result); - } - - if ($count === $total) { - $all->resolve($result); + if ($count === $total) { + $all->resolve($result); + } + }, + [$all, 'reject'] + ); } return new Promise($all, $this); diff --git a/src/Appwrite/Utopia/Response.php b/src/Appwrite/Utopia/Response.php index 6cbfb8b06..8e4cc8a4f 100644 --- a/src/Appwrite/Utopia/Response.php +++ b/src/Appwrite/Utopia/Response.php @@ -3,6 +3,7 @@ namespace Appwrite\Utopia; use Exception; +use Swoole\Http\Request as SwooleRequest; use Utopia\Swoole\Response as SwooleResponse; use Swoole\Http\Response as SwooleHTTPResponse; use Utopia\Database\Document; @@ -307,6 +308,11 @@ class Response extends SwooleResponse parent::__construct($response); } + public function getSwoole(): SwooleHTTPResponse + { + return $this->swoole; + } + /** * HTTP content types */