Last active
February 27, 2021 07:32
-
-
Save ilaborie/cde26929dbe685c74682fb01817e37c3 to your computer and use it in GitHub Desktop.
Handle Multipart GraphQL Request with SpringBoot and graphql-kotlin
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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") | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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:With
Then you need to provide a custom
SchemaGeneratorHooksProvider
to declareUpload
as a Scalar.To transform the binary part to an
Upload
we have :