Skip to content

Feature: file upload #37

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
<?php
namespace App\GraphQL\Mutation;

use Illuminate\Http\UploadedFile;
use StudioNet\GraphQL\Definition\Type;
use StudioNet\GraphQL\Support\Definition\Mutation;

class TestUpload extends Mutation
{
/**
* {@inheritDoc}
*/
public function getRelatedType()
{
return Type::string();
}

/**
* {@inheritDoc}
*/
public function getArguments()
{
return [
// define file argument of type Upload
'file' => ['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.
Expand Down
5 changes: 3 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
13 changes: 13 additions & 0 deletions src/Definition/Type.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ abstract class Type extends GraphQLType {
/** PAGINATION */
const PAGINATION = 'Pagination';

/** UPLOAD */
const UPLOAD = 'Upload';

/** @var array $cache */
protected static $cache = null;

Expand Down Expand Up @@ -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`
*
Expand All @@ -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(),
];
}

Expand Down
63 changes: 63 additions & 0 deletions src/Definition/UploadType.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);

namespace StudioNet\GraphQL\Definition;

use GraphQL\Error\Error;
use GraphQL\Error\InvariantViolation;
use GraphQL\Type\Definition\ScalarType;
use GraphQL\Utils\Utils;
use Illuminate\Http\UploadedFile;

class UploadType extends ScalarType {
/**
* @var string
*/
public $name = 'Upload';
/**
* @var string
*/
public $description = 'The `Upload` 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
* @SuppressWarnings(PHPMD.UnusedFormalParameter)
*/
public function serialize($value) {
throw new InvariantViolation('`Upload` cannot be serialized');
}

/**
* Parses an externally provided value (query variable) to use as an input
*
* @param mixed $value
*
* @return mixed
*/
public function parseValue($value) {
if (!$value instanceof UploadedFile) {
throw new \UnexpectedValueException('Could not get uploaded file, be sure to conform to GraphQL multipart request specification. Instead got: ' . Utils::printSafe($value));
}

return $value;
}

/**
* Parses an externally provided literal value (hardcoded in GraphQL query) to use as an input
*
* @param \GraphQL\Language\AST\Node $valueNode
* @param null|array $variables
*
* @throws Error
* @return mixed
* @SuppressWarnings(PHPMD.UnusedFormalParameter)
*/
public function parseLiteral($valueNode, array $variables = null) {
throw new Error('`Upload` cannot be hardcoded in query, be sure to conform to GraphQL multipart request specification. Instead got: ' . $valueNode->kind, [$valueNode]);
}
}
71 changes: 71 additions & 0 deletions src/GraphQLController.php
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<?php
namespace StudioNet\GraphQL;

use Http\Client\Exception\RequestException;
use Illuminate\Routing\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
Expand All @@ -22,7 +23,14 @@ class GraphQLController extends Controller {
* @return \Illuminate\Http\JsonResponse
*/
public function query(Request $request, $schema = null) {
// if we request is a multipart/form-data, parse given files
$contentType = $request->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
Expand Down Expand Up @@ -105,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
);
}
}
}