Skip to content
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

Closed
hantsy opened this issue Jun 28, 2021 · 15 comments
Closed

MultipartFile support #69

hantsy opened this issue Jun 28, 2021 · 15 comments
Labels
in: web Issues related to web handling status: superseded Issue is superseded by another type: enhancement A general enhancement

Comments

@hantsy
Copy link
Contributor

hantsy commented Jun 28, 2021

Support Upload scalar in schema defintion and handling multipart body.

@hantsy hantsy changed the title Multipart support MultipartFile support Jun 28, 2021
@rstoyanchev rstoyanchev added in: web Issues related to web handling type: enhancement A general enhancement labels Jun 28, 2021
@rstoyanchev rstoyanchev added this to the 1.0 M2 milestone Jun 28, 2021
@rstoyanchev
Copy link
Contributor

rstoyanchev commented Jun 28, 2021

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.

@hantsy
Copy link
Contributor Author

hantsy commented Jun 28, 2021

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
Copy link
Contributor Author

hantsy commented Jul 1, 2021

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.

@Teresafrr

This comment has been minimized.

@rstoyanchev rstoyanchev modified the milestones: 1.0.0-M2, 1.0.0-M3 Aug 11, 2021
@rstoyanchev rstoyanchev modified the milestones: 1.0.0-M3, 1.0 Backlog Oct 7, 2021
@MiguelAngelLV
Copy link

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

@Jojoooo1
Copy link

Any news on this feature ?

@bclozel
Copy link
Member

bclozel commented Jun 13, 2022

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

@hantsy
Copy link
Contributor Author

hantsy commented Jun 13, 2022

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

@nkonev
Copy link

nkonev commented Jun 30, 2022

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

@jOphey
Copy link

jOphey commented Jul 4, 2022

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

@hantsy
Copy link
Contributor Author

hantsy commented Jul 5, 2022

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)

@ddinger
Copy link

ddinger commented Oct 26, 2022

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

@rstoyanchev
Copy link
Contributor

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 @RequestMapping methods, which can be in the same @Controller.

@rstoyanchev rstoyanchev closed this as not planned Won't fix, can't repro, duplicate, stale Apr 14, 2023
@rstoyanchev rstoyanchev removed this from the 1.2 Backlog milestone Apr 14, 2023
@rstoyanchev rstoyanchev added the status: superseded Issue is superseded by another label Apr 14, 2023
@nkonev
Copy link

nkonev commented Jul 9, 2023

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

@rstoyanchev
Copy link
Contributor

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
in: web Issues related to web handling status: superseded Issue is superseded by another type: enhancement A general enhancement
Projects
None yet
Development

No branches or pull requests

9 participants