Skip to content

Instantly share code, notes, and snippets.

@ilaborie
Last active February 27, 2021 07:32
Show Gist options
  • Save ilaborie/cde26929dbe685c74682fb01817e37c3 to your computer and use it in GitHub Desktop.
Save ilaborie/cde26929dbe685c74682fb01817e37c3 to your computer and use it in GitHub Desktop.
Handle Multipart GraphQL Request with SpringBoot and graphql-kotlin
import com.expediagroup.graphql.spring.execution.QueryHandler
import com.expediagroup.graphql.spring.model.GraphQLRequest
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.reactive.asFlow
import mu.KotlinLogging
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.core.annotation.Order
import org.springframework.http.MediaType
import org.springframework.http.codec.multipart.Part
import org.springframework.web.reactive.function.server.*
@Configuration
class GraphQLMultipart(
private val queryHandler: QueryHandler,
private val objectMapper: ObjectMapper,
private val partReader: PartToJson
) {
private val logger = KotlinLogging.logger {}
@Bean
@Order(1) // Should be before the [com.expediagroup.graphql.spring.RoutesConfiguration#graphQLRoutes]
fun graphQLRoutesMultipart(): RouterFunction<ServerResponse> = coRouter {
(POST("/graphql") and contentType(MediaType.MULTIPART_FORM_DATA)) { serverRequest ->
val graphQLRequest = parsingMultipart(serverRequest)
val graphQLResult = queryHandler.executeQuery(graphQLRequest)
ok().json().bodyValueAndAwait(graphQLResult)
}
}
@Suppress("BlockingMethodInNonBlockingContext")
private suspend fun parsingMultipart(serverRequest: ServerRequest): GraphQLRequest {
val parts: Map<String, Part> = serverRequest.bodyToFlux(Part::class.java)
.doOnNext { part -> logger.info("Part {}", part.name()) }
.asFlow()
.toList()
.map { it.name() to it }
.toMap()
logger.info("parts: {}", parts)
// Operations
val operations: JsonNode = parts["operations"]
?.let { part ->
objectMapper.readTree(part.inputStream())
} ?: throw IllegalArgumentException("Missing 'operations' part")
// Substitutions
val part = parts["map"]
if (part != null) {
val substitutions = objectMapper.readValue<Map<String, List<String>>>(part.inputStream())
logger.debug("Found substitutions {}", substitutions)
substitutions.forEach { (key, paths) ->
logger.debug("Lookup '{}'", key)
val node = parts[key]?.let { partReader.transform(it) } ?: throw IllegalArgumentException("Part '$key' not found")
paths.forEach { path ->
logger.debug("Apply substitution for '{}' with {} content", path, key)
operations.substitute(path, node)
}
}
}
return objectMapper.treeToValue(operations, GraphQLRequest::class.java)
}
private suspend fun Part.inputStream(): InputStream =
this.content()
.map { it.asInputStream() }
.reduce { a, b -> SequenceInputStream(a, b) }
.awaitFirst()
private fun JsonNode.substitute(paths: String, value: JsonNode): JsonNode =
substituteAux(this, paths.split('.'), value)
private tailrec fun substituteAux(node: JsonNode, paths: List<String>, value: JsonNode): JsonNode {
if (paths.isEmpty()) return node
val (path) = paths
val tail = paths.drop(1)
return if (paths.size == 1) {
when (node) {
is ObjectNode -> node.set(path, value)
is ArrayNode -> node.set(path.toInt(), value) // FIXME could throw IndexOutOfBoundsException
else -> throw IllegalStateException("Path '$path' not found for $node")
}
} else {
val next = node.findNode(path)
substituteAux(next, tail, value)
}
}
private fun JsonNode.findNode(path: String): JsonNode =
when (this) {
is ObjectNode -> this.get(path) // FIXME might return null
is ArrayNode -> this.get(path.toInt()) // FIXME might return null or fail with toInt, or
else -> throw IllegalStateException("Path '$path' not found for $this")
}
}
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.node.TextNode
import kotlinx.coroutines.reactive.awaitFirst
import org.springframework.http.codec.multipart.Part
import org.springframework.stereotype.Component
import java.io.SequenceInputStream
import java.util.*
interface PartToJson {
suspend fun transform(part: Part): JsonNode
}
@Component
class PartToBase64 : PartToJson {
override suspend fun transform(part: Part): JsonNode =
part.content()
.map { it.asInputStream() }
.reduce { a, b -> SequenceInputStream(a, b) }
.map { inputStream ->
val bytes = inputStream.readAllBytes();
val data = Base64.getEncoder().encodeToString(bytes)
TextNode(data)
}
.awaitFirst()
}
@alea-git
Copy link

Hey Igor, thanks for this gist, seems like just what I needed :)

Just 2 things:

  1. As of version 4.x.x QueryHandler has been replaced in favor of GraphQLRequestHandler
  2. It's my first time with GraphQL & Spring Boot and I don't understand how I can declare Multipart parameters
fun updateUserProfile(
  name: String? = null, // Something like: Multipart<String>
  picture: ??? // Something like: Multipart<InputStream>
)

Thanks again for sharing this!

@ilaborie
Copy link
Author

1/ Thanks for this precision, it will help us during the migration to version 4.x.x
2/ The tricks is to use a GraphQL scalar. In your Spring component (@Component) you have:

@GraphQLDescription("Update the user profile")
fun updateUserProfile(
  name: String? = null,
  picture: Upload? = null
) = ...

With

import org.springframework.http.codec.multipart.FilePart
import java.io.InputStream

data class Upload(private val part: FilePart) {

    val filename: String
        get() = part.filename()

    suspend fun inputStream(): InputStream =
        part.inputStream()
}

Then you need to provide a custom SchemaGeneratorHooksProvider to declare Upload as a Scalar.

To transform the binary part to an Upload we have :

private object UploadCoercing : Coercing<Upload, Nothing> {
    override fun parseValue(input: Any?): Upload? =
        try {
            when (input) {
                is FilePart -> Upload(input)
                null -> null
                else -> throw CoercingParseValueException("Expected type ${FilePart::class} but was ${input.javaClass}")
            }
        } catch (e: Exception) {
            throw CoercingParseValueException("Invalid value $input for Upload", e)
        }

    override fun parseLiteral(input: Any?) =
        throw CoercingParseLiteralException("Must use variables to specify Upload values")

    override fun serialize(dataFetcherResult: Any?) =
        throw CoercingSerializeException("Upload is an input-only type")
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment