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() | |
} |
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
Hey Igor, thanks for this gist, seems like just what I needed :)
Just 2 things:
Thanks again for sharing this!