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

Commit 39bba96

Browse files
authored
Merge pull request #712 from tmkhanh/feature/graphql-test-template-file-upload-support
Function for GraphQLTestTemplate to upload files using Upload scalar
2 parents 79906f9 + ce315af commit 39bba96

File tree

7 files changed

+236
-2
lines changed

7 files changed

+236
-2
lines changed

graphql-spring-boot-test/src/main/java/com/graphql/spring/boot/test/GraphQLTestTemplate.java

Lines changed: 134 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,17 +12,20 @@
1212
import java.util.Arrays;
1313
import java.util.Collections;
1414
import java.util.List;
15+
import java.util.function.IntFunction;
1516
import lombok.Getter;
1617
import lombok.NonNull;
1718
import org.springframework.beans.factory.annotation.Value;
1819
import org.springframework.boot.test.web.client.TestRestTemplate;
20+
import org.springframework.core.io.ClassPathResource;
1921
import org.springframework.core.io.Resource;
2022
import org.springframework.core.io.ResourceLoader;
2123
import org.springframework.http.HttpEntity;
2224
import org.springframework.http.HttpHeaders;
2325
import org.springframework.http.HttpMethod;
2426
import org.springframework.http.ResponseEntity;
2527
import org.springframework.lang.Nullable;
28+
import org.springframework.util.LinkedMultiValueMap;
2629
import org.springframework.util.MultiValueMap;
2730
import org.springframework.util.StreamUtils;
2831

@@ -239,13 +242,33 @@ public GraphQLResponse perform(
239242
ObjectNode variables,
240243
List<String> fragmentResources)
241244
throws IOException {
245+
String payload = getPayload(graphqlResource, operationName, variables, fragmentResources);
246+
return post(payload);
247+
}
248+
249+
/**
250+
* Generate GraphQL payload, which consist of 3 elements: query, operationName and variables
251+
*
252+
* @param graphqlResource path to the classpath resource containing the GraphQL query
253+
* @param operationName the name of the GraphQL operation to be executed
254+
* @param variables the input variables for the GraphQL query
255+
* @param fragmentResources an ordered list of classpath resources containing GraphQL fragment
256+
* definitions.
257+
* @return the payload
258+
* @throws IOException if the resource cannot be loaded from the classpath
259+
*/
260+
private String getPayload(
261+
String graphqlResource,
262+
String operationName,
263+
ObjectNode variables,
264+
List<String> fragmentResources)
265+
throws IOException {
242266
StringBuilder sb = new StringBuilder();
243267
for (String fragmentResource : fragmentResources) {
244268
sb.append(loadQuery(fragmentResource));
245269
}
246270
String graphql = sb.append(loadQuery(graphqlResource)).toString();
247-
String payload = createJsonQuery(graphql, operationName, variables);
248-
return post(payload);
271+
return createJsonQuery(graphql, operationName, variables);
249272
}
250273

251274
/**
@@ -279,6 +302,115 @@ public GraphQLResponse postMultipart(String query, String variables) {
279302
return postRequest(RequestFactory.forMultipart(query, variables, headers));
280303
}
281304

305+
/**
306+
* Handle the multipart files upload request to GraphQL servlet
307+
*
308+
* <p>In contrast with usual the GraphQL request with body as json payload (consist of query,
309+
* operationName and variables), multipart file upload request will use multipart/form-data body
310+
* with the following structure:
311+
*
312+
* <ul>
313+
* <li><b>operations</b> the payload that we used to use for the normal GraphQL request
314+
* <li><b>map</b> a map for referencing between one part of multi-part request and the
315+
* corresponding <i>Upload</i> element inside <i>variables</i>
316+
* <li>a consequence of upload files embedded into the multi-part request, keyed as numeric
317+
* number starting from 1, valued as File payload of usual multipart file upload
318+
* </ul>
319+
*
320+
* <p>Example uploading two files:
321+
*
322+
* <p>* Please note that we can't embed binary data into json. Clients library supporting graphql
323+
* file upload will set variable.files to null for every element inside the array, but each file
324+
* will be a part of multipart request. GraphQL Servlet will use <i>map</i> part to walk through
325+
* variables.files and validate the request in combination with other binary file parts
326+
*
327+
* <p>----------------------------dummyid
328+
*
329+
* <p>Content-Disposition: form-data; name="operations"
330+
*
331+
* <p>{ "query": "mutation($files:[Upload]!) {uploadFiles(files:$files)}", "operationName":
332+
* "uploadFiles", "variables": { "files": [null, null] } }
333+
*
334+
* <p>----------------------------dummyid
335+
*
336+
* <p>Content-Disposition: form-data; name="map"
337+
*
338+
* <p>map: { "1":["variables.files.0"], "2":["variables.files.1"] }
339+
*
340+
* <p>----------------------------dummyid
341+
*
342+
* <p>Content-Disposition: form-data; name="1"; filename="file1.pdf"
343+
*
344+
* <p>Content-Type: application/octet-stream
345+
*
346+
* <p>--file 1 binary code--
347+
*
348+
* <p>----------------------------dummyid
349+
*
350+
* <p>Content-Disposition: form-data; name="2"; filename="file2.pdf"
351+
*
352+
* <p>Content-Type: application/octet-stream
353+
*
354+
* <p>2: --file 2 binary code--
355+
*
356+
* <p>
357+
*
358+
* @param graphqlResource path to the classpath resource containing the GraphQL query
359+
* @param variables the input variables for the GraphQL query
360+
* @param files ClassPathResource instance for each file that will be uploaded to GraphQL server.
361+
* When Spring RestTemplate processes the request, it will automatically produce a valid part
362+
* representing given file inside multipart request (including size, submittedFileName, etc.)
363+
* @return {@link GraphQLResponse} containing the result of query execution
364+
* @throws IOException if the resource cannot be loaded from the classpath
365+
*/
366+
public GraphQLResponse postFiles(
367+
String graphqlResource, ObjectNode variables, List<ClassPathResource> files)
368+
throws IOException {
369+
370+
return postFiles(
371+
graphqlResource, variables, files, index -> String.format("variables.files.%d", index));
372+
}
373+
374+
/**
375+
* Handle the multipart files upload request to GraphQL servlet
376+
*
377+
* @param graphqlResource path to the classpath resource containing the GraphQL query
378+
* @param variables the input variables for the GraphQL query
379+
* @param files ClassPathResource instance for each file that will be uploaded to GraphQL server.
380+
* When Spring RestTemplate processes the request, it will automatically produce a valid part
381+
* representing given file inside multipart request (including size, submittedFileName, etc.)
382+
* @param pathFunc function to generate the path to file inside variables. For example:
383+
* <ul>
384+
* <li>index -> String.format("variables.files.%d", index) for multiple files
385+
* <li>index -> "variables.file" for single file
386+
* </ul>
387+
*
388+
* @return {@link GraphQLResponse} containing the result of query execution
389+
* @throws IOException if the resource cannot be loaded from the classpath
390+
*/
391+
public GraphQLResponse postFiles(
392+
String graphqlResource,
393+
ObjectNode variables,
394+
List<ClassPathResource> files,
395+
IntFunction<String> pathFunc)
396+
throws IOException {
397+
MultiValueMap<String, Object> values = new LinkedMultiValueMap<>();
398+
MultiValueMap<String, Object> map = new LinkedMultiValueMap<>();
399+
400+
for (int i = 0; i < files.size(); i++) {
401+
String valueKey = String.valueOf(i + 1); // map value and part index starts at 1
402+
map.add(valueKey, pathFunc.apply(i));
403+
404+
values.add(valueKey, files.get(i));
405+
}
406+
407+
String payload = getPayload(graphqlResource, null, variables, Collections.emptyList());
408+
values.add("operations", payload);
409+
values.add("map", map);
410+
411+
return postRequest(RequestFactory.forMultipart(values, headers));
412+
}
413+
282414
/**
283415
* Performs a GraphQL request with the provided payload.
284416
*

graphql-spring-boot-test/src/main/java/com/graphql/spring/boot/test/RequestFactory.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import org.springframework.http.HttpHeaders;
55
import org.springframework.http.MediaType;
66
import org.springframework.util.LinkedMultiValueMap;
7+
import org.springframework.util.MultiValueMap;
78

89
class RequestFactory {
910

@@ -23,4 +24,10 @@ static HttpEntity<Object> forMultipart(String query, String variables, HttpHeade
2324
values.add("variables", forJson(variables, new HttpHeaders()));
2425
return new HttpEntity<>(values, headers);
2526
}
27+
28+
static HttpEntity<Object> forMultipart(
29+
MultiValueMap<String, Object> values, HttpHeaders headers) {
30+
headers.setContentType(MediaType.MULTIPART_FORM_DATA);
31+
return new HttpEntity<>(values, headers);
32+
}
2633
}

graphql-spring-boot-test/src/test/java/com/graphql/spring/boot/test/GraphQLTestTemplateIntegrationTest.java

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,23 @@
11
package com.graphql.spring.boot.test;
22

33
import com.fasterxml.jackson.databind.ObjectMapper;
4+
import com.fasterxml.jackson.databind.node.ArrayNode;
45
import com.fasterxml.jackson.databind.node.ObjectNode;
56
import com.graphql.spring.boot.test.beans.FooBar;
67
import graphql.GraphQLError;
78
import java.io.IOException;
9+
import java.util.Arrays;
810
import java.util.Collections;
11+
import java.util.List;
912
import java.util.UUID;
13+
import java.util.stream.Collectors;
1014
import org.junit.jupiter.api.BeforeEach;
1115
import org.junit.jupiter.api.DisplayName;
1216
import org.junit.jupiter.api.Test;
1317
import org.springframework.beans.factory.annotation.Autowired;
1418
import org.springframework.boot.test.context.SpringBootTest;
1519
import org.springframework.boot.test.web.client.TestRestTemplate;
20+
import org.springframework.core.io.ClassPathResource;
1621
import org.springframework.core.io.ResourceLoader;
1722
import org.springframework.http.HttpHeaders;
1823

@@ -26,9 +31,13 @@ class GraphQLTestTemplateIntegrationTest {
2631
private static final String QUERY_WITH_VARIABLES = "query-with-variables.graphql";
2732
private static final String COMPLEX_TEST_QUERY = "complex-query.graphql";
2833
private static final String MULTIPLE_QUERIES = "multiple-queries.graphql";
34+
private static final String UPLOAD_FILES_MUTATION = "upload-files.graphql";
35+
private static final String UPLOAD_FILE_MUTATION = "upload-file.graphql";
2936
private static final String INPUT_STRING_VALUE = "input-value";
3037
private static final String INPUT_STRING_NAME = "input";
3138
private static final String INPUT_HEADER_NAME = "headerName";
39+
private static final String FILES_STRING_NAME = "files";
40+
private static final String UPLOADING_FILE_STRING_NAME = "uploadingFile";
3241
private static final String TEST_HEADER_NAME = "x-test";
3342
private static final String TEST_HEADER_VALUE = String.valueOf(UUID.randomUUID());
3443
private static final String FOO = "FOO";
@@ -39,6 +48,8 @@ class GraphQLTestTemplateIntegrationTest {
3948
private static final String DATA_FIELD_OTHER_QUERY = "$.data.otherQuery";
4049
private static final String DATA_FIELD_QUERY_WITH_HEADER = "$.data.queryWithHeader";
4150
private static final String DATA_FIELD_DUMMY = "$.data.dummy";
51+
private static final String DATA_FILE_UPLOAD_FILES = "$.data.uploadFiles";
52+
private static final String DATA_FILE_UPLOAD_FILE = "$.data.uploadFile";
4253
private static final String OPERATION_NAME_WITH_VARIABLES = "withVariable";
4354
private static final String OPERATION_NAME_TEST_QUERY_1 = "testQuery1";
4455
private static final String OPERATION_NAME_TEST_QUERY_2 = "testQuery2";
@@ -224,4 +235,43 @@ void testPost() {
224235
.asString()
225236
.isEqualTo(TEST_HEADER_VALUE);
226237
}
238+
239+
@Test
240+
@DisplayName("Test perform with file uploads.")
241+
void testPerformWithFileUploads() throws IOException {
242+
// GIVEN
243+
final ObjectNode variables = objectMapper.createObjectNode();
244+
ArrayNode nodes = objectMapper.valueToTree(Arrays.asList(null, null));
245+
variables.putArray(FILES_STRING_NAME).addAll(nodes);
246+
247+
List<String> fileNames = Arrays.asList("multiple-queries.graphql", "simple-test-query.graphql");
248+
List<ClassPathResource> testUploadFiles =
249+
fileNames.stream().map(ClassPathResource::new).collect(Collectors.toList());
250+
// WHEN - THEN
251+
graphQLTestTemplate
252+
.postFiles(UPLOAD_FILES_MUTATION, variables, testUploadFiles)
253+
.assertThatNoErrorsArePresent()
254+
.assertThatField(DATA_FILE_UPLOAD_FILES)
255+
.asListOf(String.class)
256+
.isEqualTo(fileNames);
257+
}
258+
259+
@Test
260+
@DisplayName("Test perform with individual file upload and custom path.")
261+
void testPerformWithIndividualFileUpload() throws IOException {
262+
// GIVEN
263+
final ObjectNode variables = objectMapper.createObjectNode();
264+
variables.put(UPLOADING_FILE_STRING_NAME, objectMapper.valueToTree(null));
265+
266+
List<String> fileNames = Arrays.asList("multiple-queries.graphql");
267+
List<ClassPathResource> testUploadFiles =
268+
fileNames.stream().map(ClassPathResource::new).collect(Collectors.toList());
269+
// WHEN - THEN
270+
graphQLTestTemplate
271+
.postFiles(UPLOAD_FILE_MUTATION, variables, testUploadFiles, index -> "variables.file")
272+
.assertThatNoErrorsArePresent()
273+
.assertThatField(DATA_FILE_UPLOAD_FILE)
274+
.asString()
275+
.isEqualTo(fileNames.get(0));
276+
}
227277
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package com.graphql.spring.boot.test.beans;
2+
3+
import graphql.kickstart.servlet.apollo.ApolloScalars;
4+
import graphql.kickstart.tools.GraphQLMutationResolver;
5+
import graphql.schema.DataFetchingEnvironment;
6+
import graphql.schema.GraphQLScalarType;
7+
import java.util.List;
8+
import java.util.stream.Collectors;
9+
import javax.servlet.http.Part;
10+
import org.springframework.context.annotation.Bean;
11+
import org.springframework.stereotype.Service;
12+
13+
@Service
14+
public class DummyMutation implements GraphQLMutationResolver {
15+
16+
@Bean
17+
private GraphQLScalarType getUploadScalar() {
18+
// since the test doesn't inject this built-in Scalar,
19+
// so we inject here for test run purpose
20+
return ApolloScalars.Upload;
21+
}
22+
23+
public List<String> uploadFiles(List<Part> files, DataFetchingEnvironment env) {
24+
List<Part> actualFiles = env.getArgument("files");
25+
return actualFiles.stream().map(Part::getSubmittedFileName).collect(Collectors.toList());
26+
}
27+
28+
public String uploadFile(Part file, DataFetchingEnvironment env) {
29+
Part actualFile = env.getArgument("file");
30+
return actualFile.getSubmittedFileName();
31+
}
32+
}

graphql-spring-boot-test/src/test/resources/test-schema.graphqls

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
scalar Upload
2+
13
type FooBar {
24
foo: String!
35
bar: String!
@@ -17,4 +19,9 @@ type Query {
1719
fooBar(foo: String, bar: String): FooBar!
1820
queryWithVariables(input: String!): String!
1921
queryWithHeader(headerName: String!): String
22+
}
23+
24+
type Mutation {
25+
uploadFiles(files: [Upload]!): [String!]
26+
uploadFile(file: Upload): String!
2027
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
mutation($file: Upload) {
2+
uploadFile(file: $file)
3+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
mutation($files: [Upload]!) {
2+
uploadFiles(files: $files)
3+
}

0 commit comments

Comments
 (0)