From 4926336d24dcca2c0cefc95b1ddcf2c48454acac Mon Sep 17 00:00:00 2001 From: Guy Mac Date: Tue, 7 Jul 2020 00:44:14 +0100 Subject: [PATCH] :recycle: refactor --- README.md | 111 +++---- composer.json | 1 + src/{ResolverMethod.php => ActionMethod.php} | 2 +- src/ApiUserInterface.php | 5 - src/DateTimeType.php | 13 +- src/EntitySchemaBuilder.php | 313 ++++++++----------- src/Error/InternalError.php | 18 ++ src/Error/PermissionsError.php | 13 + src/GraphQLEntity.php | 78 +++-- src/Mutation.php | 61 ++-- src/PermissionLevel.php | 12 - src/Permissions.php | 47 --- 12 files changed, 287 insertions(+), 387 deletions(-) rename src/{ResolverMethod.php => ActionMethod.php} (83%) delete mode 100644 src/ApiUserInterface.php create mode 100644 src/Error/InternalError.php create mode 100644 src/Error/PermissionsError.php delete mode 100644 src/PermissionLevel.php delete mode 100644 src/Permissions.php diff --git a/README.md b/README.md index 1a7578f..7014d14 100644 --- a/README.md +++ b/README.md @@ -9,85 +9,58 @@ composer install guym4c/doctrine-graphql-helper ``` ## Usage -This package is a helper package for [`graphql-php`](https://github.com/webonyx/graphql-php) and [`graphql-doctrine`](https://github.com/ecodev/graphql-doctrine), which install automatically with it. Entities used with this package must be `graphql-doctrine`-compatible. Refer to these packages’ documentation for more details. +This package is a helper package for [`graphql-php`](https://github.com/webonyx/graphql-php) and [`graphql-doctrine`](https://github.com/ecodev/graphql-doctrine), which install automatically with it. Entities used with this package must be [`graphql-doctrine`](https://github.com/ecodev/graphql-doctrine) -compatible. Refer to these packages’ documentation for more details. -### `GraphQLEntity` +### Implementing `GraphQLEntity` Any entities which you wish to use in your API must extend `GraphQLEntity` and implement the required methods. +You probably won't want any methods you implement to be added to the schema by `graphql-doctrine`, and so you must annotate them as excluded: +```php +use GraphQL\Doctrine\Annotation as API; + +/** + * @API\Exclude + */ +public function someMethod() {} +``` + #### Entity constructors -When an entity is created over GraphQL using a create mutator, it is constructed using `buildFromJson()`, which in turn calls the `hydrate()` method. If your entity has a constructor with parameters, then you will need to override `buildFromJson()` in your entity class and call the constructor yourself. You may still use `hydrate()` after this call, but remember to unset any fields that you have already hydrated from the (JSON) array before you make this call or they will be overwritten. +When an entity is created over GraphQL using a create mutator, it is constructed using the `buildFromJson()` static, which calls the constructor with no parameters. If your entity has a constructor with parameters, then you will need to override `buildFromJson()` in your entity class and call the constructor yourself. + +After you've performed any tasks you need to, you may still use the inherited `hydrate()` call after this to fill out the object with the input data. You must unset any properties that you have already hydrated yourself from the input array `$data` before you make this call, or your existing properties will be overwritten. #### Events -In addition to the events that Doctrine provides, the schema builder adds events that fire during the execution of some resolvers: `beforeUpdate()` and `beforeDelete()`. You may extend these from `GraphQLEntity`. Both fire immediately after the entity in its initial state is retrieved from Doctrine, and before any operation is performed. (For `beforeDelete()` in particular, these means all fields, including generated values, are accessible.) +In addition to the events that Doctrine provides you with, the schema builder adds events that fire during the execution of some resolvers: `beforeUpdate()` and `beforeDelete()`. You may extend these from `GraphQLEntity`. Both fire immediately after the entity in its initial state is retrieved from the ORM, and before any operation is performed. (For `beforeDelete()` in particular, these means all fields, including generated values, are accessible.) -### Building the schema -Construct a schema builder on entities of your choice. You must provide an instance of the Doctrine entity manager: -```php -$builder = new EntitySchemaBuilder($em); -``` +#### Permissions +You always need to implement `hasPermission()`, regardless of whether you intend to implement permissions at this level or not. You can find more details on implementing permissions below, or just stub it out with a `return true;` for the moment. For security reasons, the builder does not default to this. -You may then build the schema from an associative array where the key is the plural form of the entity's name, and the value is the class name of the entity. For example: +### Building the schema +Construct a schema builder on entities of your choice. You must provide an instance of the Doctrine entity manager, and an associative array where the key is the plural form of the entity's name, and the value is the fully-qualified class name of the entity definition. For example: ```php -$schema = $builder->build([ - 'users' => User::class, +$builder = new EntitySchemaBuilder($em, [ + 'owners' => Owner::class, 'dogs' => Dog::class, 'cats' => Cat::class, ]); ``` -Note that `getSchema()` does not build the schema, but retrieves the most recently built schema. - -### Setting permissions -If you wish to use permissions, you may also provide the EntitySchemaBuilder constructor with: - -* An array of scopes and the permissions on methods acting upon them (example below) -* The class name of the user entity, which must implement ApiUserInterface - ### Running queries -You may use your built schema in a GraphQL server of your choice, or use the helper’s integration with `graphql-php` to retrieve a server object already set up with your schema and any permissions settings you have defined by calling `getServer()`. +You may use your built schema in a GraphQL server of your choice, or use the helper’s integration with `graphql-php` to retrieve a server object already set up with your schema by calling `getServer()`. -The server object returned accepts a request object in its `executeRequest()` method. In some cases you may wish to run a JSON payload through the server - to do this you can parse the JSON to a format which the server will accept as a parameter to `executeRequest()` by calling `EntitySchemaBuilder::jsonToOperation($json)`. +The server returned accepts a request object in its `executeRequest()` method. In some cases you may wish to run a raw JSON payload through the server. To do this, can parse the JSON to a format which the server will accept as a parameter to `executeRequest()` by calling `EntitySchemaBuilder::jsonToOperation($json)`. ### Using permissions -If you have set the schema builder’s permissions during instantiation, provide the permitted scopes (as an array) and the user’s identifier to the `getServer()` method to execute the query with permissions enabled. -The schema generator generates four queries for each provided entity, which have parallels to the HTTP request methods used in REST: a simple `GET` query, and `POST` (create), `UPDATE` and `DELETE` mutators. You may define the permissions at method-level granularity using the scopes array, provided to the builder’s constructor. - -For example: - +Permissions are managed using the handler you implemented when extending `GraphQLEntity`. The `hasPermission()` handler is passed 4 parameters to help you implement this: ```php -$scopes = [ - 'admin' => ['*'], - 'vet' => [ - 'dog' => [ - 'get' => 'all', - 'update' => 'all', - ], - 'cat' => [ - 'get' => 'all', - 'update' => 'all', - ], - 'user' => [ - 'get' => 'permissive', - 'update' => 'permissive', - ], - ], - 'dog-owner' => [ - 'dog' => [ - 'get' => 'permissive', - 'update' => 'permissive', - ], - ], - - // etc. -]; +abstract public function hasPermission( + EntityManager $em, // an instance of the entity manager + DoctrineUniqueInterface $user, // the user you passed to getServer() + array $context, // array of additional context you optionally passed to getServer() + string $method // action method verb +): bool; ``` - -An asterisk (\*) is a wildcard, indicating full permissions are given for this scope. Otherwise, each entity is assigned a permission on a per-method basis. Methods and entities without defined permissions will be assumed to be accessible to all users. Each method may be assigned one of three values: - -* **All:** Accessible to all users with this scope -* **None:** Not accessible to users with this scope -* **Permissive:** Users permissions with this scope are resolved in the entity’s `hasPermission()` method. If you don’t wish to use permissive, but are running the server with permissions enabled, simply implement the method with a return true. -The `hasPermission()` method is called for all methods that are defined as permissive, and you are passed an instance of the Doctrine entity manager and an instance of your API user class as `ApiUserInterface`. +The `method` corresponds to the action verb assigned to the currently executing query or mutation. The generated queries and mutators use pre-set CRUD-like verbs: `get`, `create`, `update` and `delete`, but you can use any verb you choose when writing your own mutators. ## Using custom mutators The schema generator exposes a simple API for adding your own mutators, and a class (`Mutation`). This wraps some advanced functionality of graphql-doctrine, and so reference to that package’s documentation may or will be required using this feature. @@ -101,11 +74,11 @@ There are two methods of hydrating the new `Mutation` returned by the factory: u **`setDescription()`:** Set a description returned by the server in introspection queries (optional). -**`setArgs()`:** Set the arguments that may be given when this mutator is queried. By default, this is a non-null (required) ID type, allowing you to retrieve the entity that ID refers to using the entity manager. +**`setArgs()`:** Set the arguments that may be given when this mutator is queried. By default, this is a non-null (required) ID type, allowing you to retrieve the entity that the ID refers to using the entity manager. -**`usePermissions()`:** It is expected that you will implement your own permissions check in your resolver, but as a fallback you may hook in to the helper’s permissions system by setting `usePermissions()` to `true` and giving a valid query method to `setMethod()`. The helper will then use the permission level assigned to the `Mutation`’s set entity and provided method using the scopes of the request. +**`setMethod()`:** Set the action verb that this mutator uses for permissions purposes. -**`setResolver()`:** You must provide a callable here that takes two arguments – an array of your mutator’s args and the user ID of the user making the request. You must return the data that you wish to be returned with the response to the query, and that data must of the correct type – methods that can assist with this are provided in `EntitySchemaBuilder`, and it is suggested that you define this callable in a variable scope where you have access to it and the entity manager. Failure to resolve data of the correct type will result in the server returning 500. +**`setResolver()`:** You must provide a callable here that takes two arguments – an array of your mutator’s args and the API user making the request. You must return the data that you wish to be returned with the response to the query, and that data must of the correct type – methods that can assist with this are provided in `EntitySchemaBuilder`, and it is suggested that you define this callable in a variable scope where you have access to it and the entity manager. Failure to resolve data of the correct type will result in the server returning an error. ## Methods exposed by the builder The schema builder exposes a variety of methods which may be of use when writing resolver functions. You may wish to consult the documentation of `graphql-php` and `graphql-doctrine` for more information on the values that some of these methods return. @@ -116,18 +89,10 @@ The schema builder exposes a variety of methods which may be of use when writing **`getMutator()`:** Generates a mutator (in its array form, not a `Mutation`) from the provided type, args and resolver. -**`setUserEntity()`:** Changes the user entity class name from that given during instantiation. - -**`getTypes()`:** Retrieve the types generated by `graphql-doctrine` from the entities provided during the most recent `build()`. - -**`isPermitted()`:** Resolves the permission level of a query, given its args, query context and entity class name. The query context is a value used internally by the schema builder and is an associative array of the following format: +**`setUserEntity()`:** Changes the user from that given during instantiation. -```php -$context = [ - 'scopes' => [],// array of this request's scopes - 'user' => '',// user ID -]; -``` +**`getTypes()`:** Retrieve the types that have been generated for use in the schema. +**`isPermitted()`:** Resolves the permission level of a query, given its args, query context and entity class name. diff --git a/composer.json b/composer.json index 2aabc3e..0d0ae3c 100644 --- a/composer.json +++ b/composer.json @@ -19,6 +19,7 @@ } }, "require": { + "php": "^7.4", "webonyx/graphql-php": "^0.13.0", "ecodev/graphql-doctrine": "^6.1", "myclabs/php-enum": "^1.6", diff --git a/src/ResolverMethod.php b/src/ActionMethod.php similarity index 83% rename from src/ResolverMethod.php rename to src/ActionMethod.php index 330e35d..51753c2 100644 --- a/src/ResolverMethod.php +++ b/src/ActionMethod.php @@ -4,7 +4,7 @@ use MyCLabs\Enum\Enum; -class ResolverMethod extends Enum { +class ActionMethod extends Enum { const CREATE = 'create'; const UPDATE = 'update'; diff --git a/src/ApiUserInterface.php b/src/ApiUserInterface.php deleted file mode 100644 index 3be3eca..0000000 --- a/src/ApiUserInterface.php +++ /dev/null @@ -1,5 +0,0 @@ -value; } - public function parseValue($value, array $variables = null) - { + public function parseValue($value, array $variables = null) { if (!is_string($value)) { - throw new \UnexpectedValueException('Cannot represent value as DateTime date: ' . Utils::printSafe($value)); + throw new UnexpectedValueException('Cannot represent value as DateTime date: ' . Utils::printSafe($value)); } return new DateTime($value); } - public function serialize($value) - { + public function serialize($value) { if ($value instanceof DateTime) { return $value->format('c'); } diff --git a/src/EntitySchemaBuilder.php b/src/EntitySchemaBuilder.php index 11a98d6..f79efff 100644 --- a/src/EntitySchemaBuilder.php +++ b/src/EntitySchemaBuilder.php @@ -6,7 +6,10 @@ use Doctrine\ORM\OptimisticLockException; use Doctrine\ORM\ORMException; use GraphQL\Doctrine\DefaultFieldResolver; +use GraphQL\Doctrine\Helper\Error\InternalError; +use GraphQL\Doctrine\Helper\Error\PermissionsError; use GraphQL\Doctrine\Types; +use GraphQL\Error\UserError; use GraphQL\GraphQL; use GraphQL\Server\OperationParams; use GraphQL\Server\ServerConfig; @@ -14,7 +17,7 @@ use GraphQL\Type\Definition\ObjectType; use GraphQL\Type\Definition\Type; use GraphQL\Type\Schema; -use GraphQL\Doctrine\Helper\ResolverMethod as Resolver; +use GraphQL\Doctrine\Helper\ActionMethod as Resolver; use League\Container\Container; use ReflectionClass; use ReflectionException; @@ -23,62 +26,50 @@ class EntitySchemaBuilder { const DEFAULT_RESULT_LIMIT = 50; - /** @var Schema */ - private $schema; + private Schema $schema; - /** @var string|null */ - private $userEntity; + private ?DoctrineUniqueInterface $user; - /** @var int */ - private $resultLimit; + private int $resultLimit; - /** @var EntityManager */ - private $em; + private EntityManager $em; - /** @var Types */ - private $types; - - /** @var Permissions|null */ - private $permissions; + private Types $types; /** @var Mutation[] */ - private $mutators = []; + private array $mutators = []; /** * EntitySchema constructor. - * @param EntityManager $em An instance of the entity manager. - * @param array|null $scopes An array of scopes' permissions on entity methods - * @param string $userEntity The class name of the user entity. If this is null, all permissions will be given to all users. - * @param int $resultLimit The maximum amount of results that can be returned by the API. + * @param EntityManager $em An instance of the entity manager. + * @param array $entities associative array of the plural form of the fully qualified class name of the entity + * @param int $resultLimit The maximum amount of results that can be returned by the API. */ - public function __construct(EntityManager $em, ?array $scopes = null, ?string $userEntity = null, int $resultLimit = self::DEFAULT_RESULT_LIMIT) { + public function __construct( + EntityManager $em, + array $entities, + int $resultLimit = self::DEFAULT_RESULT_LIMIT + ) { $types = new Container(); $types->add('datetime', new DateTimeType()); - $this->userEntity = $userEntity; - $this->resultLimit = $resultLimit; $this->em = $em; + $this->resultLimit = $resultLimit; $this->types = new Types($this->em, $types); - $this->permissions = $scopes == null ? null : new Permissions($scopes); + + $this->buildSchema($entities); } - public function getServer(array $scopes = [], ?string $userId = null): StandardServer { + public function getServer(DoctrineUniqueInterface $user = null, array $context = []): StandardServer { + $this->user = $user; + return new StandardServer(ServerConfig::create() ->setSchema($this->schema) - ->setContext([ - 'scopes' => $scopes, - 'user' => $userId, - ])); + ->setContext($context) + ); } - /** - * Builds the schema, where $entities is an associative array of the plural form to the fully qualified class name of the entity. - * - * @param array $entities An associative array of the plural form to the fully qualified class name of the entity. - * @return self - */ - public function build(array $entities): self { - + private function buildSchema(array $entities): void { GraphQL::setDefaultFieldResolver(new DefaultFieldResolver()); $parsedMutators = []; @@ -98,8 +89,6 @@ public function build(array $entities): self { $parsedMutators), ]), ]); - - return $this; } /** @@ -116,9 +105,9 @@ private function getAllQueries(array $entities) { return $queries; } - private static $ID_ARG_DOC = 'Shorthand for a filter for a single ID. You may encounter a 403 response where you do not have permission for a full query against that resource. In this case, you may provide this argument to select an entity you are permitted to access.'; - private static $LIMIT_ARG_DOC = 'Limits the amount of results returned - %d by default. If you require more results, paginate your requests using limit and offset'; - private static $OFFSET_ARG_DOC = 'The number of the first record your result set will begin from, inclusive.'; + private static string $ID_ARG_DOC = 'Shorthand for a filter for a single ID. You may encounter a 403 response where you do not have permission for a full query against that resource. In this case, you may provide this argument to select an entity you are permitted to access.'; + private static string $LIMIT_ARG_DOC = 'Limits the amount of results returned - %d by default. If you require more results, paginate your requests using limit and offset'; + private static string $OFFSET_ARG_DOC = 'The number of the first record your result set will begin from, inclusive.'; /** * Return a GraphQL list type of entity $entity, with default sorting options and comprehensive Doctrine-compatible sorting. @@ -169,47 +158,56 @@ public function listOfType(string $entity): Type { * Resolve a simple 'get' query against entity $entity, parsing filtering and sorting as given by listOf(). * If $only is not provided, then you must provide the query $context. * - * @param array $args The arguments posted with this query - * @param string $entity The entity to query against - * @param DoctrineUniqueInterface|null $only Optionally, restrict unfiltered queries to only return this entity. - * @param array $context - * @return mixed If successful, an array of + * @param array $args The arguments posted with this query + * @param string $entityName The entity classname to query against + * @param DoctrineUniqueInterface|null $only Optionally, restrict unfiltered queries to only return this entity. + * @param array $context + * @return mixed If successful, an array of results */ - public function resolveQuery(array $args, string $entity, ?DoctrineUniqueInterface $only = null, array $context = []) { + public function resolveQuery(array $args, string $entityName, ?DoctrineUniqueInterface $only = null, array $context = []) { if (!empty($only)) { $args['id'] = $only->getIdentifier(); - } else if (count($context) > 0 && - !$this->isPermitted($args, $context, $entity)) { - return [403]; } - $qb = $this->types->createFilteredQueryBuilder($entity, + if (!$this->isPermitted($args, $entityName, $context)) { + throw new PermissionsError($entityName); + } + + $queryBuilder = $this->types->createFilteredQueryBuilder($entityName, $args['filter'] ?? [], $args['sorting'] ?? []); - $query = $qb->select(); - $params = []; + $query = $queryBuilder->select(); if (!empty($args['id'])) { - $query->where( - $qb->expr() - ->eq($query->getRootAliases()[0] . '.identifier', ':id')); - $params['id'] = $args['id']; + $query->where($queryBuilder->expr() + ->eq($query->getRootAliases()[0] . '.identifier', ':id') + ); + $query->setParameter('id', $args['id']); } $n = $args['limit'] ?? self::DEFAULT_RESULT_LIMIT; $offset = $args['offset'] ?? 0; - foreach ($params as $key => $value) { - $query->setParameter($key, $value); - } - - $query->getQuery() + return $query->getQuery() ->setFirstResult($offset) - ->setMaxResults($n); + ->setMaxResults($n) + ->execute(); + } - return $query->getQuery()->execute(); + /** + * Wraps getMutators() and generates mutators for all entities in $entities. + * @param array $entities An array of entity type class names that the mutators should act upon. + * @return array A list of mutator types + * @see self::getMutators() + */ + private function generateMutatorsForEntities(array $entities): array { + $mutators = []; + foreach ($entities as $entity) { + $mutators = array_merge($mutators, $this->generateMutatorsForEntity($entity)); + } + return $mutators; } /** @@ -218,7 +216,7 @@ public function resolveQuery(array $args, string $entity, ?DoctrineUniqueInterfa * @param string $entity The entity type class name that the mutators should act upon. * @return array A list of mutator types */ - private function generateMutatorsForEntity(string $entity): array { + private function generateMutatorsForEntity(string $entity): ?array { try { $entityName = (new ReflectionClass($entity))->getShortName(); @@ -228,7 +226,7 @@ private function generateMutatorsForEntity(string $entity): array { return [ 'create' . $entityName => $this->getMutator($entity, [ - 'input' => Type::nonNull($this->types->getInput($entity)), + 'input' => Type::nonNull($this->types->getInput($entity)), ], function ($root, $args, $context) use ($entity) { return $this->mutationResolver($args, $context, $entity, Resolver::CREATE); }), @@ -248,37 +246,20 @@ private function generateMutatorsForEntity(string $entity): array { ]; } - /** - * Wraps getMutators() and generates mutators for all entities in $entities. - * @param array $entities An array of entity type class names that the mutators should act upon. - * @return array A list of mutator types - * @see self::getMutators() - */ - private function generateMutatorsForEntities(array $entities): array { - $mutators = []; - foreach ($entities as $entity) { - $mutators = array_merge($mutators, $this->generateMutatorsForEntity($entity)); - } - return $mutators; - } - /** * Generates a mutator from the provided type, args and resolver. By default, the mutator returns a list type of $entity using listOf(). * - * @param string $entity The entity which the mutator acts against - * @param array $args An array of args that this mutator will possess - * @param callable $resolver A callable resolver in the format function($root, $args) + * @param string $entityName The entity which the mutator acts against + * @param array $args An array of args that this mutator will possess + * @param callable $resolver A callable resolver in the format function($root, $args) * @param string|null $description - * @param Type|null $type If specified, a type that will override the default given above + * @param Type|null $type If specified, a type that will override the default given above * * @return array The mutator type */ - public function getMutator(string $entity, array $args, callable $resolver, ?string $description = null, ?Type $type = null): array { - if (empty($type)) { - $type = Type::listOf($this->types->getOutput($entity)); - } + public function getMutator(string $entityName, array $args, callable $resolver, ?string $description = null, ?Type $type = null): array { return [ - 'type' => $type, + 'type' => $type ?? Type::listOf($this->types->getOutput($entityName)), 'args' => $args, 'resolve' => $resolver, 'description' => $description ?? '', @@ -286,142 +267,108 @@ public function getMutator(string $entity, array $args, callable $resolver, ?str } /** - * @param array $args - * @param array $context - * @param string $entity + * @param array $args + * @param string $entityName + * @param array $context * @param string $method * @return bool */ - public function isPermitted(array $args, array $context, string $entity, string $method = 'GET'): bool { - - if ($context['user'] == null || - $context['scopes'] == null || - $this->userEntity == null || - $this->permissions == null) { + public function isPermitted(array $args, string $entityName, array $context, string $method = Resolver::GET): bool { + if ($this->user === null) { return true; } - $permitted = false; - foreach ($context['scopes'] as $scope) { - switch ($this->permissions->getPermission($scope, $entity, $method)) { - - case PermissionLevel::ALL: - $permitted = true; - break; - - case PermissionLevel::PERMISSIVE: - /** @var GraphQLEntity $entityObject */ - $entityObject = $this->em->getRepository($entity)->find($args['id']); - /** @var ApiUserInterface $user */ - $user = $this->em->getRepository($this->userEntity)->find($context['user']); - - $permitted = $entityObject->hasPermission($this->em, $user); - break; - - case PermissionLevel::NONE: - break; - } - - if ($permitted) { - break; - } - } - - return $permitted; + /** @var GraphQLEntity $entity */ + $entity = $this->em->getRepository($entityName) + ->find($args['id']); + return $entity->hasPermission($this->em, $this->user, $context, $method); } /** * Dispatches mutations to the appropriate resolver for their $method. * - * @param array $args - * @param array $context - * @param string $entity + * @param array $args + * @param array $context + * @param string $entityName * @param string $method * @return mixed - * @throws ORMException - * @throws OptimisticLockException + * @throws ORMException|OptimisticLockException|UserError|InternalError */ - private function mutationResolver(array $args, array $context, string $entity, string $method) { + private function mutationResolver(array $args, array $context, string $entityName, string $method) { - if (!$this->isPermitted($args, $context, $entity, $method)) { - return [403]; + if (!$this->isPermitted($args, $entityName, $context, $method)) { + throw new PermissionsError($entityName, $method); } switch ($method) { - case 'create': - return $this->createResolver($args, $entity); - break; - - case 'update': - return $this->updateResolver($args, $entity); - break; - - case 'delete': - return $this->deleteResolver($args, $entity); - break; + case ActionMethod::CREATE: + return $this->createResolver($args, $entityName); + case ActionMethod::UPDATE: + return $this->updateResolver($args, $entityName); + case ActionMethod::DELETE: + return $this->deleteResolver($args, $entityName); + default: + throw new InternalError('Invalid action %s on entity %s', $method, $entityName); } - - return [400]; } /** * Calls $entity::buildFromJson and persists the result, where $entity is the class name of a GraphQLConstructableInterface, and then resolves the rest of the query. * - * @param array $args The query args from the calling mutator - * @param string $entity The entity which the resolver acts against + * @param array $args The query args from the calling mutator + * @param string $entityName The entity which the resolver acts against * * @return mixed The rest of the query, resolved * @throws ORMException * @throws OptimisticLockException */ - private function createResolver(array $args, string $entity) { + private function createResolver(array $args, string $entityName) { /** @var GraphQLEntity $new */ - $new = call_user_func($entity . '::buildFromJson', $this->em, $args['input']); + $new = call_user_func($entityName . '::buildFromJson', $this->em, $args['input']); $this->em->persist($new); $this->em->flush(); - return $this->resolveQuery($args, $entity, $new); + return $this->resolveQuery($args, $entityName, $new); } /** * Calls $entity::updateFromJson and persists the result, where $entity is the class name of a GraphQLEntity, and then resolves the rest of the query. * - * @param array $args The query args from the calling mutator - * @param string $entity The entity which the resolver acts against + * @param array $args The query args from the calling mutator + * @param string $entityName The entity which the resolver acts against * * @return mixed The rest of the query, resolved - * @throws ORMException - * @throws OptimisticLockException + * @throws ORMException|OptimisticLockException|InternalError */ - private function updateResolver(array $args, string $entity) { + private function updateResolver(array $args, string $entityName) { - /** @var GraphQLEntity $update */ - $update = $this->em->getRepository($entity)->find($args['id']); + /** @var GraphQLEntity $entity */ + $entity = $this->em->getRepository($entityName) + ->find($args['id']); - $update->beforeUpdate($this->em, $args); + $entity->beforeUpdate($this->em, $args); - $update->hydrate($this->em, $args['input'], $entity, true); + $entity->hydrate($this->em, $args['input'], $entityName, true); $this->em->flush(); - return $this->resolveQuery($args, $entity, $update); + return $this->resolveQuery($args, $entityName, $entity); } /** * Removes entity $entity and returns its ID. * - * @param array $args The query args from the calling mutator - * @param string $entity The entity which the resolver acts against + * @param array $args The query args from the calling mutator + * @param string $entityName The entity which the resolver acts against * @return mixed * @throws ORMException * @throws OptimisticLockException */ - private function deleteResolver(array $args, string $entity) { - + private function deleteResolver(array $args, string $entityName) { /** @var GraphQLEntity $condemned */ - $condemned = $this->em->getRepository($entity)->find($args['id']); + $condemned = $this->em->getRepository($entityName)->find($args['id']); $condemned->beforeDelete($this->em, $args); @@ -431,43 +378,33 @@ private function deleteResolver(array $args, string $entity) { return $args['id']; } - /** - * @param string|null $userEntity - */ - public function setUserEntity(?string $userEntity): void { - $this->userEntity = $userEntity; - } - - /** - * @param int $resultLimit - */ - public function setResultLimit(int $resultLimit): void { - $this->resultLimit = $resultLimit; - } - - /** - * @return Schema - */ public function getSchema(): Schema { return $this->schema; } - /** - * @return Types - */ public function getTypes(): Types { return $this->types; } + public function getUser(): DoctrineUniqueInterface { + return $this->user; + } + + /** + * Generate a blank mutation pre-associated with this builder + * + * @param string $name + * @return Mutation + */ public function mutation(string $name): Mutation { $mutation = new Mutation($this, $name); $this->mutators[$name] = $mutation; return $mutation; } - + public static function jsonToOperation(array $json): OperationParams { return OperationParams::create([ - 'query' => $json['query'], + 'query' => $json['query'], 'variables' => $json['variables'] ?? null, ]); } diff --git a/src/Error/InternalError.php b/src/Error/InternalError.php new file mode 100644 index 0000000..0f6d134 --- /dev/null +++ b/src/Error/InternalError.php @@ -0,0 +1,18 @@ +getClassMetadata($entity)->fieldMappings as $field) { + foreach ($em->getClassMetadata($entityName)->fieldMappings as $field) { $fieldName = $field['fieldName']; - if (!$field['nullable'] && - !($field['id'] ?? false)) { - - if (empty($this->{$fieldName}) || $update) { - - if (empty($data[$fieldName]) && !$update) { + if ( + !$field['nullable'] + && !($field['id'] ?? false) + ) { + if ( + empty($this->{$fieldName}) + || $isUpdate + ) { + if ( + empty($data[$fieldName]) + && !$isUpdate + ) { throw new InvalidArgumentException(sprintf("Field %s is required but not provided", $fieldName)); } else { $this->hydrateField($fieldName, $data[$fieldName]); @@ -40,29 +48,52 @@ public function hydrate(EntityManager $em, array $data, ?string $entity = null, } foreach ($data as $key => $value) { - if (property_exists($entity, $key)) { + if (property_exists($entityName, $key)) { $this->hydrateField($key, $value); } } } + /** + * @param EntityManager $em + * @param array $data + * @throws InternalError + */ + public function update(EntityManager $em, array $data): void { + $this->hydrate($em, $data, null, true); + } + /** * Hydrates a field, parsing the value if it is relational. * * @param string $key * @param object $value + * @throws InternalError */ private function hydrateField(string $key, $value): void { - if ($value instanceof EntityID) { + if ($value instanceof Relation) { try { $value = $value->getEntity(); } catch (GraphQL\Error\Error $e) { - throw new InvalidArgumentException(sprintf("Entity %s was not found in %s", $value->getId(), static::class)); + throw new InternalError( + sprintf( + "Could not fetch %s whilst mapping relations from %s ID %s", + $value->getId(), + static::class, + $this->getIdentifier() + ), + ); } } $this->{$key} = $value; } + /** + * @param EntityManager $em + * @param array $input + * @return static + * @throws InternalError + */ public static function buildFromJson(EntityManager $em, array $input) { $entity = new static(); $entity->hydrate($em, $input); @@ -72,13 +103,18 @@ public static function buildFromJson(EntityManager $em, array $input) { /** * @API\Exclude * - * @param EntityManager $em - * @param ApiUserInterface $user + * @param EntityManager $em + * @param DoctrineUniqueInterface $user + * @param array $context + * @param string $method * @return bool */ - public function hasPermission(EntityManager $em, ApiUserInterface $user): bool { - return true; - } + abstract public function hasPermission( + EntityManager $em, + DoctrineUniqueInterface $user, + array $context, + string $method + ): bool; /* * Events - implement if required diff --git a/src/Mutation.php b/src/Mutation.php index 2cc4ba6..d6a0abc 100644 --- a/src/Mutation.php +++ b/src/Mutation.php @@ -2,39 +2,29 @@ namespace GraphQL\Doctrine\Helper; -use GraphQL\Doctrine\Types; +use GraphQL\Doctrine\Helper\Error\PermissionsError; use GraphQL\Type\Definition\Type; class Mutation { - const DEFAULT_METHOD = ResolverMethod::UPDATE; + const DEFAULT_METHOD = ActionMethod::UPDATE; - /** @var string */ - private $name; + private string $name; - /** @var string */ - private $entity; + private string $entity; /** @var callable */ private $resolver; - /** @var Type|null */ - private $type; + private ?Type $type; - /** @var array */ - private $args = []; + private array $args = []; - /** @var EntitySchemaBuilder */ - private $builder; + private EntitySchemaBuilder $builder; - /** @var string */ - private $method; + private string $method; - /** @var string|null */ - private $description; - - /** @var bool */ - private $permissions = true; + private ?string $description; /** * Mutation constructor @@ -55,20 +45,31 @@ public function __construct(EntitySchemaBuilder $builder, string $name) { * @param string|null $method * @param array $args * @param string|null $description - * @param bool $permissions */ - public function hydrate(string $entity, callable $resolver, ?Type $type = null, ?string $method = null, array $args = [], ?string $description = null, bool $permissions = true) { + public function hydrate( + string $entity, + callable $resolver, + ?Type $type = null, + ?string $method = null, + array $args = [], + ?string $description = null + ) { $this->entity = $entity; $this->resolver = $resolver; $this->type = $type ?? Type::listOf($this->builder->getTypes()->getOutput($entity)); $this->method = $method ?? self::DEFAULT_METHOD; $this->args = $args; $this->description = $description; - $this->permissions = $permissions; } public function getMutator(): array { - return $this->builder->getMutator($this->entity, $this->args, $this->getResolver(), $this->description, $this->type); + return $this->builder->getMutator( + $this->entity, + $this->args, + $this->getResolver(), + $this->description, + $this->type, + ); } /** @@ -126,11 +127,11 @@ public function setDescription(?string $description): self { } /** - * @param bool $permissions + * @param string $method * @return self */ - public function usePermissions(bool $permissions): self { - $this->permissions = $permissions; + public function setMethod(string $method): self { + $this->method = $method; return $this; } @@ -147,13 +148,11 @@ public function getName(): string { public function getResolver(): callable { return function ($root, $args, $context) { - if ($this->permissions) { - if (!$this->builder->isPermitted($args, $context, $this->entity, $this->method)) { - return [403]; - } + if (!$this->builder->isPermitted($args, $this->entity, $context, $this->method)) { + throw new PermissionsError($this->entity); } - return ($this->resolver)($args, $context['user']); + return ($this->resolver)($args, $this->builder->getUser()); }; } diff --git a/src/PermissionLevel.php b/src/PermissionLevel.php deleted file mode 100644 index c4adb76..0000000 --- a/src/PermissionLevel.php +++ /dev/null @@ -1,12 +0,0 @@ -scopes = $scopes; - } - - public function scopeExists(string $id): bool { - $result = $id == '*' || - array_key_exists($id, $this->scopes); - return $result; - } - - public function getPermission(string $id, string $entity, string $method): string { - - if (!$this->scopeExists($id)) { - return PermissionLevel::NONE; - } - - $scopes = $this->scopes[$id]; - - if (!empty($scopes[0]) && - $scopes[0] == '*') { - return PermissionLevel::ALL; - } - - if (empty($scopes[$entity])) { - return PermissionLevel::NONE; - } - - if (in_array($method, $scopes[$entity])) { - return $scopes[$entity][$method]; - } - - return PermissionLevel::NONE; - } -} \ No newline at end of file