Skip to content

Commit

Permalink
Merge 3808419 into 32a2f62
Browse files Browse the repository at this point in the history
  • Loading branch information
mahmoodbazdar committed Sep 7, 2019
2 parents 32a2f62 + 3808419 commit 074cd54
Show file tree
Hide file tree
Showing 14 changed files with 510 additions and 15 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Expand Up @@ -13,6 +13,7 @@
* GraphQL: Improve serialization performance by avoiding calls to the `serialize` PHP function
* GraphQL: Allow to use a search and an exist filter on the same resource
* GraphQL: Refactor the architecture of the whole system to allow the decoration of useful services (`TypeConverter` to manage custom types, `SerializerContextBuilder` to modify the (de)serialization context dynamically, etc.)
* GraphQL: Add support for multipart form request so user can create custom file upload mutations

## 2.4.7

Expand Down
1 change: 1 addition & 0 deletions behat.yml.dist
Expand Up @@ -30,6 +30,7 @@ default:
bootstrap: 'tests/Fixtures/app/bootstrap.php'
'Behat\MinkExtension':
base_url: 'http://example.com/'
files_path: 'features/files'
sessions:
default:
symfony2: ~
Expand Down
40 changes: 40 additions & 0 deletions features/bootstrap/GraphqlContext.php
Expand Up @@ -15,6 +15,7 @@
use Behat\Behat\Context\Environment\InitializedContextEnvironment;
use Behat\Behat\Hook\Scope\BeforeScenarioScope;
use Behat\Gherkin\Node\PyStringNode;
use Behat\Gherkin\Node\TableNode;
use Behatch\Context\RestContext;
use Behatch\HttpCall\Request;
use GraphQL\Type\Introspection;
Expand Down Expand Up @@ -97,6 +98,45 @@ public function ISendTheGraphqlRequestWithOperation(string $operation)
$this->sendGraphqlRequest();
}

/**
* @Given I have the following files for a GraphQL request:
*/
public function iHaveTheFollowingFilesForAGraphqlRequest(TableNode $table)
{
$files = [];

foreach ($table->getHash() as $row) {
if (!isset($row['name']) || !isset($row['file'])) {
throw new \Exception("You must provide a 'name' and 'file' column in your table node.");
}

$files[$row['name']] = $this->restContext->getMinkParameter('files_path').DIRECTORY_SEPARATOR.$row['file'];
}

$this->graphqlRequest['files'] = $files;
}

/**
* @Given I have the following GraphQL multipart form map:
*/
public function iHaveTheFollowingGraphqlMultipartFormMap(PyStringNode $string)
{
$this->graphqlRequest['map'] = $string->getRaw();
}

/**
* @When I send the following GraphQL multipart form operations:
*/
public function iSendTheFollowingGraphqlMultipartFormOperations(PyStringNode $string)
{
$params = [];
$params['operations'] = $string->getRaw();
$params['map'] = $this->graphqlRequest['map'];

$this->request->setHttpHeader('Content-type', 'multipart/form-data');
$this->request->send('POST', '/graphql', $params, $this->graphqlRequest['files']);
}

/**
* @When I send the query to introspect the schema
*/
Expand Down
Binary file added features/files/test.gif
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
57 changes: 57 additions & 0 deletions features/graphql/mutation.feature
@@ -1,4 +1,5 @@
Feature: GraphQL mutation support

@createSchema
Scenario: Introspect types
When I send the following GraphQL request:
Expand Down Expand Up @@ -420,3 +421,59 @@ Feature: GraphQL mutation support
And the header "Content-Type" should be equal to "application/json"
And the JSON node "data.testCustomArgumentsDummyCustomMutation.dummyCustomMutation.result" should be equal to "18"
And the JSON node "data.testCustomArgumentsDummyCustomMutation.clientMutationId" should be equal to "myId"

@!mongodb
Scenario: Uploading a file with custom mutation
Given I have the following files for a GraphQL request:
| name | file |
| file | test.gif |
And I have the following GraphQL multipart form map:
"""
{
"file":["variables.file"]
}
"""
When I send the following GraphQL multipart form operations:
"""
{
"query":"mutation($file:Upload!){ uploadMediaObject(input:{file:$file}){ mediaObject{ id contentUrl } } }",
"variables":{
"file": null
}
}
"""
Then the response status code should be 200
And the response should be in JSON
And the JSON node "data.uploadMediaObject.mediaObject.contentUrl" should be equal to "test.gif"

@!mongodb
Scenario: Uploading multiple files with custom mutation
Given I have the following files for a GraphQL request:
| name | file |
| 0 | test.gif |
| 1 | test.gif |
| 2 | test.gif |
And I have the following GraphQL multipart form map:
"""
{
"0": ["variables.files.0"],
"1": ["variables.files.1"],
"2": ["variables.files.2"]
}
"""
When I send the following GraphQl multipart form operations:
"""
{
"query":"mutation($files:[Upload!]!){ uploadMultipleMediaObject(input:{files:$files}){ mediaObject{ id contentUrl } } }",
"variables":{
"files": [
null,
null,
null
]
}
}
"""
Then the response status code should be 200
And the JSON node "data.uploadMultipleMediaObject.mediaObject.contentUrl" should be equal to "test.gif"

1 change: 1 addition & 0 deletions src/Bridge/Symfony/Bundle/Resources/config/graphql.xml
Expand Up @@ -160,6 +160,7 @@
<argument type="service" id="api_platform.graphql.executor" />
<argument type="service" id="api_platform.graphql.action.graphiql" />
<argument type="service" id="api_platform.graphql.action.graphql_playground" />
<argument type="service" id="property_accessor" />
<argument>%kernel.debug%</argument>
<argument>%api_platform.graphql.graphiql.enabled%</argument>
<argument>%api_platform.graphql.graphql_playground.enabled%</argument>
Expand Down
78 changes: 65 additions & 13 deletions src/GraphQl/Action/EntrypointAction.php
Expand Up @@ -21,6 +21,7 @@
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;

/**
* GraphQL API entrypoint.
Expand All @@ -37,8 +38,9 @@ final class EntrypointAction
private $graphiqlEnabled;
private $graphQlPlaygroundEnabled;
private $defaultIde;
private $propertyAccessor;

public function __construct(SchemaBuilderInterface $schemaBuilder, ExecutorInterface $executor, GraphiQlAction $graphiQlAction, GraphQlPlaygroundAction $graphQlPlaygroundAction, bool $debug = false, bool $graphiqlEnabled = false, bool $graphQlPlaygroundEnabled = false, $defaultIde = false)
public function __construct(SchemaBuilderInterface $schemaBuilder, ExecutorInterface $executor, GraphiQlAction $graphiQlAction, GraphQlPlaygroundAction $graphQlPlaygroundAction, PropertyAccessorInterface $propertyAccessor, bool $debug = false, bool $graphiqlEnabled = false, bool $graphQlPlaygroundEnabled = false, $defaultIde = false)
{
$this->schemaBuilder = $schemaBuilder;
$this->executor = $executor;
Expand All @@ -48,6 +50,7 @@ public function __construct(SchemaBuilderInterface $schemaBuilder, ExecutorInter
$this->graphiqlEnabled = $graphiqlEnabled;
$this->graphQlPlaygroundEnabled = $graphQlPlaygroundEnabled;
$this->defaultIde = $defaultIde;
$this->propertyAccessor = $propertyAccessor;
}

public function __invoke(Request $request): Response
Expand All @@ -69,7 +72,7 @@ public function __invoke(Request $request): Response
}

if (null === $variables) {
return new JsonResponse(new ExecutionResult(null, [new Error('GraphQL variables are not valid JSON')]), Response::HTTP_BAD_REQUEST);
return new JsonResponse(new ExecutionResult(null, [new Error('GraphQL variables are not valid JSON or multipart form map does not match the variables')]), Response::HTTP_BAD_REQUEST);
}

try {
Expand All @@ -94,21 +97,19 @@ private function parseRequest(Request $request): array
}

if ('json' === $request->getContentType()) {
$input = json_decode($request->getContent(), true);
return $this->parseInput($query, $variables, $operation, $request->getContent());
}

if (isset($input['query'])) {
$query = $input['query'];
}
if (false !== mb_stripos($request->headers->get('CONTENT_TYPE'), 'multipart/form-data')) {
if ($request->request->has('operations')) {
[$query, $operation, $variables] = $this->parseInput($query, $variables, $operation, $request->request->get('operations'));

if (isset($input['variables'])) {
$variables = \is_array($input['variables']) ? $input['variables'] : json_decode($input['variables'], true);
}
if ($request->request->has('map')) {
$variables = $this->applyMapToVariables($request, $variables);
}

if (isset($input['operation'])) {
$operation = $input['operation'];
return [$query, $operation, $variables];
}

return [$query, $operation, $variables];
}

if ('application/graphql' === $request->headers->get('CONTENT_TYPE')) {
Expand All @@ -117,4 +118,55 @@ private function parseRequest(Request $request): array

return [$query, $operation, $variables];
}

private function parseInput(?string $query, ?array $variables, ?string $operation, string $jsonContent): array
{
$input = json_decode($jsonContent, true);

if (isset($input['query'])) {
$query = $input['query'];
}

if (isset($input['variables'])) {
$variables = \is_array($input['variables']) ? $input['variables'] : json_decode($input['variables'], true);
}

if (isset($input['operation'])) {
$operation = $input['operation'];
}

return [$query, $operation, $variables];
}

private function applyMapToVariables(Request $request, array $variables): ?array
{
$mapValues = json_decode($request->request->get('map'), true);
if (!$mapValues) {
return $variables;
}
foreach ($mapValues as $key => $value) {
if ($request->files->has($key)) {
foreach ($mapValues[$key] as $mapValue) {
$path = explode('.', $mapValue);
if ('variables' === $path[0]) {
unset($path[0]);

$mapPathExistsInVariables = array_reduce($path, function (array $arr, $idx) {
return (
\array_key_exists($idx, $arr)
) ? $arr[$idx] : false;
}, $variables);

if (false !== $mapPathExistsInVariables) {
$this->propertyAccessor->setValue($variables, '['.implode('][', $path).']', $request->files->get($key));
} else {
return null;
}
}
}
}
}

return $variables;
}
}
53 changes: 53 additions & 0 deletions tests/Fixtures/TestBundle/Entity/MediaObject.php
@@ -0,0 +1,53 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

declare(strict_types=1);

namespace ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity;

use ApiPlatform\Core\Annotation\ApiProperty;
use ApiPlatform\Core\Annotation\ApiResource;
use GraphQL\Type\Definition\Type;

/**
* Class MediaObject.
*
* @ApiResource(iri="http://schema.org/MediaObject",
* graphql={
* "upload"={
* "mutation"="app.graphql.mutation_resolver.upload_media_object",
* "args"={
* "file"={"type"="Upload!", "description"="File"}
* }
* },
* "uploadMultiple"={
* "mutation"="app.graphql.mutation_resolver.upload_multiple_media_object",
* "args"={
* "files"={"type"="[Upload!]!", "description"="Files"}
* }
* }
* }
* )
*
* @author Mahmood Bazdar <mahmood@bazdar.me>
*/
class MediaObject
{
/**
* @ApiProperty(identifier=true)
*/
public $id;

/**
* @var string
*/
public $contentUrl;
}
@@ -0,0 +1,43 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

declare(strict_types=1);

namespace ApiPlatform\Core\Tests\Fixtures\TestBundle\GraphQl\Resolver;

use ApiPlatform\Core\GraphQl\Resolver\MutationResolverInterface;
use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\MediaObject;

/**
* Resolver for custom file upload mutation.
*
* @author Mahmood Bazdar <mahmood@bazdar.me>
*/
class UploadMediaObjectResolver implements MutationResolverInterface
{
/**
* @param MediaObject|null $item
*/
public function __invoke($item, array $context): MediaObject
{
/**
* @var \Symfony\Component\HttpFoundation\File\UploadedFile
*/
$uploadedFile = $context['args']['input']['file'];
// doing some process for uploading the file

$uploadedMediaObject = new MediaObject();
$uploadedMediaObject->id = 1;
$uploadedMediaObject->contentUrl = $uploadedFile->getFileName();

return $uploadedMediaObject;
}
}

0 comments on commit 074cd54

Please sign in to comment.