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

Function for GraphQLTestTemplate to upload files using Upload scalar #712

Merged
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
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)
}