diff --git a/graphql-spring-boot-test/src/main/java/com/graphql/spring/boot/test/GraphQLTestTemplate.java b/graphql-spring-boot-test/src/main/java/com/graphql/spring/boot/test/GraphQLTestTemplate.java index bd27ac05..8bbd79ce 100644 --- a/graphql-spring-boot-test/src/main/java/com/graphql/spring/boot/test/GraphQLTestTemplate.java +++ b/graphql-spring-boot-test/src/main/java/com/graphql/spring/boot/test/GraphQLTestTemplate.java @@ -12,10 +12,12 @@ 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; @@ -23,6 +25,7 @@ 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; @@ -239,13 +242,33 @@ public GraphQLResponse perform( ObjectNode variables, List 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 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); } /** @@ -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 + * + *

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: + * + *

+ * + *

Example uploading two files: + * + *

* 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 map part to walk through + * variables.files and validate the request in combination with other binary file parts + * + *

----------------------------dummyid + * + *

Content-Disposition: form-data; name="operations" + * + *

{ "query": "mutation($files:[Upload]!) {uploadFiles(files:$files)}", "operationName": + * "uploadFiles", "variables": { "files": [null, null] } } + * + *

----------------------------dummyid + * + *

Content-Disposition: form-data; name="map" + * + *

map: { "1":["variables.files.0"], "2":["variables.files.1"] } + * + *

----------------------------dummyid + * + *

Content-Disposition: form-data; name="1"; filename="file1.pdf" + * + *

Content-Type: application/octet-stream + * + *

--file 1 binary code-- + * + *

----------------------------dummyid + * + *

Content-Disposition: form-data; name="2"; filename="file2.pdf" + * + *

Content-Type: application/octet-stream + * + *

2: --file 2 binary code-- + * + *

+ * + * @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 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: + *

+ * + * @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 files, + IntFunction pathFunc) + throws IOException { + MultiValueMap values = new LinkedMultiValueMap<>(); + MultiValueMap 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. * diff --git a/graphql-spring-boot-test/src/main/java/com/graphql/spring/boot/test/RequestFactory.java b/graphql-spring-boot-test/src/main/java/com/graphql/spring/boot/test/RequestFactory.java index 64e7f3bf..aa997f6b 100644 --- a/graphql-spring-boot-test/src/main/java/com/graphql/spring/boot/test/RequestFactory.java +++ b/graphql-spring-boot-test/src/main/java/com/graphql/spring/boot/test/RequestFactory.java @@ -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 { @@ -23,4 +24,10 @@ static HttpEntity forMultipart(String query, String variables, HttpHeade values.add("variables", forJson(variables, new HttpHeaders())); return new HttpEntity<>(values, headers); } + + static HttpEntity forMultipart( + MultiValueMap values, HttpHeaders headers) { + headers.setContentType(MediaType.MULTIPART_FORM_DATA); + return new HttpEntity<>(values, headers); + } } diff --git a/graphql-spring-boot-test/src/test/java/com/graphql/spring/boot/test/GraphQLTestTemplateIntegrationTest.java b/graphql-spring-boot-test/src/test/java/com/graphql/spring/boot/test/GraphQLTestTemplateIntegrationTest.java index 723e85a5..ddc381ca 100644 --- a/graphql-spring-boot-test/src/test/java/com/graphql/spring/boot/test/GraphQLTestTemplateIntegrationTest.java +++ b/graphql-spring-boot-test/src/test/java/com/graphql/spring/boot/test/GraphQLTestTemplateIntegrationTest.java @@ -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; @@ -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"; @@ -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"; @@ -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 fileNames = Arrays.asList("multiple-queries.graphql", "simple-test-query.graphql"); + List 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 fileNames = Arrays.asList("multiple-queries.graphql"); + List 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)); + } } diff --git a/graphql-spring-boot-test/src/test/java/com/graphql/spring/boot/test/beans/DummyMutation.java b/graphql-spring-boot-test/src/test/java/com/graphql/spring/boot/test/beans/DummyMutation.java new file mode 100644 index 00000000..77b6d363 --- /dev/null +++ b/graphql-spring-boot-test/src/test/java/com/graphql/spring/boot/test/beans/DummyMutation.java @@ -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 uploadFiles(List files, DataFetchingEnvironment env) { + List 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(); + } +} diff --git a/graphql-spring-boot-test/src/test/resources/test-schema.graphqls b/graphql-spring-boot-test/src/test/resources/test-schema.graphqls index d373ef51..51102612 100644 --- a/graphql-spring-boot-test/src/test/resources/test-schema.graphqls +++ b/graphql-spring-boot-test/src/test/resources/test-schema.graphqls @@ -1,3 +1,5 @@ +scalar Upload + type FooBar { foo: String! bar: String! @@ -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! } \ No newline at end of file diff --git a/graphql-spring-boot-test/src/test/resources/upload-file.graphql b/graphql-spring-boot-test/src/test/resources/upload-file.graphql new file mode 100644 index 00000000..19ee9e17 --- /dev/null +++ b/graphql-spring-boot-test/src/test/resources/upload-file.graphql @@ -0,0 +1,3 @@ +mutation($file: Upload) { + uploadFile(file: $file) +} \ No newline at end of file diff --git a/graphql-spring-boot-test/src/test/resources/upload-files.graphql b/graphql-spring-boot-test/src/test/resources/upload-files.graphql new file mode 100644 index 00000000..fbebb41e --- /dev/null +++ b/graphql-spring-boot-test/src/test/resources/upload-files.graphql @@ -0,0 +1,3 @@ +mutation($files: [Upload]!) { + uploadFiles(files: $files) +} \ No newline at end of file