Skip to content
This repository has been archived by the owner on Dec 19, 2023. It is now read-only.

Commit

Permalink
Merge pull request #712 from tmkhanh/feature/graphql-test-template-fi…
Browse files Browse the repository at this point in the history
…le-upload-support

Function for GraphQLTestTemplate to upload files using Upload scalar
  • Loading branch information
oliemansm authored Jan 22, 2022
2 parents 79906f9 + ce315af commit 39bba96
Show file tree
Hide file tree
Showing 7 changed files with 236 additions and 2 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,20 @@
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.function.IntFunction;
import lombok.Getter;
import lombok.NonNull;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource;
import org.springframework.core.io.ResourceLoader;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.ResponseEntity;
import org.springframework.lang.Nullable;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.util.StreamUtils;

Expand Down Expand Up @@ -239,13 +242,33 @@ public GraphQLResponse perform(
ObjectNode variables,
List<String> fragmentResources)
throws IOException {
String payload = getPayload(graphqlResource, operationName, variables, fragmentResources);
return post(payload);
}

/**
* Generate GraphQL payload, which consist of 3 elements: query, operationName and variables
*
* @param graphqlResource path to the classpath resource containing the GraphQL query
* @param operationName the name of the GraphQL operation to be executed
* @param variables the input variables for the GraphQL query
* @param fragmentResources an ordered list of classpath resources containing GraphQL fragment
* definitions.
* @return the payload
* @throws IOException if the resource cannot be loaded from the classpath
*/
private String getPayload(
String graphqlResource,
String operationName,
ObjectNode variables,
List<String> fragmentResources)
throws IOException {
StringBuilder sb = new StringBuilder();
for (String fragmentResource : fragmentResources) {
sb.append(loadQuery(fragmentResource));
}
String graphql = sb.append(loadQuery(graphqlResource)).toString();
String payload = createJsonQuery(graphql, operationName, variables);
return post(payload);
return createJsonQuery(graphql, operationName, variables);
}

/**
Expand Down Expand Up @@ -279,6 +302,115 @@ public GraphQLResponse postMultipart(String query, String variables) {
return postRequest(RequestFactory.forMultipart(query, variables, headers));
}

/**
* Handle the multipart files upload request to GraphQL servlet
*
* <p>In contrast with usual the GraphQL request with body as json payload (consist of query,
* operationName and variables), multipart file upload request will use multipart/form-data body
* with the following structure:
*
* <ul>
* <li><b>operations</b> the payload that we used to use for the normal GraphQL request
* <li><b>map</b> a map for referencing between one part of multi-part request and the
* corresponding <i>Upload</i> element inside <i>variables</i>
* <li>a consequence of upload files embedded into the multi-part request, keyed as numeric
* number starting from 1, valued as File payload of usual multipart file upload
* </ul>
*
* <p>Example uploading two files:
*
* <p>* Please note that we can't embed binary data into json. Clients library supporting graphql
* file upload will set variable.files to null for every element inside the array, but each file
* will be a part of multipart request. GraphQL Servlet will use <i>map</i> part to walk through
* variables.files and validate the request in combination with other binary file parts
*
* <p>----------------------------dummyid
*
* <p>Content-Disposition: form-data; name="operations"
*
* <p>{ "query": "mutation($files:[Upload]!) {uploadFiles(files:$files)}", "operationName":
* "uploadFiles", "variables": { "files": [null, null] } }
*
* <p>----------------------------dummyid
*
* <p>Content-Disposition: form-data; name="map"
*
* <p>map: { "1":["variables.files.0"], "2":["variables.files.1"] }
*
* <p>----------------------------dummyid
*
* <p>Content-Disposition: form-data; name="1"; filename="file1.pdf"
*
* <p>Content-Type: application/octet-stream
*
* <p>--file 1 binary code--
*
* <p>----------------------------dummyid
*
* <p>Content-Disposition: form-data; name="2"; filename="file2.pdf"
*
* <p>Content-Type: application/octet-stream
*
* <p>2: --file 2 binary code--
*
* <p>
*
* @param graphqlResource path to the classpath resource containing the GraphQL query
* @param variables the input variables for the GraphQL query
* @param files ClassPathResource instance for each file that will be uploaded to GraphQL server.
* When Spring RestTemplate processes the request, it will automatically produce a valid part
* representing given file inside multipart request (including size, submittedFileName, etc.)
* @return {@link GraphQLResponse} containing the result of query execution
* @throws IOException if the resource cannot be loaded from the classpath
*/
public GraphQLResponse postFiles(
String graphqlResource, ObjectNode variables, List<ClassPathResource> files)
throws IOException {

return postFiles(
graphqlResource, variables, files, index -> String.format("variables.files.%d", index));
}

/**
* Handle the multipart files upload request to GraphQL servlet
*
* @param graphqlResource path to the classpath resource containing the GraphQL query
* @param variables the input variables for the GraphQL query
* @param files ClassPathResource instance for each file that will be uploaded to GraphQL server.
* When Spring RestTemplate processes the request, it will automatically produce a valid part
* representing given file inside multipart request (including size, submittedFileName, etc.)
* @param pathFunc function to generate the path to file inside variables. For example:
* <ul>
* <li>index -> String.format("variables.files.%d", index) for multiple files
* <li>index -> "variables.file" for single file
* </ul>
*
* @return {@link GraphQLResponse} containing the result of query execution
* @throws IOException if the resource cannot be loaded from the classpath
*/
public GraphQLResponse postFiles(
String graphqlResource,
ObjectNode variables,
List<ClassPathResource> files,
IntFunction<String> pathFunc)
throws IOException {
MultiValueMap<String, Object> values = new LinkedMultiValueMap<>();
MultiValueMap<String, Object> map = new LinkedMultiValueMap<>();

for (int i = 0; i < files.size(); i++) {
String valueKey = String.valueOf(i + 1); // map value and part index starts at 1
map.add(valueKey, pathFunc.apply(i));

values.add(valueKey, files.get(i));
}

String payload = getPayload(graphqlResource, null, variables, Collections.emptyList());
values.add("operations", payload);
values.add("map", map);

return postRequest(RequestFactory.forMultipart(values, headers));
}

/**
* Performs a GraphQL request with the provided payload.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;

class RequestFactory {

Expand All @@ -23,4 +24,10 @@ static HttpEntity<Object> forMultipart(String query, String variables, HttpHeade
values.add("variables", forJson(variables, new HttpHeaders()));
return new HttpEntity<>(values, headers);
}

static HttpEntity<Object> forMultipart(
MultiValueMap<String, Object> values, HttpHeaders headers) {
headers.setContentType(MediaType.MULTIPART_FORM_DATA);
return new HttpEntity<>(values, headers);
}
}
Original file line number Diff line number Diff line change
@@ -1,18 +1,23 @@
package com.graphql.spring.boot.test;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.graphql.spring.boot.test.beans.FooBar;
import graphql.GraphQLError;
import java.io.IOException;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.UUID;
import java.util.stream.Collectors;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.ResourceLoader;
import org.springframework.http.HttpHeaders;

Expand All @@ -26,9 +31,13 @@ class GraphQLTestTemplateIntegrationTest {
private static final String QUERY_WITH_VARIABLES = "query-with-variables.graphql";
private static final String COMPLEX_TEST_QUERY = "complex-query.graphql";
private static final String MULTIPLE_QUERIES = "multiple-queries.graphql";
private static final String UPLOAD_FILES_MUTATION = "upload-files.graphql";
private static final String UPLOAD_FILE_MUTATION = "upload-file.graphql";
private static final String INPUT_STRING_VALUE = "input-value";
private static final String INPUT_STRING_NAME = "input";
private static final String INPUT_HEADER_NAME = "headerName";
private static final String FILES_STRING_NAME = "files";
private static final String UPLOADING_FILE_STRING_NAME = "uploadingFile";
private static final String TEST_HEADER_NAME = "x-test";
private static final String TEST_HEADER_VALUE = String.valueOf(UUID.randomUUID());
private static final String FOO = "FOO";
Expand All @@ -39,6 +48,8 @@ class GraphQLTestTemplateIntegrationTest {
private static final String DATA_FIELD_OTHER_QUERY = "$.data.otherQuery";
private static final String DATA_FIELD_QUERY_WITH_HEADER = "$.data.queryWithHeader";
private static final String DATA_FIELD_DUMMY = "$.data.dummy";
private static final String DATA_FILE_UPLOAD_FILES = "$.data.uploadFiles";
private static final String DATA_FILE_UPLOAD_FILE = "$.data.uploadFile";
private static final String OPERATION_NAME_WITH_VARIABLES = "withVariable";
private static final String OPERATION_NAME_TEST_QUERY_1 = "testQuery1";
private static final String OPERATION_NAME_TEST_QUERY_2 = "testQuery2";
Expand Down Expand Up @@ -224,4 +235,43 @@ void testPost() {
.asString()
.isEqualTo(TEST_HEADER_VALUE);
}

@Test
@DisplayName("Test perform with file uploads.")
void testPerformWithFileUploads() throws IOException {
// GIVEN
final ObjectNode variables = objectMapper.createObjectNode();
ArrayNode nodes = objectMapper.valueToTree(Arrays.asList(null, null));
variables.putArray(FILES_STRING_NAME).addAll(nodes);

List<String> fileNames = Arrays.asList("multiple-queries.graphql", "simple-test-query.graphql");
List<ClassPathResource> testUploadFiles =
fileNames.stream().map(ClassPathResource::new).collect(Collectors.toList());
// WHEN - THEN
graphQLTestTemplate
.postFiles(UPLOAD_FILES_MUTATION, variables, testUploadFiles)
.assertThatNoErrorsArePresent()
.assertThatField(DATA_FILE_UPLOAD_FILES)
.asListOf(String.class)
.isEqualTo(fileNames);
}

@Test
@DisplayName("Test perform with individual file upload and custom path.")
void testPerformWithIndividualFileUpload() throws IOException {
// GIVEN
final ObjectNode variables = objectMapper.createObjectNode();
variables.put(UPLOADING_FILE_STRING_NAME, objectMapper.valueToTree(null));

List<String> fileNames = Arrays.asList("multiple-queries.graphql");
List<ClassPathResource> testUploadFiles =
fileNames.stream().map(ClassPathResource::new).collect(Collectors.toList());
// WHEN - THEN
graphQLTestTemplate
.postFiles(UPLOAD_FILE_MUTATION, variables, testUploadFiles, index -> "variables.file")
.assertThatNoErrorsArePresent()
.assertThatField(DATA_FILE_UPLOAD_FILE)
.asString()
.isEqualTo(fileNames.get(0));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package com.graphql.spring.boot.test.beans;

import graphql.kickstart.servlet.apollo.ApolloScalars;
import graphql.kickstart.tools.GraphQLMutationResolver;
import graphql.schema.DataFetchingEnvironment;
import graphql.schema.GraphQLScalarType;
import java.util.List;
import java.util.stream.Collectors;
import javax.servlet.http.Part;
import org.springframework.context.annotation.Bean;
import org.springframework.stereotype.Service;

@Service
public class DummyMutation implements GraphQLMutationResolver {

@Bean
private GraphQLScalarType getUploadScalar() {
// since the test doesn't inject this built-in Scalar,
// so we inject here for test run purpose
return ApolloScalars.Upload;
}

public List<String> uploadFiles(List<Part> files, DataFetchingEnvironment env) {
List<Part> actualFiles = env.getArgument("files");
return actualFiles.stream().map(Part::getSubmittedFileName).collect(Collectors.toList());
}

public String uploadFile(Part file, DataFetchingEnvironment env) {
Part actualFile = env.getArgument("file");
return actualFile.getSubmittedFileName();
}
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
scalar Upload

type FooBar {
foo: String!
bar: String!
Expand All @@ -17,4 +19,9 @@ type Query {
fooBar(foo: String, bar: String): FooBar!
queryWithVariables(input: String!): String!
queryWithHeader(headerName: String!): String
}

type Mutation {
uploadFiles(files: [Upload]!): [String!]
uploadFile(file: Upload): String!
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
mutation($file: Upload) {
uploadFile(file: $file)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
mutation($files: [Upload]!) {
uploadFiles(files: $files)
}

0 comments on commit 39bba96

Please sign in to comment.