1. Introduction

GraphQL has transformed the way developers interact with APIs, offering a streamlined, powerful alternative to traditional REST approaches.

However, handling file uploads with GraphQL in Java, particularly within a Spring Boot application, requires a bit of setup due to the nature of GraphQL’s handling of binary data. In this tutorial, we’ll go through setting up file uploads using GraphQL in a Spring Boot application.

2. File Upload Using GraphQL vs. HTTP

In the realm of developing GraphQL APIs with Spring Boot, adhering to best practices often involves leveraging standard HTTP requests for handling file uploads.

By managing file uploads via dedicated HTTP endpoints and then linking these uploads to GraphQL mutations through identifiers like URLs or IDs, developers can effectively minimize the complexity and processing overhead typically associated with embedding file uploads directly within GraphQL queries. Such an approach not only simplifies the upload process but also helps avoid potential issues related to file size constraints and serialization demands, contributing to a more streamlined and scalable application structure.

Nonetheless, certain situations necessitate directly incorporating file uploads within GraphQL queries. In such scenarios, integrating file upload capabilities into GraphQL APIs demands a tailored strategy that carefully balances user experience with application performance. Therefore, we need to define a specialized scalar type for handling uploads. Additionally, this method involves the deployment of specific mechanisms for validating input and mapping uploaded files to the correct variables within GraphQL operations. Furthermore, uploading a file requires the multipart/form-data content type of a request body, so we need to implement a custom HttpHandler.

3. File Upload Implementation In GraphQL

This section outlines a comprehensive approach to integrating file upload functionality within a GraphQL API using Spring Boot. Through a series of steps, we’ll explore the creation and configuration of essential components designed to handle file uploads directly through GraphQL queries.

In this guide, we’ll utilize a specialized starter package to enable GraphQL support in a Spring Boot Application:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-graphql</artifactId>
    <version>3.3.0</version>
</dependency>

3.1. Custom Upload Scalar Type

First, we define a custom scalar type, Upload, within our GraphQL schema. The introduction of the Upload scalar type extends GraphQL’s capability to handle binary file data, enabling the API to accept file uploads. The custom scalar serves as a bridge between the client’s file upload requests and the server’s processing logic, ensuring a type-safe and structured approach to handling file uploads.

Let’s define it in the src/main/resources/file-upload/graphql/upload.graphqls file:

scalar Upload

type Mutation {
    uploadFile(file: Upload!, description: String!): String
}

type Query {
    getFile: String
}

In the definition above, we also have the description parameter to illustrate how to pass additional data along with a file.

3.2. UploadCoercing Implementation

In the context of GraphQL, coercing refers to the process of converting a value from one type to another. This is particularly important when dealing with custom scalar types, like our Upload type. In that case, we need to define how values associated with this type are parsed (converted from input) and serialized (converted to output).

The UploadCoercing implementation is crucial for managing these conversions in a way that aligns with the operational requirements of file uploads within a GraphQL API.

Let’s define the UploadCoercing class to handle the Upload type correctly:

public class UploadCoercing implements Coercing<MultipartFile, Void> {
    @Override
    public Void serialize(Object dataFetcherResult) {
        throw new CoercingSerializeException("Upload is an input-only type and cannot be serialized");
    }

    @Override
    public MultipartFile parseValue(Object input) {
        if (input instanceof MultipartFile) {
            return (MultipartFile) input;
        }
        throw new CoercingParseValueException("Expected type MultipartFile but was " + input.getClass().getName());
    }

    @Override
    public MultipartFile parseLiteral(Object input) {
        throw new CoercingParseLiteralException("Upload is an input-only type and cannot be parsed from literals");
    }
}

As we can see, this involves converting an input value (from a query or mutation) into a Java type that our application can understand and work with. For the Upload scalar, this means taking the file input from the client and ensuring it’s correctly represented as a MultipartFile in our server-side code.

3.3. MultipartGraphQlHttpHandler: Handling Multipart Requests

GraphQL, in its standard specification, is designed to handle JSON-formatted requests. This format works well for typical CRUD operations but falls short when dealing with file uploads, which are inherently binary data and not easily represented in JSON. The multipart/form-data content type is the standard for submitting forms and uploading files over HTTP, but handling these requests requires parsing the request body differently than a standard GraphQL request.

By default, GraphQL servers do not understand or handle multipart requests directly, often leading to a 404 Not Found response for such requests.  Therefore, we need to implement a handler that bridges that gap, ensuring that the multipart/form-data content type is correctly handled by our application.

Let’s implement this class:

public ServerResponse handleMultipartRequest(ServerRequest serverRequest) throws ServletException {
    HttpServletRequest httpServletRequest = serverRequest.servletRequest();

    Map<String, Object> inputQuery = Optional.ofNullable(this.<Map<String, Object>>deserializePart(httpServletRequest, "operations", MAP_PARAMETERIZED_TYPE_REF.getType())).orElse(new HashMap<>());

    final Map<String, Object> queryVariables = getFromMapOrEmpty(inputQuery, "variables");
    final Map<String, Object> extensions = getFromMapOrEmpty(inputQuery, "extensions");

    Map<String, MultipartFile> fileParams = readMultipartFiles(httpServletRequest);

    Map<String, List<String>> fileMappings = Optional.ofNullable(this.<Map<String, List<String>>>deserializePart(httpServletRequest, "map", LIST_PARAMETERIZED_TYPE_REF.getType())).orElse(new HashMap<>());

    fileMappings.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");

    Map<String, Object> body = new HashMap<>();
    body.put("query", query);
    body.put("operationName", StringUtils.hasText(opName) ? opName : "");
    body.put("variables", queryVariables);
    body.put("extensions", extensions);

    WebGraphQlRequest graphQlRequest = new WebGraphQlRequest(serverRequest.uri(), serverRequest.headers().asHttpHeaders(), body, 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);
}

The handleMultipartRequest method within the MultipartGraphQlHttpHandler class processes multipart/form-data requests. At first, we extract the HTTP request from the server request object, which allows access to the multipart files and other form data included in the request. Then, we attempt to deserialize the “operations” part of the request, which contains the GraphQL query or mutation, along with the “map” part, which specifies how to map files to the variables in the GraphQL operation.

After deserializing these parts, the method proceeds to read the actual file uploads from the request, using the mappings defined in the “map” to associate each uploaded file with the correct variable in the GraphQL operation.

3.4. Implementing File Upload DataFetcher

As we have the uploadFile mutation for uploading files, we need to implement specific logic to accept a file and additional metadata from the client and save the file.
In GraphQL, every field within the schema is linked to a DataFetcher, a component responsible for retrieving the data associated with that field.

While some fields might require specialized DataFetcher implementations capable of fetching data from databases or other persistent storage systems, many fields simply extract data from in-memory objects. This extraction often relies on the field names and utilizes standard Java object patterns to access the required data.

Let’s implement our implementation of the DataFetcher interface:

@Component
public class FileUploadDataFetcher implements DataFetcher<String> {
    private final FileStorageService fileStorageService;

    public FileUploadDataFetcher(FileStorageService fileStorageService) {
        this.fileStorageService = fileStorageService;
    }

    @Override
    public String get(DataFetchingEnvironment environment) {
        MultipartFile file = environment.getArgument("file");
        String description = environment.getArgument("description");
        String storedFilePath = fileStorageService.store(file, description);
        return String.format("File stored at: %s, Description: %s", storedFilePath, description);
    }
}

When the get method of this data fetcher is invoked by the GraphQL framework it retrieves the file and an optional description from the mutation’s arguments. It then calls the FileStorageService to store the file, passing along the file and its description.

4. Spring Boot Configuration for GraphQL Upload Support

The integration of file upload into a GraphQL API using Spring Boot is a multi-faceted process that requires the configuration of several key components.

Let’s define the configuration according to our implementation:

@Configuration
public class MultipartGraphQlWebMvcAutoconfiguration {

    private final FileUploadDataFetcher fileUploadDataFetcher;

    public MultipartGraphQlWebMvcAutoconfiguration(FileUploadDataFetcher fileUploadDataFetcher) {
        this.fileUploadDataFetcher = fileUploadDataFetcher;
    }

    @Bean
    public RuntimeWiringConfigurer runtimeWiringConfigurer() {
        return (builder) -> builder
          .type(newTypeWiring("Mutation").dataFetcher("uploadFile", fileUploadDataFetcher))
          .scalar(GraphQLScalarType.newScalar()
            .name("Upload")
            .coercing(new UploadCoercing())
            .build());
    }

    @Bean
    @Order(1)
    public RouterFunction<ServerResponse> graphQlMultipartRouterFunction(
      GraphQlProperties properties,
      WebGraphQlHandler webGraphQlHandler,
      ObjectMapper objectMapper
    ) {
        String path = properties.getPath();
        RouterFunctions.Builder builder = RouterFunctions.route();
        MultipartGraphQlHttpHandler graphqlMultipartHandler = new MultipartGraphQlHttpHandler(webGraphQlHandler, new MappingJackson2HttpMessageConverter(objectMapper));
        builder = builder.POST(path, RequestPredicates.contentType(MULTIPART_FORM_DATA)
          .and(RequestPredicates.accept(SUPPORTED_MEDIA_TYPES.toArray(new MediaType[]{}))), graphqlMultipartHandler::handleMultipartRequest);
        return builder.build();
    }
}

RuntimeWiringConfigurer plays a pivotal role in this setup, granting us the ability to link the GraphQL schema’s operations such as mutations and queries with the corresponding data fetchers. This linkage is crucial for the uploadFile mutation, where we apply the FileUploadDataFetcher to handle the file upload process.

Furthermore, the RuntimeWiringConfigurer is instrumental in defining and integrating the custom Upload scalar type within the GraphQL schema. This scalar type, associated with the UploadCoercing, enables the GraphQL API to understand and correctly handle file data, ensuring that files are properly serialized and deserialized as part of the upload process.

To address the handling of incoming requests, particularly those carrying the multipart/form-data content type necessary for file uploads, we employ the RouterFunction bean definition. This function is adept at intercepting these specific types of requests, allowing us to process them through the MultipartGraphQlHttpHandler. This handler is key to parsing multipart requests, extracting files, and mapping them to the appropriate variables in the GraphQL operation, thereby facilitating the execution of file upload mutations. We also apply the correct order by using the @Order(1) annotation.

5. Testing File Upload Using Postman

Testing file upload functionality in a GraphQL API via Postman requires a non-standard approach, as the built-in GraphQL payload format doesn’t directly support multipart/form-data requests, which are essential for uploading files. Instead, we must construct a multipart request manually, mimicking the way a client would upload a file alongside a GraphQL mutation.
In the Body tab, the selection should be set to form-data. Three key-value pairs are required: operations, map, and the file variable with the key name according to the map value.

For the operations key, the value should be a JSON object that encapsulates the GraphQL query and variables, with the file part represented by null as a placeholder. The type for this part remains as Text.

{"query": "mutation UploadFile($file: Upload!, $description: String!) { uploadFile(file: $file, description: $description) }","variables": {"file": null,"description": "Sample file description"}}

Next, the map key requires a value that is another JSON object. This time, mapping the file variable to the form field containing the file. If we attach a file to the key 0, then the map would explicitly associate this key with the file variable in the GraphQL variables, ensuring the server correctly interprets which part of the form data contains the file. This value has the Text type as well.

{"0": ["variables.file"]}

Finally, we add a file itself with a key that matches its reference in the map object.  In our case, we use 0 as the key for this value. Unlike the previous text values, the type for this part is File.

After executing the request, we should get a JSON response:

{
    "data": {
        "uploadFile": "File stored at: File uploaded successfully: C:\\Development\\TutorialsBaeldung\\tutorials\\uploads\\2023-06-21_14-22.bmp with description: Sample file description, Description: Sample file description"
    }
}

6. Conclusion

In this article, we’ve explored how to add file upload functionality to a GraphQL API using Spring Boot. We started by introducing a custom scalar type called Upload, which handles file data in GraphQL mutations.

We then implemented the MultipartGraphQlHttpHandler class to manage multipart/form-data requests, necessary for uploading files through GraphQL mutations. Unlike standard GraphQL requests that use JSON, file uploads need multipart requests to handle binary file data.

The FileUploadDataFetcher class processes the uploadFile mutation. It extracts and stores uploaded files and sends a clear response to the client about the file upload status.

Usually, it’s more efficient to use a plain HTTP request for file uploads and pass the resulting ID through a GraphQL query. However, sometimes directly using GraphQL for file uploads is necessary.

As always, code snippets are available over on GitHub.