spring-graphql icon indicating copy to clipboard operation
spring-graphql copied to clipboard

MultipartFile support

Open hantsy opened this issue 3 years ago • 11 comments

Support Upload scalar in schema defintion and handling multipart body.

hantsy avatar Jun 28 '21 10:06 hantsy

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.

rstoyanchev avatar Jun 28 '21 13:06 rstoyanchev

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.

hantsy avatar Jun 28 '21 14:06 hantsy

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.

hantsy avatar Jul 01 '21 03:07 hantsy

Do you care to know about Intel hacks? You can contact intelhackman on telegram.

Teresafrr avatar Jul 10 '21 02:07 Teresafrr

This is the only feature I need to migrate form Graphql kickstart to Spring Graphql

MiguelAngelLV avatar Jun 06 '22 15:06 MiguelAngelLV

Any news on this feature ?

Jojoooo1 avatar Jun 13 '22 12:06 Jojoooo1

@MiguelAngelLV @Jojoooo1 please use the voting feature on the issue description itself, as it helps the team to prioritize issues (and creates less notifications).

bclozel avatar Jun 13 '22 12:06 bclozel

The Graphql Multipart Request spec: https://github.com/jaydenseric/graphql-multipart-request-spec includes sync and async scenario.

hantsy avatar Jun 13 '22 13:06 hantsy

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\"]}"'

nkonev avatar Jun 30 '22 19:06 nkonev

Can confirm: @nkonev s solution works. Big thanks!

jOphey avatar Jul 04 '22 20:07 jOphey

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)

hantsy avatar Jul 05 '22 02:07 hantsy

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 ;-(

ddinger avatar Oct 26 '22 14:10 ddinger

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.

rstoyanchev avatar Apr 14 '23 09:04 rstoyanchev

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

nkonev avatar Jul 09 '23 03:07 nkonev

Nice work @nkonev! I've created #747 to updated the reference documentation where we currently don't have anything at all on the topic.

rstoyanchev avatar Jul 11 '23 09:07 rstoyanchev