From ddecc75673c5181ddb138430b80a5cff1965fed3 Mon Sep 17 00:00:00 2001 From: lorado Date: Sun, 20 Jan 2019 03:41:50 +0100 Subject: [PATCH 1/2] add support for file upload and graphql-multipart-request-spec --- composer.json | 5 ++- src/Definition/Type.php | 13 +++++++ src/Definition/UploadType.php | 63 ++++++++++++++++++++++++++++++ src/GraphQLController.php | 73 ++++++++++++++++++++++++++++++++++- 4 files changed, 150 insertions(+), 4 deletions(-) create mode 100644 src/Definition/UploadType.php diff --git a/composer.json b/composer.json index 034ac67..bf9f63a 100644 --- a/composer.json +++ b/composer.json @@ -12,8 +12,9 @@ "laravel/framework": "~5.6.0|~5.7.0", "webonyx/graphql-php": "^0.13.0", "doctrine/dbal": "^2.5", - "cache/array-adapter": "^1.0" - }, + "cache/array-adapter": "^1.0", + "ext-json": "*" + }, "require-dev": { "phpunit/phpunit": "^7.0", "orchestra/testbench-browser-kit": "~3.6.0|~3.7.0@dev", diff --git a/src/Definition/Type.php b/src/Definition/Type.php index 4fca297..c467591 100644 --- a/src/Definition/Type.php +++ b/src/Definition/Type.php @@ -20,6 +20,9 @@ abstract class Type extends GraphQLType { /** PAGINATION */ const PAGINATION = 'Pagination'; + /** UPLOAD */ + const UPLOAD = 'Upload'; + /** @var array $cache */ protected static $cache = null; @@ -64,6 +67,15 @@ public static function pagination() { return self::getCache(self::PAGINATION); } + /** + * Return upload type + * + * @return UploadType + */ + public static function upload() { + return self::getCache(self::UPLOAD); + } + /** * Alias of `boolean` * @@ -85,6 +97,7 @@ protected static function getCache($name = null) { self::JSON => new JsonType, self::DATETIME => new DatetimeType, self::PAGINATION => new PaginationType, + self::UPLOAD => new UploadType(), ]; } diff --git a/src/Definition/UploadType.php b/src/Definition/UploadType.php new file mode 100644 index 0000000..6ff1310 --- /dev/null +++ b/src/Definition/UploadType.php @@ -0,0 +1,63 @@ +kind, [$valueNode]); + } +} diff --git a/src/GraphQLController.php b/src/GraphQLController.php index 4a28a6c..e31fa79 100644 --- a/src/GraphQLController.php +++ b/src/GraphQLController.php @@ -1,6 +1,7 @@ header('content-type', ''); + if (mb_stripos($contentType, 'multipart/form-data') !== false) { + $this->validateParsedBody($request); + $request = $this->parseUploadedFiles($request); + } $inputs = $request->all(); + $data = []; // If there's no schema, just use default one @@ -48,7 +55,6 @@ public function query(Request $request, $schema = null) { else { $data = $this->executeQuery($schema, $inputs); } - } catch (\Exception $exception) { $data = GraphQL::formatGraphQLException($exception); Log::debug($exception); @@ -107,4 +113,67 @@ protected function getContext() { return null; } + + /** + * Parse uploaded files, replace file variables with \Illuminate\Http\UploadedFile instance, + * and return operations stack + * + * @param $request Request + * + * @return Request + */ + private function parseUploadedFiles(Request $request): Request { + $bodyParams = $request->post(); + if (!isset($bodyParams['map'])) { + throw new RequestException('The request must define a `map`', $request); + } + $map = json_decode($bodyParams['map'], true); + $result = json_decode($bodyParams['operations'], true); + if (isset($result['operationName'])) { + $result['operation'] = $result['operationName']; + unset($result['operationName']); + } + foreach ($map as $fileKey => $locations) { + foreach ($locations as $location) { + $items = &$result; + foreach (explode('.', $location) as $key) { + if (!isset($items[$key]) || !is_array($items[$key])) { + $items[$key] = []; + } + $items = &$items[$key]; + } + $items = $request->file($fileKey); + } + } + $request->headers->set('content-type', 'application/json'); + $request->json()->replace($result); + return $request; + } + + /** + * Validates that the request meet our expectations + * + * @param Request $request + */ + private function validateParsedBody(Request $request): void { + $bodyParams = $request->post(); + if (null === $bodyParams) { + throw new RequestException( + 'PSR-7 request is expected to provide parsed body for "multipart/form-data" requests but got null', + $request + ); + } + if (!is_array($bodyParams)) { + throw new RequestException( + 'GraphQL Server expects JSON object or array, but got something unexpected', + $request + ); + } + if (empty($bodyParams)) { + throw new RequestException( + 'PSR-7 request is expected to provide parsed body for "multipart/form-data" requests but got empty array', + $request + ); + } + } } From a8f3a7f46140fa595ddfd999860c958f7ef0390a Mon Sep 17 00:00:00 2001 From: lorado Date: Sun, 20 Jan 2019 04:12:24 +0100 Subject: [PATCH 2/2] add file upload section to readme --- README.md | 56 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/README.md b/README.md index cf1debb..7150235 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ php artisan vendor:publish --provider="StudioNet\GraphQL\ServiceProvider" - [Definition](#definition) - [Query](#query) - [Mutation](#mutation) +- [File Upload](#file-upload) - [Pipeline](#pipeline) - [Require authorization](#require-authorization) - [Self documentation](#self-documentation) @@ -332,6 +333,61 @@ return [ ]; ``` +### File Upload + +This library supports [graphql-multipart-request-spec](https://github.com/jaydenseric/graphql-multipart-request-spec). +So if you use some graphql client, that is able to do such requests, you can upload files with your graphql mutations. +E.g. apollo has an [apollo-upload-client link](https://github.com/jaydenseric/apollo-upload-client) that you can use +instead of a simple apollo-http-link. Apollo will then automatically creates multipart form, if you pass a File reference +or Blob to variables. + +Implementation was inspired by [graphql-upload](https://github.com/Ecodev/graphql-upload) and adopted to work with laravel. + +Here is an example how you can define upload file field in your mutation and store uploaded file: + +```php + ['type' => Type::nonNull(Type::upload())], + ]; + } + + /** + * Return logged user + * + */ + public function getResolver($opts) + { + /** @var UploadedFile $file */ + $file = $opts['args']['file']; + // store uploaded file to uploads folder on default storage disk + return $file->store('uploads'); + } +} +``` + ### Pipeline Pipeline are used to convert a definition into queryable and mutable operations.