From 70229ccffc09dece118dcd4514357d9e46b45bfe Mon Sep 17 00:00:00 2001 From: Andreas Berger Date: Tue, 15 Aug 2023 10:38:15 +0200 Subject: [PATCH] add support for apollo file uploads, resolves #61 --- .../FileUploadAutoConfiguration.java | 20 +++++ .../autoconfigure/FileUploadHandler.java | 88 +++++++++++++++++++ .../spring/autoconfigure/SpqrProperties.java | 28 +++++- .../spqr/spring/web/GraphQLController.java | 60 +++++++++++-- ...ot.autoconfigure.AutoConfiguration.imports | 1 + ...lverBuilder_SpqrAutoConfigurationTest.java | 81 +++++++++-------- .../test/ResolverBuilder_TestConfig.java | 38 ++++++-- .../spring/web/GraphQLControllerTest.java | 59 +++++++++++-- .../src/test/resources/application.properties | 2 + .../src/test/resources/schema.graphql | 41 +++++++++ 10 files changed, 356 insertions(+), 62 deletions(-) create mode 100644 graphql-spqr-spring-boot-autoconfigure/src/main/java/io/leangen/graphql/spqr/spring/autoconfigure/FileUploadAutoConfiguration.java create mode 100644 graphql-spqr-spring-boot-autoconfigure/src/main/java/io/leangen/graphql/spqr/spring/autoconfigure/FileUploadHandler.java create mode 100644 graphql-spqr-spring-boot-autoconfigure/src/test/resources/schema.graphql diff --git a/graphql-spqr-spring-boot-autoconfigure/src/main/java/io/leangen/graphql/spqr/spring/autoconfigure/FileUploadAutoConfiguration.java b/graphql-spqr-spring-boot-autoconfigure/src/main/java/io/leangen/graphql/spqr/spring/autoconfigure/FileUploadAutoConfiguration.java new file mode 100644 index 0000000..8e74234 --- /dev/null +++ b/graphql-spqr-spring-boot-autoconfigure/src/main/java/io/leangen/graphql/spqr/spring/autoconfigure/FileUploadAutoConfiguration.java @@ -0,0 +1,20 @@ +package io.leangen.graphql.spqr.spring.autoconfigure; + +import io.leangen.graphql.module.Module; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class FileUploadAutoConfiguration { + + @Bean + @ConditionalOnProperty(name = "graphql.spqr.multipart-upload.enabled", havingValue = "true") + public Internal uploadModule() { + FileUploadHandler uploadAdapter = new FileUploadHandler(); + return new Internal<>(context -> context.getSchemaGenerator() + .withArgumentInjectors(uploadAdapter) + .withTypeMappers(uploadAdapter) + ); + } +} diff --git a/graphql-spqr-spring-boot-autoconfigure/src/main/java/io/leangen/graphql/spqr/spring/autoconfigure/FileUploadHandler.java b/graphql-spqr-spring-boot-autoconfigure/src/main/java/io/leangen/graphql/spqr/spring/autoconfigure/FileUploadHandler.java new file mode 100644 index 0000000..bb75edb --- /dev/null +++ b/graphql-spqr-spring-boot-autoconfigure/src/main/java/io/leangen/graphql/spqr/spring/autoconfigure/FileUploadHandler.java @@ -0,0 +1,88 @@ +package io.leangen.graphql.spqr.spring.autoconfigure; + +import java.lang.reflect.AnnotatedElement; +import java.lang.reflect.AnnotatedType; +import java.lang.reflect.Parameter; +import java.util.*; + +import graphql.schema.*; +import io.leangen.geantyref.GenericTypeReflector; +import io.leangen.graphql.generator.mapping.ArgumentInjector; +import io.leangen.graphql.generator.mapping.ArgumentInjectorParams; +import io.leangen.graphql.generator.mapping.TypeMapper; +import io.leangen.graphql.generator.mapping.TypeMappingEnvironment; +import io.leangen.graphql.util.ClassUtils; +import org.springframework.web.multipart.MultipartFile; + +class FileUploadHandler implements TypeMapper, ArgumentInjector { + + public static final GraphQLScalarType FILE_UPLOAD_SCALAR = GraphQLScalarType.newScalar() + .name("FileUpload") + .description("An apollo upload compatible scalar for multipart uploads") + .coercing(new Coercing() { + + @Override + public Void serialize(Object dataFetcherResult) throws CoercingSerializeException { + throw new CoercingSerializeException("Upload is not a return type"); + } + + @Override + public MultipartFile parseValue(Object input) throws CoercingParseValueException { + if (input instanceof MultipartFile) { + return (MultipartFile) input; + } + throw new CoercingParseValueException("Expected the input to be parsed by the servlet controller"); + } + + @Override + public MultipartFile parseLiteral(Object input) throws CoercingParseLiteralException { + throw new CoercingParseLiteralException("Parsing the literal of the upload is not supported"); + } + }) + .build(); + + @Override + public GraphQLInputType toGraphQLInputType(AnnotatedType javaType, Set> mappersToSkip, TypeMappingEnvironment env) { + return FILE_UPLOAD_SCALAR; + } + + @Override + public GraphQLOutputType toGraphQLType(AnnotatedType javaType, Set> mappersToSkip, TypeMappingEnvironment env) { + throw new UnsupportedOperationException("FileUpload is not an output type"); + } + + @Override + public boolean supports(AnnotatedElement element, AnnotatedType type) { + return type != null && ClassUtils.isAssignable(MultipartFile.class, type.getType()); + } + + @Override + public Object getArgumentValue(ArgumentInjectorParams params) { + if ((params.getInput() instanceof MultipartFile)) { + return params.getInput(); + } + if (!(params.getInput() instanceof Collection)) { + return null; + } + if (ClassUtils.isAssignable(params.getType().getType(), params.getInput().getClass())) { + return params.getInput(); + } + if (ClassUtils.isAssignable(List.class, params.getType().getType())) { + //noinspection rawtypes,unchecked + return new ArrayList((Collection) params.getInput()); + } + if (ClassUtils.isAssignable(Set.class, params.getType().getType())) { + //noinspection rawtypes,unchecked + return new LinkedHashSet((Collection) params.getInput()); + } + throw new UnsupportedOperationException("Cannot convert " + params.getInput().getClass() + " to " + params.getType()); + } + + @Override + public boolean supports(AnnotatedType type, Parameter parameter) { + return supports(null, type) + || ClassUtils.isAssignable(Iterable.class, type.getType()) + && supports(null, GenericTypeReflector.getTypeParameter(type, Iterable.class.getTypeParameters()[0])); + } +} + diff --git a/graphql-spqr-spring-boot-autoconfigure/src/main/java/io/leangen/graphql/spqr/spring/autoconfigure/SpqrProperties.java b/graphql-spqr-spring-boot-autoconfigure/src/main/java/io/leangen/graphql/spqr/spring/autoconfigure/SpqrProperties.java index 2c940e4..ee403c0 100644 --- a/graphql-spqr-spring-boot-autoconfigure/src/main/java/io/leangen/graphql/spqr/spring/autoconfigure/SpqrProperties.java +++ b/graphql-spqr-spring-boot-autoconfigure/src/main/java/io/leangen/graphql/spqr/spring/autoconfigure/SpqrProperties.java @@ -1,9 +1,8 @@ package io.leangen.graphql.spqr.spring.autoconfigure; import io.leangen.graphql.util.Utils; -import org.springframework.boot.context.properties.ConfigurationProperties; - import jakarta.annotation.PostConstruct; +import org.springframework.boot.context.properties.ConfigurationProperties; @ConfigurationProperties(prefix = "graphql.spqr") @SuppressWarnings("WeakerAccess") @@ -17,6 +16,7 @@ public class SpqrProperties { private boolean abstractInputTypeResolution; private int maxComplexity = -1; private Relay relay = new Relay(); + private MultipartUpload multipartUpload = new MultipartUpload(); // Web properties private Http http = new Http(); @@ -97,6 +97,14 @@ public void setGui(Gui gui) { this.gui = gui; } + public MultipartUpload getMultipartUpload() { + return multipartUpload; + } + + public void setMultipartUpload(MultipartUpload multipartUpload) { + this.multipartUpload = multipartUpload; + } + public static class Relay { private boolean enabled; @@ -321,4 +329,20 @@ public void setPageTitle(String pageTitle) { this.pageTitle = pageTitle; } } + + public static class MultipartUpload { + + private boolean enabled; + + public boolean isEnabled() { + return enabled; + } + + /** + * @param enabled if enabled a multipart file upload will be activated + */ + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + } } diff --git a/graphql-spqr-spring-boot-autoconfigure/src/main/java/io/leangen/graphql/spqr/spring/web/GraphQLController.java b/graphql-spqr-spring-boot-autoconfigure/src/main/java/io/leangen/graphql/spqr/spring/web/GraphQLController.java index 5e5d6e1..7203e58 100644 --- a/graphql-spqr-spring-boot-autoconfigure/src/main/java/io/leangen/graphql/spqr/spring/web/GraphQLController.java +++ b/graphql-spqr-spring-boot-autoconfigure/src/main/java/io/leangen/graphql/spqr/spring/web/GraphQLController.java @@ -1,30 +1,41 @@ package io.leangen.graphql.spqr.spring.web; +import java.util.*; +import java.util.stream.Collectors; + import graphql.GraphQL; +import io.leangen.geantyref.GenericTypeReflector; +import io.leangen.graphql.execution.GlobalEnvironment; +import io.leangen.graphql.generator.mapping.ConverterRegistry; +import io.leangen.graphql.metadata.messages.EmptyMessageBundle; +import io.leangen.graphql.metadata.strategy.type.DefaultTypeInfoGenerator; +import io.leangen.graphql.metadata.strategy.value.ValueMapper; import io.leangen.graphql.spqr.spring.web.dto.ExecutorParams; import io.leangen.graphql.spqr.spring.web.dto.GraphQLRequest; import io.leangen.graphql.spqr.spring.web.dto.TransportType; +import io.leangen.graphql.util.Defaults; import io.leangen.graphql.util.Utils; +import org.springframework.beans.MutablePropertyValues; import org.springframework.http.MediaType; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestMethod; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; - -import java.util.Map; +import org.springframework.validation.DataBinder; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; @RestController public abstract class GraphQLController { protected final GraphQL graphQL; protected final GraphQLExecutor executor; + private final ValueMapper valueMapper; + public GraphQLController(GraphQL graphQL, GraphQLExecutor executor) { this.graphQL = graphQL; this.executor = executor; + this.valueMapper = Defaults.valueMapperFactory(new DefaultTypeInfoGenerator()).getValueMapper( + Collections.emptyMap(), + new GlobalEnvironment(EmptyMessageBundle.INSTANCE, null, null, new ConverterRegistry(Collections.emptyList(), Collections.emptyList()), null, null, null, null) + ); } @PostMapping( @@ -114,4 +125,35 @@ public Object executeGetEventStream(GraphQLRequest graphQLRequest, R request) { private Object get(GraphQLRequest graphQLRequest, R request, TransportType transportType) { return executor.execute(graphQL, new ExecutorParams<>(graphQLRequest, request, transportType)); } + + @PostMapping( + value = "${graphql.spqr.http.endpoint:/graphql}", + consumes = MediaType.MULTIPART_FORM_DATA_VALUE, + produces = MediaType.APPLICATION_JSON_UTF8_VALUE + ) + public Object executeMultipartFileUpload( + @RequestParam("operations") String requestString, + @RequestParam("map") String mappingString, + @RequestParam Map multipartFiles, + R request) + { + GraphQLRequest graphQLRequest = valueMapper.fromString(requestString, GenericTypeReflector.annotate(GraphQLRequest.class)); + Map> fileMappings = valueMapper.fromString(mappingString, GenericTypeReflector.annotate((Map.class))); + + Map values = new LinkedHashMap<>(); + fileMappings.forEach((fileKey, variables) -> { + for (String variable : variables) { + String[] parts = variable.split("\\."); + String path = parts[0] + Arrays.stream(parts).skip(1).collect(Collectors.joining("][", "[", "]")); + values.put(path, multipartFiles.get(fileKey)); + } + }); + + DataBinder binder = new DataBinder(graphQLRequest, "operations"); + binder.setIgnoreUnknownFields(false); + binder.setIgnoreInvalidFields(false); + binder.bind(new MutablePropertyValues(values)); + + return executeGet(graphQLRequest, request); + } } diff --git a/graphql-spqr-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/graphql-spqr-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index dfb4d95..2fd235c 100644 --- a/graphql-spqr-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/graphql-spqr-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -3,3 +3,4 @@ io.leangen.graphql.spqr.spring.autoconfigure.MvcAutoConfiguration io.leangen.graphql.spqr.spring.autoconfigure.ReactiveAutoConfiguration io.leangen.graphql.spqr.spring.autoconfigure.SpringDataAutoConfiguration io.leangen.graphql.spqr.spring.autoconfigure.WebSocketAutoConfiguration +io.leangen.graphql.spqr.spring.autoconfigure.FileUploadAutoConfiguration diff --git a/graphql-spqr-spring-boot-autoconfigure/src/test/java/io/leangen/graphql/spqr/spring/autoconfigure/ResolverBuilder_SpqrAutoConfigurationTest.java b/graphql-spqr-spring-boot-autoconfigure/src/test/java/io/leangen/graphql/spqr/spring/autoconfigure/ResolverBuilder_SpqrAutoConfigurationTest.java index f426959..1ac4f31 100644 --- a/graphql-spqr-spring-boot-autoconfigure/src/test/java/io/leangen/graphql/spqr/spring/autoconfigure/ResolverBuilder_SpqrAutoConfigurationTest.java +++ b/graphql-spqr-spring-boot-autoconfigure/src/test/java/io/leangen/graphql/spqr/spring/autoconfigure/ResolverBuilder_SpqrAutoConfigurationTest.java @@ -1,8 +1,16 @@ package io.leangen.graphql.spqr.spring.autoconfigure; +import java.util.Scanner; + +import graphql.scalars.ExtendedScalars; import graphql.schema.GraphQLSchema; +import graphql.schema.diff.DiffSet; +import graphql.schema.diff.SchemaDiff; +import graphql.schema.diff.reporting.CapturingReporter; +import graphql.schema.idl.*; import io.leangen.graphql.GraphQLSchemaGenerator; import io.leangen.graphql.spqr.spring.test.ResolverBuilder_TestConfig; +import org.assertj.core.api.Assertions; import org.junit.Test; import org.junit.runner.RunWith; import org.springframework.beans.factory.annotation.Autowired; @@ -14,7 +22,7 @@ import static org.junit.Assert.assertNotNull; @RunWith(SpringRunner.class) -@ContextConfiguration(classes = {BaseAutoConfiguration.class, ResolverBuilder_TestConfig.class}) +@ContextConfiguration(classes = { BaseAutoConfiguration.class, ResolverBuilder_TestConfig.class, FileUploadAutoConfiguration.class }) @TestPropertySource(locations = "classpath:application.properties") public class ResolverBuilder_SpqrAutoConfigurationTest { @@ -40,41 +48,44 @@ public void schemaGeneratorConfigTest() { @Test public void schemaConfigTest() { - assertNotNull(schema); - //Operations sources wired in different ways - // -using the default resolver builder - assertNotNull(schema.getQueryType().getFieldDefinition("greetingFromAnnotatedSource_wiredAsComponent")); - assertNotNull(schema.getQueryType().getFieldDefinition("greetingFromAnnotatedSource_wiredAsBean")); - - //Operations source wired as bean - // -using additional global resolver builder - assertNotNull(schema.getQueryType().getFieldDefinition("greetingFromBeanSource_wiredAsBean_byCustomGlobalResolverBuilder")); - // -using default resolver builders - assertNotNull(schema.getQueryType().getFieldDefinition("greetingFromBeanSource_wiredAsBean_byMethodName")); - assertNotNull(schema.getQueryType().getFieldDefinition("greetingFromBeanSource_wiredAsBean_byAnnotation")); - // -using custom resolver builders - assertNotNull(schema.getQueryType().getFieldDefinition("greetingFromBeanSource_wiredAsBean_byStringQualifiedCustomResolverBuilder_wiredAsBean")); - assertNotNull(schema.getQueryType().getFieldDefinition("greetingFromBeanSource_wiredAsBean_byStringQualifiedCustomResolverBuilder_wiredAsComponent")); - assertNotNull(schema.getQueryType().getFieldDefinition("greetingFromBeanSource_wiredAsBean_byAnnotationQualifiedCustomResolverBuilder_wiredAsBean")); - assertNotNull(schema.getQueryType().getFieldDefinition("greetingFromBeanSource_wiredAsBean_byAnnotationQualifiedCustomResolverBuilder_wiredAsComponent")); - assertNotNull(schema.getQueryType().getFieldDefinition("greetingFromBeanSource_wiredAsBean_byNamedCustomResolverBuilder_wiredAsBean")); - assertNotNull(schema.getQueryType().getFieldDefinition("greetingFromBeanSource_wiredAsBean_byNamedCustomResolverBuilder_wiredAsComponent")); - - //Operations source wired as component - // -using additional global resolver builder - assertNotNull(schema.getQueryType().getFieldDefinition("greetingFromBeanSource_wiredAsComponent_byCustomGlobalResolverBuilder")); - // -using default resolver builders - assertNotNull(schema.getQueryType().getFieldDefinition("greetingFromBeanSource_wiredAsComponent_byMethodName")); - assertNotNull(schema.getQueryType().getFieldDefinition("greetingFromBeanSource_wiredAsComponent_byAnnotation")); - // -using custom resolver builders - assertNotNull(schema.getQueryType().getFieldDefinition("greetingFromBeanSource_wiredAsComponent_byStringQualifiedCustomResolverBuilder_wiredAsBean")); - assertNotNull(schema.getQueryType().getFieldDefinition("greetingFromBeanSource_wiredAsComponent_byStringQualifiedCustomResolverBuilder_wiredAsComponent")); - assertNotNull(schema.getQueryType().getFieldDefinition("greetingFromBeanSource_wiredAsComponent_byAnnotationQualifiedCustomResolverBuilder_wiredAsBean")); - assertNotNull(schema.getQueryType().getFieldDefinition("greetingFromBeanSource_wiredAsComponent_byAnnotationQualifiedCustomResolverBuilder_wiredAsComponent")); - assertNotNull(schema.getQueryType().getFieldDefinition("greetingFromBeanSource_wiredAsComponent_byNamedCustomResolverBuilder_wiredAsBean")); - assertNotNull(schema.getQueryType().getFieldDefinition("greetingFromBeanSource_wiredAsComponent_byNamedCustomResolverBuilder_wiredAsComponent")); - assertNotNull(schema.getQueryType().getFieldDefinition("springPageComponent_users")); + printSchema(); + + String expectedSchemaString = new Scanner(ResolverBuilder_SpqrAutoConfigurationTest.class + .getResourceAsStream("/schema.graphql"), "UTF-8") + .useDelimiter("\\A") + .next(); + + SchemaParser schemaParser = new SchemaParser(); + TypeDefinitionRegistry reg = schemaParser.parse(expectedSchemaString); + SchemaGenerator gen = new SchemaGenerator(); + + RuntimeWiring.Builder runtimeWiring = RuntimeWiring.newRuntimeWiring() + .scalar(ExtendedScalars.GraphQLLong) + .scalar(FileUploadHandler.FILE_UPLOAD_SCALAR); + + GraphQLSchema expected = gen.makeExecutableSchema(reg, runtimeWiring.build()); + + diff(expected, schema); + diff(schema, expected); + } + + private void printSchema() { + SchemaPrinter schemaPrinter = new SchemaPrinter(SchemaPrinter.Options.defaultOptions() + .includeDirectives(false) + .includeScalarTypes(true) + .includeSchemaDefinition(true) + .includeIntrospectionTypes(false)); + System.out.println("Augmented Schema:"); + System.out.println(schemaPrinter.print(schema)); } + private void diff(GraphQLSchema augmentedSchema, GraphQLSchema expected) { + DiffSet diffSet = DiffSet.diffSet(augmentedSchema, expected); + CapturingReporter capture = new CapturingReporter(); + new SchemaDiff(SchemaDiff.Options.defaultOptions()) + .diffSchema(diffSet, capture); + Assertions.assertThat(capture.getDangers()).isEmpty(); + Assertions.assertThat(capture.getBreakages()).isEmpty(); + } } diff --git a/graphql-spqr-spring-boot-autoconfigure/src/test/java/io/leangen/graphql/spqr/spring/test/ResolverBuilder_TestConfig.java b/graphql-spqr-spring-boot-autoconfigure/src/test/java/io/leangen/graphql/spqr/spring/test/ResolverBuilder_TestConfig.java index 977957e..c3d771f 100644 --- a/graphql-spqr-spring-boot-autoconfigure/src/test/java/io/leangen/graphql/spqr/spring/test/ResolverBuilder_TestConfig.java +++ b/graphql-spqr-spring-boot-autoconfigure/src/test/java/io/leangen/graphql/spqr/spring/test/ResolverBuilder_TestConfig.java @@ -1,5 +1,13 @@ package io.leangen.graphql.spqr.spring.test; +import java.io.IOException; +import java.lang.reflect.Method; +import java.util.Arrays; +import java.util.List; +import java.util.Scanner; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + import io.leangen.graphql.ExtensionProvider; import io.leangen.graphql.GeneratorConfiguration; import io.leangen.graphql.annotations.GraphQLArgument; @@ -18,12 +26,7 @@ import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Component; - -import java.lang.reflect.Method; -import java.util.Arrays; -import java.util.List; -import java.util.stream.Collectors; -import java.util.stream.IntStream; +import org.springframework.web.multipart.MultipartFile; @Configuration public class ResolverBuilder_TestConfig { @@ -49,11 +52,32 @@ protected boolean isQuery(Method method, ResolverBuilderParams params) { @Component("annotatedOperationSourceBean") @GraphQLApi - private static class AnnotatedOperationSourceBean { + public static class AnnotatedOperationSourceBean { @GraphQLQuery(name = "greetingFromAnnotatedSource_wiredAsComponent") public String getGreeting() { return "Hello world !"; } + + @GraphQLQuery + public List upload(@GraphQLArgument(name = "file1") MultipartFile file1, @GraphQLArgument(name = "file2") MultipartFile file2) throws IOException { + return Arrays.asList( + new Scanner(file1.getInputStream(), "UTF-8").useDelimiter("\\A").next(), + new Scanner(file2.getInputStream(), "UTF-8").useDelimiter("\\A").next() + ); + } + + @GraphQLQuery + public List uploadFiles(@GraphQLArgument(name = "files") List files) { + return files.stream() + .map(file -> { + try { + return new Scanner(file.getInputStream(), "UTF-8").useDelimiter("\\A").next(); + } catch (IOException e) { + throw new RuntimeException(e); + } + }) + .collect(Collectors.toList()); + } } @Bean diff --git a/graphql-spqr-spring-boot-autoconfigure/src/test/java/io/leangen/graphql/spqr/spring/web/GraphQLControllerTest.java b/graphql-spqr-spring-boot-autoconfigure/src/test/java/io/leangen/graphql/spqr/spring/web/GraphQLControllerTest.java index cfb8c74..6d033d3 100644 --- a/graphql-spqr-spring-boot-autoconfigure/src/test/java/io/leangen/graphql/spqr/spring/web/GraphQLControllerTest.java +++ b/graphql-spqr-spring-boot-autoconfigure/src/test/java/io/leangen/graphql/spqr/spring/web/GraphQLControllerTest.java @@ -1,6 +1,10 @@ package io.leangen.graphql.spqr.spring.web; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; + import io.leangen.graphql.spqr.spring.autoconfigure.BaseAutoConfiguration; +import io.leangen.graphql.spqr.spring.autoconfigure.FileUploadAutoConfiguration; import io.leangen.graphql.spqr.spring.autoconfigure.MvcAutoConfiguration; import io.leangen.graphql.spqr.spring.autoconfigure.SpringDataAutoConfiguration; import io.leangen.graphql.spqr.spring.test.ResolverBuilder_TestConfig; @@ -15,20 +19,16 @@ import org.springframework.test.context.junit4.SpringRunner; import org.springframework.test.web.servlet.MockMvc; -import java.net.URLEncoder; -import java.nio.charset.StandardCharsets; - import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.equalToCompressingWhiteSpace; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @RunWith(SpringRunner.class) @WebMvcTest @ContextConfiguration(classes = {BaseAutoConfiguration.class, MvcAutoConfiguration.class, - SpringDataAutoConfiguration.class, ResolverBuilder_TestConfig.class}) + SpringDataAutoConfiguration.class, ResolverBuilder_TestConfig.class, FileUploadAutoConfiguration.class }) @TestPropertySource(locations = "classpath:application.properties") public class GraphQLControllerTest { @@ -132,10 +132,10 @@ public void defaultControllerTest_POST_formUrlEncoded_INVALID() throws Exception @Test public void defaultControllerTest_POST_formUrlEncoded_overridingQueryParams() throws Exception { mockMvc.perform( - post("/"+apiContext) - .param("query","{greetingFromBeanSource_wiredAsComponent_byAnnotation}") + post("/" + apiContext) + .param("query", "{greetingFromBeanSource_wiredAsComponent_byAnnotation}") .contentType(MediaType.APPLICATION_FORM_URLENCODED_VALUE) - .content("query="+ URLEncoder.encode("{INVALID_QUERY}", StandardCharsets.UTF_8.toString()))) + .content("query=" + URLEncoder.encode("{INVALID_QUERY}", StandardCharsets.UTF_8.toString()))) .andExpect(status().isOk()) .andExpect(content().string(containsString("Hello world"))); } @@ -188,4 +188,45 @@ public void defaultControllerTest_POST_spring_page() throws Exception { "\"springPageComponent_user_projects\":{\"pageInfo\":{\"startCursor\":\"1\",\"endCursor\":\"2\",\"hasNextPage\":true}," + "\"edges\":[{\"node\":{\"name\":\"Project0\"}},{\"node\":{\"name\":\"Project1\"}}]}}}]}}}"))); } + + @Test + public void uploadFile() throws Exception { + mockMvc.perform( + multipart("/" + apiContext) + .file("1", "content1".getBytes()) + .file("2", "content2".getBytes()) + .param("operations", "{\n" + + " \"operationName\": \"Upload\",\n" + + " \"variables\": {\n" + + " \"file1\": null,\n" + + " \"file2\": null\n" + + " },\n" + + " \"query\": \"query Upload($file1: FileUpload, $file2: FileUpload) {\\n upload(file1: $file1, file2: $file2) }\\n\"\n" + + "}") + .param("map", "{ \"1\": [\"variables.file1\"], \"2\": [\"variables.file2\"] }") + + ) + .andExpect(status().isOk()) + .andExpect(content().json("{\"data\":{\"upload\":[\"content1\",\"content2\"]}}", true)); + } + + @Test + public void uploadMultipleFile() throws Exception { + mockMvc.perform( + multipart("/" + apiContext) + .file("1", "content1".getBytes()) + .file("2", "content2".getBytes()) + .param("operations", "{\n" + + " \"operationName\": \"UploadFiles\",\n" + + " \"variables\": {\n" + + " \"files\": [null]\n" + + " },\n" + + " \"query\": \"query UploadFiles($files: [FileUpload]) {\\n uploadFiles(files: $files) }\\n\"\n" + + "}") + .param("map", "{ \"1\": [\"variables.files.0\"], \"2\": [\"variables.files.1\"] }") + + ) + .andExpect(status().isOk()) + .andExpect(content().json("{\"data\":{\"uploadFiles\":[\"content1\",\"content2\"]}}", true)); + } } diff --git a/graphql-spqr-spring-boot-autoconfigure/src/test/resources/application.properties b/graphql-spqr-spring-boot-autoconfigure/src/test/resources/application.properties index c36d571..69117ca 100644 --- a/graphql-spqr-spring-boot-autoconfigure/src/test/resources/application.properties +++ b/graphql-spqr-spring-boot-autoconfigure/src/test/resources/application.properties @@ -5,6 +5,8 @@ graphql.spqr.relay.mutation-wrapper=relayWrapper graphql.spqr.relay.mutation-wrapper-description=relayWrapperDescription graphql.spqr.relay.spring-data-compatible=true +graphql.spqr.multipart-upload.enabled=true + graphql.spqr.http.endpoint=custom-graphql graphql.spqr.http.mvc.executor=blocking diff --git a/graphql-spqr-spring-boot-autoconfigure/src/test/resources/schema.graphql b/graphql-spqr-spring-boot-autoconfigure/src/test/resources/schema.graphql new file mode 100644 index 0000000..e12501c --- /dev/null +++ b/graphql-spqr-spring-boot-autoconfigure/src/test/resources/schema.graphql @@ -0,0 +1,41 @@ +schema { + query: Query +} + +type Page_User { + totalElements: Long! + totalPages: Int! +} + +#Query root +type Query { + greetingFromAnnotatedSource_wiredAsBean: String + greetingFromAnnotatedSource_wiredAsComponent: String + greetingFromBeanSource_wiredAsBean_byAnnotation: String + greetingFromBeanSource_wiredAsBean_byAnnotationQualifiedCustomResolverBuilder_wiredAsBean: String + greetingFromBeanSource_wiredAsBean_byAnnotationQualifiedCustomResolverBuilder_wiredAsComponent: String + greetingFromBeanSource_wiredAsBean_byCustomGlobalResolverBuilder: String + greetingFromBeanSource_wiredAsBean_byMethodName: String + greetingFromBeanSource_wiredAsBean_byNamedCustomResolverBuilder_wiredAsBean: String + greetingFromBeanSource_wiredAsBean_byNamedCustomResolverBuilder_wiredAsComponent: String + greetingFromBeanSource_wiredAsBean_byStringQualifiedCustomResolverBuilder_wiredAsBean: String + greetingFromBeanSource_wiredAsBean_byStringQualifiedCustomResolverBuilder_wiredAsComponent: String + greetingFromBeanSource_wiredAsComponent_byAnnotation: String + greetingFromBeanSource_wiredAsComponent_byAnnotationQualifiedCustomResolverBuilder_wiredAsBean: String + greetingFromBeanSource_wiredAsComponent_byAnnotationQualifiedCustomResolverBuilder_wiredAsComponent: String + greetingFromBeanSource_wiredAsComponent_byCustomGlobalResolverBuilder: String + greetingFromBeanSource_wiredAsComponent_byMethodName: String + greetingFromBeanSource_wiredAsComponent_byNamedCustomResolverBuilder_wiredAsBean: String + greetingFromBeanSource_wiredAsComponent_byNamedCustomResolverBuilder_wiredAsComponent: String + greetingFromBeanSource_wiredAsComponent_byStringQualifiedCustomResolverBuilder_wiredAsBean: String + greetingFromBeanSource_wiredAsComponent_byStringQualifiedCustomResolverBuilder_wiredAsComponent: String + springPageComponent_users(after: String, first: Int!): Page_User + upload(file1: FileUpload, file2: FileUpload): [String] + uploadFiles(files: [FileUpload]): [String] +} + +#An apollo upload compatible scalar for multipart uploads +scalar FileUpload + +"A 64-bit signed integer" +scalar Long