-
Notifications
You must be signed in to change notification settings - Fork 305
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
MultipartFile support #69
Comments
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": [
{
"query": "",
"variables": "",
"operationName":"",
"extensions":""
},
{...}
],
"map": {}
"..."
} The |
This comment has been minimized.
This comment has been minimized.
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 |
Just migrated my whole project over from graphql-kickstart to "official" spring graphql support and getting stuck |
I'm closing this as superseded by #430, but in summary, as per discussion under #430 (comment), 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 |
I've done a library https://github.com/nkonev/multipart-spring-graphql
|
Support
Upload
scalar in schema defintion and handling multipart body.The text was updated successfully, but these errors were encountered: