spring-graphql
spring-graphql copied to clipboard
MultipartFile support
Support Upload
scalar in schema defintion and handling multipart body.
One concern with this is that GraphQL doesn't support binary data, so there is an argument for handling uploads outside of GraphQL. Here is one resource with a discussion around this, including pros and cons.
That said, I'm setting this for M2 in order to have look and make a decision as to whether to support this and if so how. At least we can consider what we can do to enable other frameworks to build it on top of Spring GraphQL.
If it is implemented by the existing Spring Mvc MultipartFile or Spring WebFlux MultiPart, why binary is not supported.
The bad side is in the client building the multiform data satisfies the GraphQL file upload spec is too complex, if there is no GraphQL Client Helper to simplify work, I would like to use the simplest RESTful API for this case.
When looking into the file upload spec, it supports a batch operations
payload item like this.
{
"operations": [
{
"query": "",
"variables": "",
"operationName":"",
"extensions":""
},
{...}
],
"map": {}
"..."
}
The operations
is a requirement from GraphQL spec or just for file uploads? GraphQL over HTTP does not include such an item.
Do you care to know about Intel hacks? You can contact intelhackman on telegram.
This is the only feature I need to migrate form Graphql kickstart to Spring Graphql
Any news on this feature ?
@MiguelAngelLV @Jojoooo1 please use the voting feature on the issue description itself, as it helps the team to prioritize issues (and creates less notifications).
The Graphql Multipart Request spec: https://github.com/jaydenseric/graphql-multipart-request-spec includes sync and async scenario.
I crafted MultipartFile file for Web MVC support by translating DGS kotlin implementation to Java
package yourcompany;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import graphql.schema.Coercing;
import graphql.schema.CoercingParseLiteralException;
import graphql.schema.CoercingParseValueException;
import graphql.schema.CoercingSerializeException;
import graphql.schema.GraphQLScalarType;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.boot.autoconfigure.graphql.GraphQlProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.i18n.LocaleContextHolder;
import org.springframework.core.annotation.Order;
import org.springframework.graphql.ExecutionGraphQlRequest;
import org.springframework.graphql.execution.RuntimeWiringConfigurer;
import org.springframework.graphql.server.WebGraphQlHandler;
import org.springframework.graphql.server.WebGraphQlRequest;
import org.springframework.graphql.server.webmvc.GraphQlHttpHandler;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.lang.Nullable;
import org.springframework.util.AlternativeJdkIdGenerator;
import org.springframework.util.Assert;
import org.springframework.util.IdGenerator;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.multipart.support.AbstractMultipartHttpServletRequest;
import org.springframework.web.server.ServerWebInputException;
import org.springframework.web.servlet.function.RequestPredicates;
import org.springframework.web.servlet.function.RouterFunction;
import org.springframework.web.servlet.function.RouterFunctions;
import org.springframework.web.servlet.function.ServerRequest;
import org.springframework.web.servlet.function.ServerResponse;
import reactor.core.publisher.Mono;
import javax.servlet.ServletException;
import java.net.URI;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.regex.Pattern;
import static yourcompany.GraphqlMultipartHandler.SUPPORTED_RESPONSE_MEDIA_TYPES;
import static org.springframework.http.MediaType.MULTIPART_FORM_DATA;
@Configuration
public class GraphqlConfiguration {
@Bean
public RuntimeWiringConfigurer runtimeWiringConfigurerUpload() {
GraphQLScalarType uploadScalar = GraphQLScalarType.newScalar()
.name("Upload")
.coercing(new UploadCoercing())
.build();
return wiringBuilder -> wiringBuilder.scalar(uploadScalar);
}
@Bean
@Order(1)
public RouterFunction<ServerResponse> graphQlMultipartRouterFunction(
GraphQlProperties properties,
WebGraphQlHandler webGraphQlHandler,
ObjectMapper objectMapper
) {
String path = properties.getPath();
RouterFunctions.Builder builder = RouterFunctions.route();
GraphqlMultipartHandler graphqlMultipartHandler = new GraphqlMultipartHandler(webGraphQlHandler, objectMapper);
builder = builder.POST(path, RequestPredicates.contentType(MULTIPART_FORM_DATA)
.and(RequestPredicates.accept(SUPPORTED_RESPONSE_MEDIA_TYPES.toArray(MediaType[]::new))), graphqlMultipartHandler::handleRequest);
return builder.build();
}
}
class UploadCoercing implements Coercing<MultipartFile, MultipartFile> {
@Override
public MultipartFile serialize(Object dataFetcherResult) throws CoercingSerializeException {
throw new CoercingSerializeException("Upload is an input-only type");
}
@Override
public MultipartFile parseValue(Object input) throws CoercingParseValueException {
if (input instanceof MultipartFile) {
return (MultipartFile)input;
}
throw new CoercingParseValueException(
String.format("Expected a 'MultipartFile' like object but was '%s'.", input != null ? input.getClass() : null)
);
}
@Override
public MultipartFile parseLiteral(Object input) throws CoercingParseLiteralException {
throw new CoercingParseLiteralException("Parsing literal of 'MultipartFile' is not supported");
}
}
class GraphqlMultipartHandler {
private final WebGraphQlHandler graphQlHandler;
private final ObjectMapper objectMapper;
public GraphqlMultipartHandler(WebGraphQlHandler graphQlHandler, ObjectMapper objectMapper) {
Assert.notNull(graphQlHandler, "WebGraphQlHandler is required");
Assert.notNull(objectMapper, "ObjectMapper is required");
this.graphQlHandler = graphQlHandler;
this.objectMapper = objectMapper;
}
public static final List<MediaType> SUPPORTED_RESPONSE_MEDIA_TYPES =
Arrays.asList(MediaType.APPLICATION_GRAPHQL, MediaType.APPLICATION_JSON);
private static final Log logger = LogFactory.getLog(GraphQlHttpHandler.class);
private final IdGenerator idGenerator = new AlternativeJdkIdGenerator();
public ServerResponse handleRequest(ServerRequest serverRequest) throws ServletException {
Optional<String> operation = serverRequest.param("operations");
Optional<String> mapParam = serverRequest.param("map");
Map<String, Object> inputQuery = readJson(operation, new TypeReference<>() {});
final Map<String, Object> queryVariables;
if (inputQuery.containsKey("variables")) {
queryVariables = (Map<String, Object>)inputQuery.get("variables");
} else {
queryVariables = new HashMap<>();
}
Map<String, Object> extensions = new HashMap<>();
if (inputQuery.containsKey("extensions")) {
extensions = (Map<String, Object>)inputQuery.get("extensions");
}
Map<String, MultipartFile> fileParams = readMultipartBody(serverRequest);
Map<String, List<String>> fileMapInput = readJson(mapParam, new TypeReference<>() {});
fileMapInput.forEach((String fileKey, List<String> objectPaths) -> {
MultipartFile file = fileParams.get(fileKey);
if (file != null) {
objectPaths.forEach((String objectPath) -> {
MultipartVariableMapper.mapVariable(
objectPath,
queryVariables,
file
);
});
}
});
String query = (String) inputQuery.get("query");
String opName = (String) inputQuery.get("operationName");
WebGraphQlRequest graphQlRequest = new MultipartGraphQlRequest(
query,
opName,
queryVariables,
extensions,
serverRequest.uri(), serverRequest.headers().asHttpHeaders(),
this.idGenerator.generateId().toString(), LocaleContextHolder.getLocale());
if (logger.isDebugEnabled()) {
logger.debug("Executing: " + graphQlRequest);
}
Mono<ServerResponse> responseMono = this.graphQlHandler.handleRequest(graphQlRequest)
.map(response -> {
if (logger.isDebugEnabled()) {
logger.debug("Execution complete");
}
ServerResponse.BodyBuilder builder = ServerResponse.ok();
builder.headers(headers -> headers.putAll(response.getResponseHeaders()));
builder.contentType(selectResponseMediaType(serverRequest));
return builder.body(response.toMap());
});
return ServerResponse.async(responseMono);
}
private <T> T readJson(Optional<String> string, TypeReference<T> t) {
Map<String, Object> map = new HashMap<>();
if (string.isPresent()) {
try {
return objectMapper.readValue(string.get(), t);
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
}
return (T)map;
}
private static Map<String, MultipartFile> readMultipartBody(ServerRequest request) {
try {
AbstractMultipartHttpServletRequest abstractMultipartHttpServletRequest = (AbstractMultipartHttpServletRequest) request.servletRequest();
return abstractMultipartHttpServletRequest.getFileMap();
}
catch (RuntimeException ex) {
throw new ServerWebInputException("Error while reading request parts", null, ex);
}
}
private static MediaType selectResponseMediaType(ServerRequest serverRequest) {
for (MediaType accepted : serverRequest.headers().accept()) {
if (SUPPORTED_RESPONSE_MEDIA_TYPES.contains(accepted)) {
return accepted;
}
}
return MediaType.APPLICATION_JSON;
}
}
// As in DGS, this is borrowed from https://github.com/graphql-java-kickstart/graphql-java-servlet/blob/eb4dfdb5c0198adc1b4d4466c3b4ea4a77def5d1/graphql-java-servlet/src/main/java/graphql/kickstart/servlet/core/internal/VariableMapper.java
class MultipartVariableMapper {
private static final Pattern PERIOD = Pattern.compile("\\.");
private static final Mapper<Map<String, Object>> MAP_MAPPER =
new Mapper<Map<String, Object>>() {
@Override
public Object set(Map<String, Object> location, String target, MultipartFile value) {
return location.put(target, value);
}
@Override
public Object recurse(Map<String, Object> location, String target) {
return location.get(target);
}
};
private static final Mapper<List<Object>> LIST_MAPPER =
new Mapper<List<Object>>() {
@Override
public Object set(List<Object> location, String target, MultipartFile value) {
return location.set(Integer.parseInt(target), value);
}
@Override
public Object recurse(List<Object> location, String target) {
return location.get(Integer.parseInt(target));
}
};
@SuppressWarnings({"unchecked", "rawtypes"})
public static void mapVariable(String objectPath, Map<String, Object> variables, MultipartFile part) {
String[] segments = PERIOD.split(objectPath);
if (segments.length < 2) {
throw new RuntimeException("object-path in map must have at least two segments");
} else if (!"variables".equals(segments[0])) {
throw new RuntimeException("can only map into variables");
}
Object currentLocation = variables;
for (int i = 1; i < segments.length; i++) {
String segmentName = segments[i];
Mapper mapper = determineMapper(currentLocation, objectPath, segmentName);
if (i == segments.length - 1) {
if (null != mapper.set(currentLocation, segmentName, part)) {
throw new RuntimeException("expected null value when mapping " + objectPath);
}
} else {
currentLocation = mapper.recurse(currentLocation, segmentName);
if (null == currentLocation) {
throw new RuntimeException(
"found null intermediate value when trying to map " + objectPath);
}
}
}
}
private static Mapper<?> determineMapper(
Object currentLocation, String objectPath, String segmentName) {
if (currentLocation instanceof Map) {
return MAP_MAPPER;
} else if (currentLocation instanceof List) {
return LIST_MAPPER;
}
throw new RuntimeException(
"expected a map or list at " + segmentName + " when trying to map " + objectPath);
}
interface Mapper<T> {
Object set(T location, String target, MultipartFile value);
Object recurse(T location, String target);
}
}
// It's possible to remove this class if there was a extra constructor in WebGraphQlRequest
class MultipartGraphQlRequest extends WebGraphQlRequest implements ExecutionGraphQlRequest {
private final String document;
private final String operationName;
private final Map<String, Object> variables;
private final Map<String, Object> extensions;
public MultipartGraphQlRequest(
String query,
String operationName,
Map<String, Object> variables,
Map<String, Object> extensions,
URI uri, HttpHeaders headers,
String id, @Nullable Locale locale) {
super(uri, headers, fakeBody(query), id, locale);
this.document = query;
this.operationName = operationName;
this.variables = variables;
this.extensions = extensions;
}
private static Map<String, Object> fakeBody(String query) {
Map<String, Object> fakeBody = new HashMap<>();
fakeBody.put("query", query);
return fakeBody;
}
@Override
public String getDocument() {
return document;
}
@Override
public String getOperationName() {
return operationName;
}
@Override
public Map<String, Object> getVariables() {
return variables;
}
@Override
public Map<String, Object> getExtensions() {
return extensions;
}
}
scalar Upload
Example of usage
type FileUploadResult {
id: String!
}
extend type Mutation {
fileUpload(file: Upload!): FileUploadResult!
}
package yourcompany;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.graphql.data.method.annotation.Argument;
import org.springframework.graphql.data.method.annotation.MutationMapping;
import org.springframework.stereotype.Controller;
import org.springframework.web.multipart.MultipartFile;
import java.util.UUID;
@Controller
public class FileController {
private static final Logger logger = LoggerFactory.getLogger(FileController.class);
@MutationMapping(name = "fileUpload")
public FileUploadResult uploadFile(@Argument MultipartFile file) {
logger.info("Upload file: name={}", file.getOriginalFilename());
return new FileUploadResult(UUID.randomUUID());
}
}
class FileUploadResult {
UUID id;
public FileUploadResult(UUID id) {
this.id = id;
}
public UUID getId() {
return id;
}
public void setId(UUID id) {
this.id = id;
}
}
curl --location --request POST 'http://localhost:8080/graphql' \
--form 'operations="{ \"query\": \"mutation FileUpload($file: Upload!) {fileUpload(file: $file){id}}\" , \"variables\": {\"file\": null}}"' \
--form 'file=@"/home/nkonev/Downloads/avatar425677689-0.jpg"' \
--form 'map="{\"file\": [\"variables.file\"]}"'
Can confirm: @nkonev s solution works. Big thanks!
Great work.
In the future I hope there is a upload method in GraphQlClient
and GraphQlTester
to simplfy the upload operations. And I hope there is another version for WebFlux(and ideally support WebSocket and RSocket protocol at the same time)
Just migrated my whole project over from graphql-kickstart to "official" spring graphql support and getting stuck with my exiting file upload mutations. Sad to find out that this seems to be the last issue before I can finish migration ;-(
I'm closing this as superseded by #430, but in summary, as per discussion under https://github.com/spring-projects/spring-graphql/pull/430#issuecomment-1476186878, we don't intend to have built-in support for the GraphQL multipart request spec. Our recommendation is to post regular HTTP multipart requests, and handle those with @RequestMapping
methods, which can be in the same @Controller
.
I've done a library
https://github.com/nkonev/multipart-spring-graphql
<dependency>
<groupId>name.nkonev.multipart-spring-graphql</groupId>
<artifactId>multipart-spring-graphql</artifactId>
<version>VERSION</version>
</dependency>
multipart-spring-graphql | Java | Spring Boot | Example |
---|---|---|---|
0.10.x | 8+ | Spring Boot 2.7.x | https://github.com/nkonev/multipart-graphql-demo/tree/0.10.x |
1.0.x | 17+ | Spring Boot 3.0.x | https://github.com/nkonev/multipart-graphql-demo/tree/1.0.x |
1.1.x | 17+ | Spring Boot 3.1.x | https://github.com/nkonev/multipart-graphql-demo/tree/1.1.x |
Nice work @nkonev! I've created #747 to updated the reference documentation where we currently don't have anything at all on the topic.