1
0
Fork 0
mirror of synced 2024-06-28 19:20:25 +12:00

Code cleanup

This commit is contained in:
Jake Barnby 2022-07-13 21:34:56 +12:00
parent 92c8e2c3a1
commit a656699fa7
12 changed files with 1011 additions and 932 deletions

View file

@ -1,7 +1,7 @@
<?php
use Appwrite\Extend\Exception;
use Appwrite\GraphQL\CoroutinePromiseAdapter;
use Appwrite\GraphQL\Promises\CoroutinePromiseAdapter;
use Appwrite\Utopia\Response;
use GraphQL\Error\DebugFlag;
use GraphQL\GraphQL;

View file

@ -18,16 +18,14 @@ ini_set('display_startup_errors', 1);
ini_set('default_socket_timeout', -1);
error_reporting(E_ALL);
use Appwrite\Extend\PDO;
use Ahc\Jwt\JWT;
use Ahc\Jwt\JWTException;
use Appwrite\Extend\Exception;
use Appwrite\Auth\Auth;
use Appwrite\Auth\Phone\Mock;
use Appwrite\Auth\Phone\Msg91;
use Appwrite\Auth\Phone\Telesign;
use Appwrite\Auth\Phone\TextMagic;
use Appwrite\Auth\Phone\Twilio;
use Appwrite\Auth\Phone\Msg91;
use Appwrite\DSN\DSN;
use Appwrite\Event\Audit;
use Appwrite\Event\Database as EventDatabase;
@ -35,44 +33,45 @@ use Appwrite\Event\Delete;
use Appwrite\Event\Event;
use Appwrite\Event\Mail;
use Appwrite\Event\Phone;
use Appwrite\GraphQL\Builder;
use Appwrite\GraphQL\CoroutinePromiseAdapter;
use Appwrite\Extend\Exception;
use Appwrite\Extend\PDO;
use Appwrite\GraphQL\SchemaBuilder;
use Appwrite\GraphQL\Promises\CoroutinePromiseAdapter;
use Appwrite\Network\Validator\Email;
use Appwrite\Network\Validator\IP;
use Appwrite\Network\Validator\URL;
use Appwrite\OpenSSL\OpenSSL;
use Appwrite\Stats\Stats;
use Appwrite\Utopia\View;
use Utopia\App;
use Utopia\Logger\Logger;
use Utopia\Config\Config;
use Utopia\CLI\Console;
use Utopia\Locale\Locale;
use Utopia\Registry\Registry;
use MaxMind\Db\Reader;
use PHPMailer\PHPMailer\PHPMailer;
use Utopia\Cache\Adapter\Redis as RedisCache;
use Utopia\Cache\Cache;
use Utopia\Database\Adapter\MariaDB;
use Utopia\Database\Document;
use Utopia\Database\Database;
use Utopia\Database\Validator\Structure;
use Utopia\Database\Validator\Authorization;
use Utopia\Validator\Range;
use Utopia\Validator\WhiteList;
use Swoole\Database\PDOConfig;
use Swoole\Database\PDOPool;
use Swoole\Database\RedisConfig;
use Swoole\Database\RedisPool;
use Utopia\App;
use Utopia\Cache\Adapter\Redis as RedisCache;
use Utopia\Cache\Cache;
use Utopia\Config\Config;
use Utopia\Database\Adapter\MariaDB;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Query;
use Utopia\Database\Validator\Authorization;
use Utopia\Database\Validator\Structure;
use Utopia\Locale\Locale;
use Utopia\Logger\Logger;
use Utopia\Registry\Registry;
use Utopia\Storage\Device;
use Utopia\Storage\Storage;
use Utopia\Storage\Device\Backblaze;
use Utopia\Storage\Device\DOSpaces;
use Utopia\Storage\Device\Linode;
use Utopia\Storage\Device\Local;
use Utopia\Storage\Device\S3;
use Utopia\Storage\Device\Linode;
use Utopia\Storage\Device\Wasabi;
use Utopia\Storage\Storage;
use Utopia\Validator\Range;
use Utopia\Validator\WhiteList;
const APP_NAME = 'Appwrite';
const APP_DOMAIN = 'appwrite.io';
@ -1008,5 +1007,5 @@ App::setResource('promiseAdapter', function ($register) {
}, ['register']);
App::setResource('gqlSchema', function ($utopia, $dbForProject, $user) {
return Builder::buildSchema($utopia, $dbForProject, $user);
return SchemaBuilder::buildSchema($utopia, $dbForProject, $user);
}, ['utopia', 'dbForProject', 'user']);

View file

@ -1,849 +0,0 @@
<?php
namespace Appwrite\GraphQL;
use Appwrite\GraphQL\Types\InputFile;
use Appwrite\GraphQL\Types\Json;
use Appwrite\Utopia\Request;
use Appwrite\Utopia\Response;
use Appwrite\Utopia\Response\Model;
use GraphQL\Type\Definition\ObjectType;
use GraphQL\Type\Definition\Type;
use GraphQL\Type\Schema;
use Swoole\Coroutine\WaitGroup;
use Swoole\Http\Response as SwooleResponse;
use Utopia\App;
use Utopia\CLI\Console;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Validator\Authorization;
use Utopia\Route;
use Utopia\Validator;
use function Co\go;
class Builder
{
protected static ?Json $jsonType = null;
protected static ?InputFile $inputFile = null;
protected static array $typeMapping = [];
protected static array $defaultDocumentArgs = [];
/**
* Initialise the typeMapping array with the base cases of the recursion
*
* @return void
*/
public static function init(): void
{
self::$typeMapping = [
Model::TYPE_BOOLEAN => 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;
}
}

View file

@ -1,11 +1,11 @@
<?php
namespace Appwrite\GraphQL;
namespace Appwrite\GraphQL\Promises;
use GraphQL\Error\InvariantViolation;
use GraphQL\Utils\Utils;
use Swoole\Coroutine\Channel;
use function Co\go;
/**
* Inspired by https://github.com/streamcommon/promise/blob/master/lib/ExtSwoolePromise.php
*
@ -34,8 +34,7 @@ class CoroutinePromise
$this->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;

View file

@ -1,6 +1,6 @@
<?php
namespace Appwrite\GraphQL;
namespace Appwrite\GraphQL\Promises;
use GraphQL\Error\InvariantViolation;
use GraphQL\Executor\Promise\Promise;
@ -8,24 +8,42 @@ use GraphQL\Executor\Promise\PromiseAdapter;
use GraphQL\Utils\Utils;
use Swoole\Coroutine\Channel;
use function Co\go;
class CoroutinePromiseAdapter implements PromiseAdapter
{
/**
* Returns true if the given value is a {@see CoroutinePromise}.
*
* @param $value
* @return bool
*/
public function isThenable($value): bool
{
return $value instanceof CoroutinePromise;
}
/**
* Converts a {@see CoroutinePromise} into a {@see Promise}
*
* @param $thenable
* @return Promise
*/
public function convertThenable($thenable): Promise
{
if (!$thenable instanceof CoroutinePromise) {
throw new InvariantViolation('Expected instance of SwoolePromise, got ' . Utils::printSafe($thenable));
throw new InvariantViolation('Expected instance of CoroutinePromise, got ' . Utils::printSafe($thenable));
}
return new Promise($thenable, $this);
}
/**
* Returns a promise that resolves when the passed in promise resolves.
*
* @param Promise $promise
* @param callable|null $onFulfilled
* @param callable|null $onRejected
* @return Promise
*/
public function then(Promise $promise, ?callable $onFulfilled = null, ?callable $onRejected = null): Promise
{
/** @var CoroutinePromise $adoptedPromise */
@ -34,6 +52,12 @@ class CoroutinePromiseAdapter implements PromiseAdapter
return new Promise($adoptedPromise->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) {

View file

@ -0,0 +1,259 @@
<?php
namespace Appwrite\GraphQL;
use Appwrite\GraphQL\Promises\CoroutinePromise;
use Appwrite\Utopia\Request;
use Appwrite\Utopia\Response;
use Utopia\App;
use Utopia\Database\Database;
use Utopia\Exception;
use Utopia\Route;
class Resolvers
{
/**
* Create a resolver for a given {@see Route}.
*
* @param App $utopia
* @param ?Route $route
* @return callable
*/
public 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) {
$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);
}
}

View file

@ -0,0 +1,336 @@
<?php
namespace Appwrite\GraphQL;
use Appwrite\Utopia\Response;
use GraphQL\Type\Definition\ObjectType;
use GraphQL\Type\Definition\Type;
use GraphQL\Type\Schema;
use Swoole\Coroutine\WaitGroup;
use Swoole\Http\Response as SwooleResponse;
use Utopia\App;
use Utopia\CLI\Console;
use Utopia\Database\Database;
use Utopia\Database\Validator\Authorization;
use Utopia\Registry\Registry;
use Utopia\Route;
class SchemaBuilder
{
/**
* @throws \Exception
*/
public static function buildSchema(
App $utopia,
Database $dbForProject
): 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')
) {
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');
}
}

View file

@ -0,0 +1,124 @@
<?php
namespace Appwrite\GraphQL;
use Appwrite\Utopia\Response\Model\Attribute;
use Exception;
use GraphQL\Type\Definition\Type;
use Utopia\App;
use Utopia\Route;
use Utopia\Validator;
class TypeMapper
{
/**
* Map a {@see Route} parameter to a GraphQL Type
*
* @param App $utopia
* @param Validator|callable $validator
* @param bool $required
* @param array $injections
* @return Type
* @throws Exception
*/
public static function typeFromParameter(
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':
/** @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;
}
}

View file

@ -0,0 +1,206 @@
<?php
namespace Appwrite\GraphQL;
use Appwrite\GraphQL\Types\InputFile;
use Appwrite\GraphQL\Types\Json;
use Appwrite\Utopia\Response;
use Appwrite\Utopia\Response\Model;
use GraphQL\Type\Definition\ObjectType;
use GraphQL\Type\Definition\Type;
use Utopia\CLI\Console;
use Utopia\Database\Database;
class TypeRegistry
{
private static ?Json $jsonType = null;
private static ?InputFile $inputFile = null;
private static array $typeMapping = [];
private static array $defaultDocumentArgs = [];
private static array $models = [];
public static function init($models): void
{
self::$typeMapping = [
Model::TYPE_BOOLEAN => 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;
}
}

View file

@ -1,7 +1,5 @@
<?php
declare(strict_types=1);
namespace Appwrite\GraphQL\Types;
use GraphQL\Error\Error;
@ -11,50 +9,22 @@ use GraphQL\Type\Definition\ScalarType;
class InputFile extends ScalarType
{
/**
* @var string
* @var string
*/
public $name = 'InputFile';
/**
* @var string
*/
public $description
= 'The `InputFile` special type represents a file to be uploaded in the same HTTP request as specified by
[graphql-multipart-request-spec](https://github.com/jaydenseric/graphql-multipart-request-spec).';
/**
* Serializes an internal value to include in a response.
*
* @param mixed $value
*
* @return mixed
*/
public function serialize($value)
{
throw new InvariantViolation('`InputFile` cannot be serialized');
}
/**
* Parses an externally provided value (query variable) to use as an input.
*
* @param mixed $value
**/
public function parseValue($value)
{
return $value;
}
/**
* Parses an externally provided literal value (hardcoded in GraphQL query) to use as an input.
*
* @param Node $valueNode
*
* @return mixed
* @throws Error
*/
public function parseLiteral($valueNode, ?array $variables = null)
public function parseLiteral(Node $valueNode, ?array $variables = null)
{
throw new Error('`InputFile` cannot be hardcoded in query, be sure to conform to GraphQL multipart request specification. Instead got: ' . $valueNode->kind, $valueNode);
}

View file

@ -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);
}

View file

@ -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());
}
}