1
0
Fork 0
mirror of synced 2024-07-07 23:46:11 +12:00

Merge pull request #944 from christyjacob4/graphql

GraphQL Support in Appwrite
This commit is contained in:
Eldad A. Fux 2021-03-10 16:08:06 +02:00 committed by GitHub
commit 2eb024200e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 540 additions and 5940 deletions

2
.env
View file

@ -16,7 +16,7 @@ _APP_DB_PORT=3306
_APP_DB_SCHEMA=appwrite
_APP_DB_USER=user
_APP_DB_PASS=password
_APP_STORAGE_ANTIVIRUS=enabled
_APP_STORAGE_ANTIVIRUS=disabled
_APP_STORAGE_ANTIVIRUS_HOST=clamav
_APP_STORAGE_ANTIVIRUS_PORT=3310
_APP_INFLUXDB_HOST=influxdb

0
.swiftlint.yml Normal file
View file

View file

@ -7,6 +7,7 @@ $member = [
'home',
'console',
'account',
'graphql',
'teams.read',
'teams.write',
'documents.read',
@ -22,6 +23,7 @@ $member = [
];
$admins = [
'graphql',
'teams.read',
'teams.write',
'documents.read',
@ -56,6 +58,7 @@ return [
'public',
'home',
'console',
'graphql',
'documents.read',
'files.read',
'locale.read',
@ -82,6 +85,6 @@ return [
],
Auth::USER_ROLE_APP => [
'label' => 'Application',
'scopes' => ['health.read'],
'scopes' => ['health.read', 'graphql'],
],
];

View file

@ -1,5 +1,17 @@
<?php
use Appwrite\GraphQL\GraphQLBuilder;
use GraphQL\GraphQL;
use GraphQL\Type\Schema;
use GraphQL\Type\Definition\ObjectType;
use GraphQL\Type\Definition\Type;
use Appwrite\Utopia\Response;
use Appwrite\Utopia\Response\Model;
use Appwrite\GraphQL\Types\JsonType;
use GraphQL\Error\Error;
use GraphQL\Error\ClientAware;
use GraphQL\Error\FormattedError;
use GraphQL\Type\Definition\ListOfType;
use Utopia\App;
/**
@ -7,17 +19,78 @@ use Utopia\App;
* 1. Map all objects, object-params, object-fields
* 2. Parse GraphQL request payload (use: https://github.com/webonyx/graphql-php)
* 3. Route request to relevant controllers (of REST API?) / resolvers and aggergate data
* 4. Handle errors if any
* 5. Returen JSON response
* 6. Write tests!
* 4. Handle scope authentication
* 5. Handle errors
* 6. Return response
* 7. Write tests!
*
* Demo
* curl -H "Content-Type: application/json" http://localhost/v1/graphql -d '{"query": "query { echo(message: \"Hello World\") }" }'
*
* Explorers:
* - https://shopify.dev/tools/graphiql-admin-api
* - https://developer.github.com/v4/explorer/
* - http://localhost:4000
*
* Docs
* - Overview
* - Clients
*
* - Queries
* - Mutations
*
* - Objects
*/
class MySafeException extends \Exception implements ClientAware
{
public function isClientSafe()
{
return true;
}
public function getCategory()
{
return 'businessLogic';
}
}
App::post('/v1/graphql')
->desc('GraphQL Endpoint')
->groups(['api', 'graphql'])
->label('scope', 'public')
->action(
function () {
throw new Exception('GraphQL support is coming soon!', 502);
->label('scope', 'graphql')
->inject('request')
->inject('response')
->inject('schema')
->middleware(false)
->action(function ($request, $response, $schema) {
// $myErrorFormatter = function(Error $error) {
// $formattedError = FormattedError::createFromException($error);
// var_dump("***** IN ERROR FORMATTER ******");
// return $formattedError;
// };
$query = $request->getPayload('query', '');
$variables = $request->getPayload('variables', null);
$response->setContentType(Response::CONTENT_TYPE_NULL);
try {
$rootValue = [];
$result = GraphQL::executeQuery($schema, $query, $rootValue, null, $variables);
$output = $result->toArray();
} catch (\Exception $error) {
$output = [
'errors' => [
[
'message' => $error->getMessage().'xxx',
'code' => $error->getCode(),
'file' => $error->getFile(),
'line' => $error->getLine(),
'trace' => $error->getTrace(),
]
]
];
}
$response->json($output);
}
);

View file

@ -189,7 +189,7 @@ App::get('/v1/locale/continents')
->action(function ($response, $locale) {
/** @var Appwrite\Utopia\Response $response */
/** @var Utopia\Locale\Locale $locale */
$list = $locale->getText('continents'); /* @var $list array */
\asort($list);

View file

@ -98,6 +98,8 @@ App::get('/v1/users')
/** @var Appwrite\Utopia\Response $response */
/** @var Appwrite\Database\Database $projectDB */
var_dump("Running execute method for list users");
$results = $projectDB->getCollection([
'limit' => $limit,
'offset' => $offset,
@ -217,7 +219,7 @@ App::get('/v1/users/:userId/sessions')
'sum' => count($sessions),
'sessions' => $sessions
]), Response::MODEL_SESSION_LIST);
}, ['response', 'projectDB', 'locale']);
});
App::get('/v1/users/:userId/logs')
->desc('Get User Logs')

View file

@ -33,6 +33,7 @@ App::init(function ($utopia, $request, $response, $console, $project, $user, $lo
/** @var bool $mode */
/** @var array $clients */
$localeParam = (string)$request->getParam('locale', $request->getHeader('x-appwrite-locale', ''));
if (\in_array($localeParam, Config::getParam('locale-codes'))) {
@ -41,6 +42,8 @@ App::init(function ($utopia, $request, $response, $console, $project, $user, $lo
$route = $utopia->match($request);
var_dump("*********** In general.php init with route {$route->getURL()} *************");
if (!empty($route->getLabel('sdk.platform', [])) && empty($project->getId()) && ($route->getLabel('scope', '') !== 'public')) {
throw new Exception('Missing or unknown project ID', 400);
}
@ -167,7 +170,9 @@ App::init(function ($utopia, $request, $response, $console, $project, $user, $lo
$scopes = $roles[$role]['scopes']; // Allowed scopes for user role
$authKey = $request->getHeader('x-appwrite-key', '');
var_dump("***** AUTH KEY ******");
var_dump($authKey);
if (!empty($authKey)) { // API Key authentication
// Check if given key match project API keys
$key = $project->search('secret', $authKey, $project->getAttribute('keys', []));
@ -210,6 +215,10 @@ App::init(function ($utopia, $request, $response, $console, $project, $user, $lo
// TDOO Check if user is god
// var_dump("*********** Allowed Scopes *********");
// var_dump($scopes);
// var_dump($scope);
if (!\in_array($scope, $scopes)) {
if (empty($project->getId()) || Database::SYSTEM_COLLECTION_PROJECTS !== $project->getCollection()) { // Check if permission is denied because project is missing
throw new Exception('Project not found', 404);
@ -252,6 +261,8 @@ App::error(function ($error, $utopia, $request, $response, $layout, $project) {
/** @var Utopia\View $layout */
/** @var Appwrite\Database\Document $project */
var_dump("*********** In general.php error *************");
$route = $utopia->match($request);
$template = ($route) ? $route->getLabel('error', null) : null;

View file

@ -22,6 +22,8 @@ App::init(function ($utopia, $request, $response, $project, $user, $register, $e
/** @var Appwrite\Event\Event $deletes */
/** @var Appwrite\Event\Event $functions */
var_dump("*********** In api.php init *************");
Storage::setDevice('files', new Local(APP_STORAGE_UPLOADS.'/app-'.$project->getId()));
Storage::setDevice('functions', new Local(APP_STORAGE_FUNCTIONS.'/app-'.$project->getId()));
@ -47,9 +49,12 @@ App::init(function ($utopia, $request, $response, $project, $user, $register, $e
//TODO make sure we get array here
foreach ($request->getParams() as $key => $value) { // Set request params as potential abuse keys
$timeLimit->setParam('{param-'.$key.'}', (\is_array($value)) ? \json_encode($value) : $value);
}
// var_dump($request->getParams());
// foreach ($request->getParams() as $key => $value) { // Set request params as potential abuse keys
// $timeLimit->setParam('{param-'.$key.'}', (\is_array($value)) ? \json_encode($value) : $value);
// }
$abuse = new Abuse($timeLimit);
@ -122,6 +127,8 @@ App::shutdown(function ($utopia, $request, $response, $project, $events, $audits
/** @var Appwrite\Event\Event $functions */
/** @var bool $mode */
var_dump("*********** In api.php shutdown *************");
if (!empty($events->getParam('event'))) {
if(empty($events->getParam('payload'))) {
$events->setParam('payload', $response->getPayload());

View file

@ -81,6 +81,8 @@ $http->on('request', function (SwooleRequest $swooleRequest, SwooleResponse $swo
$request = new Request($swooleRequest);
$response = new Response($swooleResponse);
var_dump($swooleRequest->header);
if(Files::isFileLoaded($request->getURI())) {
$time = (60 * 60 * 24 * 365 * 2); // 45 days cache
@ -99,9 +101,11 @@ $http->on('request', function (SwooleRequest $swooleRequest, SwooleResponse $swo
try {
Authorization::cleanRoles();
Authorization::setRole('*');
var_dump("******* Running App ******* ");
$app->run($request, $response);
} catch (\Throwable $th) {
var_dump("*********** In http.php catching error *************");
Console::error('[Error] Type: '.get_class($th));
Console::error('[Error] Message: '.$th->getMessage());
Console::error('[Error] File: '.$th->getFile());

View file

@ -21,6 +21,7 @@ use Appwrite\Database\Document;
use Appwrite\Database\Validator\Authorization;
use Appwrite\Event\Event;
use Appwrite\Extend\PDO;
use Appwrite\GraphQL\GraphQLBuilder;
use Appwrite\OpenSSL\OpenSSL;
use Utopia\App;
use Utopia\View;
@ -502,3 +503,24 @@ App::setResource('geodb', function($register) {
/** @var Utopia\Registry\Registry $register */
return $register->get('geodb');
}, ['register']);
App::setResource('schema', function($utopia, $response, $request, $register) {
$schema = null;
try {
/*
Try to get the schema from the register.
If there is no schema, an exception will be thrown
*/
var_dump('[INFO] Getting Schema from register..');
$schema = $register->get('_schema');
} catch (Exception $e) {
var_dump('[INFO] Exception, Schema not present. Generating Schema');
$schema = GraphQLBuilder::buildSchema($utopia, $response, $request);
$register->set('_schema', function () use ($schema){ // Register cache connection
return $schema;
});
}
return $schema;
}, ['utopia', 'response', 'request', 'register']);

View file

@ -34,7 +34,7 @@
"appwrite/php-clamav": "1.0.*",
"utopia-php/framework": "0.10.0",
"utopia-php/framework": "0.12.0",
"utopia-php/abuse": "0.3.*",
"utopia-php/analytics": "0.1.*",
"utopia-php/audit": "0.5.*",
@ -50,6 +50,7 @@
"utopia-php/storage": "0.4.*",
"utopia-php/image": "0.1.*",
"webonyx/graphql-php": "14.4.0",
"resque/php-resque": "1.3.6",
"matomo/device-detector": "4.1.0",
"dragonmantank/cron-expression": "3.1.0",

5920
composer.lock generated

File diff suppressed because it is too large Load diff

View file

@ -63,7 +63,7 @@ services:
- ./psalm.xml:/usr/src/code/psalm.xml
- ./tests:/usr/src/code/tests
- ./app:/usr/src/code/app
# - ./vendor:/usr/src/code/vendor
- ./vendor:/usr/src/code/vendor
- ./docs:/usr/src/code/docs
- ./public:/usr/src/code/public
- ./src:/usr/src/code/src
@ -523,6 +523,14 @@ services:
# Dev Tools End ------------------------------------------------------------------------------------------
graphiql:
container_name: graphiql
ports:
- '9506:4000'
environment:
- API_URL=http://localhost/v1/graphql
image: npalm/graphiql
networks:
gateway:
appwrite:

View file

@ -0,0 +1,295 @@
<?php
namespace Appwrite\GraphQL;
use Appwrite\GraphQL\Types\JsonType;
use Appwrite\Utopia\Response;
use Appwrite\Utopia\Response\Model;
use Exception;
use GraphQL\Type\Definition\ListOfType;
use GraphQL\Type\Definition\ObjectType;
use GraphQL\Type\Definition\Type;
use GraphQL\Type\Schema;
use MySafeException;
class GraphQLBuilder {
public static $jsonParser;
public static $typeMapping;
private static function init() {
self::$jsonParser = new JsonType();
self::$typeMapping = [
Model::TYPE_BOOLEAN => Type::boolean(),
Model::TYPE_STRING => Type::string(),
Model::TYPE_INTEGER => Type::int(),
Model::TYPE_FLOAT => Type::float(),
Response::MODEL_NONE => Type::string(),
Model::TYPE_JSON => self::$jsonParser,
Response::MODEL_ANY => self::$jsonParser,
];
}
static function createTypeMapping(Model $model, Response $response) {
/*
If the map already contains the type, end the recursion
and return.
*/
if (isset(self::$typeMapping[$model->getType()])) return;
$rules = $model->getRules();
$name = $model->getType();
$fields = [];
$type = null;
/*
Iterate through all the rules in the response model. Each rule is of the form
[
[KEY 1] => [
'type' => A string from Appwrite/Utopia/Response
'description' => A description of the type
'default' => A default value for this type
'example' => An example of this type
'require' => a boolean representing whether this field is required
'array' => a boolean representing whether this field is an array
],
[KEY 2] => [
],
[KEY 3] => [
] .....
]
*/
foreach ($rules as $key => $props) {
/*
If there are any field names containing characters other than a-z, A-Z, 0-9, _ ,
we need to remove all those characters. Currently Appwrite's Response model has only the
$ sign which is prohibited. So we're only replacing that. We need to replace this with a regex
based approach.
*/
$keyWithoutSpecialChars = str_replace('$', '', $key);
if (isset(self::$typeMapping[$props['type']])) {
$type = self::$typeMapping[$props['type']];
} else {
try {
$complexModel = $response->getModel($props['type']);
self::createTypeMapping($complexModel, $response);
$type = self::$typeMapping[$props['type']];
} catch (Exception $e) {
var_dump("Could Not find model for : {$props['type']}");
}
}
/*
If any of the rules is a list,
Wrap the base type with a listOf Type
*/
if ($props['array']) {
$type = Type::listOf($type);
}
$fields[$keyWithoutSpecialChars] = [
'type' => $type,
'description' => $props['description'],
'resolve' => function ($object, $args, $context, $info) use ($key, $type) {
// var_dump("************* RESOLVING FIELD {$info->fieldName} *************");
// var_dump($info->returnType->getWrappedType());
// var_dump("isListType : ", $info->returnType instanceof ListOfType);
// var_dump("isCompositeType : ", Type::isCompositeType($info->returnType));
// var_dump("isBuiltinType : ", Type::isBuiltInType($info->returnType));
// var_dump("isLeafType : ", Type::isLeafType($info->returnType));
// var_dump("isOutputType : ", Type::isOutputType($info->returnType));
// var_dump("PHP Type of object: " . gettype($object[$key]));
return $object[$key];
}
];
}
$objectType = [
'name' => $name,
'fields' => $fields
];
self::$typeMapping[$name] = new ObjectType($objectType);
}
private static function getArgType($validator, bool $required, $utopia, $injections) {
$validator = (\is_callable($validator)) ? call_user_func_array($validator, $utopia->getResources($injections)) : $validator;
$type = [];
switch ((!empty($validator)) ? \get_class($validator) : '') {
case 'Utopia\Validator\Text':
$type = Type::string();
break;
case 'Utopia\Validator\Boolean':
$type = Type::boolean();
break;
case 'Appwrite\Database\Validator\UID':
$type = Type::string();
break;
case 'Utopia\Validator\Email':
$type = Type::string();
break;
case 'Utopia\Validator\URL':
$type = Type::string();
break;
case 'Utopia\Validator\JSON':
case 'Utopia\Validator\Mock':
case 'Utopia\Validator\Assoc':
$type = Type::string();
break;
case 'Appwrite\Storage\Validator\File':
$type = Type::string();
case 'Utopia\Validator\ArrayList':
$type = Type::listOf(Type::string());
break;
case 'Appwrite\Auth\Validator\Password':
$type = Type::string();
break;
case 'Utopia\Validator\Range': /* @var $validator \Utopia\Validator\Range */
$type = Type::int();
break;
case 'Utopia\Validator\Numeric':
$type = Type::int();
break;
case 'Utopia\Validator\Length':
$type = Type::string();
break;
case 'Utopia\Validator\Host':
$type = Type::string();
break;
case 'Utopia\Validator\WhiteList': /* @var $validator \Utopia\Validator\WhiteList */
$type = Type::string();
break;
default:
$type = Type::string();
break;
}
if ($required) {
$type = Type::nonNull($type);
}
return $type;
}
private static function getArgs(array $params, $utopia) {
$args = [];
foreach ($params as $key => $value) {
$args[$key] = [
'type' => self::getArgType($value['validator'],!$value['optional'], $utopia, $value['injections']),
'description' => $value['description'],
'defaultValue' => $value['default']
];
}
return $args;
}
private static function isModel($response, Model $model) {
foreach ($model->getRules() as $key => $rule) {
if (!isset($response[$key])) {
return false;
}
}
return true;
}
public static function buildSchema($utopia, $response) {
self::init();
var_dump("[INFO] Building GraphQL Schema...");
$start = microtime(true);
$queryFields = [];
$mutationFields = [];
foreach($utopia->getRoutes() as $method => $routes ){
foreach($routes as $route) {
$namespace = $route->getLabel('sdk.namespace', '');
if ($namespace == 'database' || true) {
$methodName = $namespace.'_'.$route->getLabel('sdk.method', '');
$responseModelName = $route->getLabel('sdk.response.model', "");
// var_dump("******************************************");
// var_dump("Processing route : ${method} : {$route->getURL()}");
// var_dump("Model Name : ${responseModelName}");
if ( $responseModelName !== "" && $responseModelName !== Response::MODEL_NONE ) {
$responseModel = $response->getModel($responseModelName);
self::createTypeMapping($responseModel, $response);
$type = self::$typeMapping[$responseModel->getType()];
// var_dump("Type Created : ${type}");
$args = self::getArgs($route->getParams(), $utopia);
// var_dump("Args Generated :");
// var_dump($args);
$field = [
'type' => $type,
'description' => $route->getDesc(),
'args' => $args,
'resolve' => function ($type, $args, $context, $info) use (&$utopia, $route, $response) {
// var_dump("************* REACHED RESOLVE FOR {$info->fieldName} *****************");
// var_dump($route);
// var_dump("************* CONTEXT *****************");
// var_dump($context);
// var_dump("********************** ARGS *******************");
// var_dump($args);
$utopia->setRoute($route);
$utopia->execute($route, $args);
// var_dump("**************** OUTPUT ************");
// var_dump($response->getPayload());
$result = $response->getPayload();
if (self::isModel($result, $response->getModel(Response::MODEL_ERROR)) || self::isModel($result, $response->getModel(Response::MODEL_ERROR_DEV))) {
throw new MySafeException($result['message'], $result['code']);
}
return $result;
}
];
if ($method == 'GET') {
$queryFields[$methodName] = $field;
} else if ($method == 'POST' || $method == 'PUT' || $method == 'PATCH' || $method == 'DELETE') {
$mutationFields[$methodName] = $field;
}
// var_dump("Processed route : ${method} : {$route->getURL()}");
} else {
// var_dump("Skipping route : {$route->getURL()}");
}
}
}
}
ksort($queryFields);
ksort($mutationFields);
$queryType = new ObjectType([
'name' => 'Query',
'description' => 'The root of all your queries',
'fields' => $queryFields
]);
$mutationType = new ObjectType([
'name' => 'Mutation',
'description' => 'The root of all your mutations',
'fields' => $mutationFields
]);
$schema = new Schema([
'query' => $queryType,
'mutation' => $mutationType
]);
$time_elapsed_secs = microtime(true) - $start;
var_dump("[INFO] Time Taken To Build Schema : ${time_elapsed_secs}s");
return $schema;
}
}

View file

@ -0,0 +1,68 @@
<?php
namespace Appwrite\GraphQL\Types;
use GraphQL\Language\AST\BooleanValueNode;
use GraphQL\Language\AST\FloatValueNode;
use GraphQL\Language\AST\IntValueNode;
use GraphQL\Language\AST\ListValueNode;
use GraphQL\Language\AST\Node;
use GraphQL\Language\AST\ObjectValueNode;
use GraphQL\Language\AST\StringValueNode;
use GraphQL\Type\Definition\ScalarType;
// https://github.com/webonyx/graphql-php/issues/129#issuecomment-309366803
class JsonType 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).';
public function __construct(?string $name = null)
{
if ($name) {
$this->name = $name;
}
parent::__construct();
}
public function parseValue($value)
{
return $this->identity($value);
}
public function serialize($value)
{
return $this->identity($value);
}
public function parseLiteral(Node $valueNode, ?array $variables = null)
{
switch ($valueNode) {
case ($valueNode instanceof StringValueNode):
case ($valueNode instanceof BooleanValueNode):
return $valueNode->value;
case ($valueNode instanceof IntValueNode):
case ($valueNode instanceof FloatValueNode):
return floatval($valueNode->value);
case ($valueNode instanceof ObjectValueNode): {
$value = [];
foreach ($valueNode->fields as $field) {
$value[$field->name->value] = $this->parseLiteral($field->value);
}
return $value;
}
case ($valueNode instanceof ListValueNode):
return array_map([$this, 'parseLiteral'], $valueNode->values);
default:
return null;
}
}
private function identity($value)
{
return $value;
}
}

View file

@ -120,6 +120,9 @@ class Response extends SwooleResponse
// Tests (keep last)
const MODEL_MOCK = 'mock';
// Content type
const CONTENT_TYPE_NULL = 'null';
/**
* @var Filter
*/
@ -266,7 +269,22 @@ class Response extends SwooleResponse
$output = self::getFilter()->parse($output, $model);
}
$this->json(!empty($output) ? $output : new stdClass());
switch($this->getContentType()) {
case self::CONTENT_TYPE_JSON:
$this->json(!empty($output) ? $output : new stdClass());
break;
case self::CONTENT_TYPE_NULL:
break;
case self::CONTENT_TYPE_YAML:
$this->yaml(!empty($output) ? $output : new stdClass());
break;
default :
$this->json(!empty($output) ? $output : new stdClass());
break;
}
}
/**
@ -318,6 +336,14 @@ class Response extends SwooleResponse
$this->payload = $output;
// var_dump("********************** PAYLOAD SET *********************");
// var_dump("Message : {$output['message']}");
// var_dump("Code : {$output['code']}");
// var_dump("Version : {$output['version']}");
// var_dump("File : {$output['file']}");
// var_dump("Line : {$output['line']}");
// var_dump("Trace : ");
return $this->payload;
}