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

Http4s Module upload support #945

Merged
merged 13 commits into from
Jul 4, 2021
179 changes: 177 additions & 2 deletions adapters/http4s/src/main/scala/caliban/Http4sAdapter.scala
Original file line number Diff line number Diff line change
Expand Up @@ -3,33 +3,47 @@ package caliban
import caliban.ResponseValue.{ ObjectValue, StreamValue }
import caliban.Value.NullValue
import caliban.execution.QueryExecution
import caliban.uploads._
import cats.arrow.FunctionK
import cats.data.{ Kleisli, OptionT }
import cats.effect.Effect
import cats.syntax.either._
import cats.syntax.traverse._
import cats.effect.{ Blocker, Effect }
import cats.effect.syntax.all._
import cats.~>
import fs2.{ Pipe, Stream }
import fs2.text.utf8Decode
import io.circe.Decoder.Result
import io.circe.Json
import io.circe.{ DecodingFailure, Json }
import io.circe.parser._
import io.circe.syntax._
import org.http4s._
import org.http4s.circe.CirceEntityCodec._
import org.http4s.dsl.Http4sDsl
import org.http4s.headers.`Content-Disposition`
import org.http4s.implicits._
import org.http4s.server.websocket.WebSocketBuilder
import org.http4s.websocket.WebSocketFrame
import org.http4s.websocket.WebSocketFrame.Text
import org.http4s.multipart.{ Multipart, Part }
import zio.Exit.Failure
import zio._
import zio.clock.Clock
import zio.duration.Duration
import zio.interop.catz._
import zio.random.Random

import java.io.File
import java.nio.file.Path
import scala.util.Try

object Http4sAdapter {

val `application/graphql`: MediaType = mediaType"application/graphql"

private def parsePath(path: String): List[Either[String, Int]] =
path.split('.').map(c => Try(c.toInt).toEither.left.map(_ => c)).toList

private def executeToJson[R, E](
interpreter: GraphQLInterpreter[R, E],
request: GraphQLRequest,
Expand All @@ -46,6 +60,24 @@ object Http4sAdapter {
)
.foldCause(cause => GraphQLResponse(NullValue, cause.defects).asJson, _.asJson)

private def executeToJsonWithUpload[R <: Has[_], E](
interpreter: GraphQLInterpreter[R, E],
request: GraphQLRequest,
skipValidation: Boolean,
enableIntrospection: Boolean,
queryExecution: QueryExecution,
fileHandle: ZLayer[Any, Nothing, Uploads]
): URIO[R, Json] =
interpreter
.executeRequest(
request,
skipValidation = skipValidation,
enableIntrospection = enableIntrospection,
queryExecution
)
.foldCause(cause => GraphQLResponse(NullValue, cause.defects).asJson, _.asJson)
.provideSomeLayer[R](fileHandle)

@deprecated("Use makeHttpService instead", "0.4.0")
def makeRestService[R, E](interpreter: GraphQLInterpreter[R, E]): HttpRoutes[RIO[R, *]] =
makeHttpService(interpreter)
Expand Down Expand Up @@ -75,6 +107,149 @@ object Http4sAdapter {
params.get("extensions")
)

private def parseGraphQLRequest(body: String): Result[GraphQLRequest] =
parse(body).flatMap(_.as[GraphQLRequest]).leftMap(e => DecodingFailure(e.getMessage, Nil))

private def parsePaths(map: Map[String, Seq[String]]): List[(String, List[Either[String, Int]])] =
map.map { case (k, v) => k -> v.map(parsePath).toList }.toList.flatMap(kv => kv._2.map(kv._1 -> _))

def makeHttpUploadService[R <: Has[_] with Random, E](
interpreter: GraphQLInterpreter[R, E],
rootUploadPath: Path,
blocker: Blocker,
skipValidation: Boolean = false,
enableIntrospection: Boolean = true,
queryExecution: QueryExecution = QueryExecution.Parallel
): HttpRoutes[RIO[R, *]] = {
object dsl extends Http4sDsl[RIO[R, *]]
import dsl._

HttpRoutes.of[RIO[R, *]] {
case req @ POST -> Root if req.contentType.exists(_.mediaType.isMultipart) =>
def getFileRefs(
parts: Vector[Part[RIO[R, *]]]
)(random: Random.Service): RIO[R, Map[String, (File, Part[RIO[R, *]])]] =
parts
.filter(_.headers.exists(_.value.contains("filename")))
.traverse { p =>
p.name.traverse { n =>
random.nextUUID.flatMap { uuid =>
val path = rootUploadPath.resolve(uuid.toString)
p.body
.through(
fs2.io.file.writeAll(
path,
blocker
)
)
.compile
.foldMonoid
.as((n, path.toFile -> p))
}
}
}
.map(_.flatten.toMap)

def getUploadQuery(
operations: GraphQLRequest,
map: Map[String, Seq[String]],
parts: Vector[Part[RIO[R, *]]]
)(random: Random.Service): RIO[R, GraphQLUploadRequest] = {
val fileRefs = getFileRefs(parts)(random)
val filePaths = parsePaths(map)

fileRefs.map { fileRef =>
def handler(handle: String): UIO[Option[FileMeta]] =
fileRef
.get(handle)
.traverse { case (file, fp) =>
random.nextUUID.asSomeError
.map(uuid =>
FileMeta(
uuid.toString,
file.getAbsoluteFile.toPath,
fp.headers.get(`Content-Disposition`).map(_.dispositionType),
fp.contentType.map { ct =>
val mt = ct.mediaType
s"${mt.mainType}/${mt.subType}"
},
fp.filename.getOrElse(file.getName),
file.length
)
)
.optional
}
.map(_.flatten)

GraphQLUploadRequest(
operations,
filePaths,
Uploads.handler(handler)
)
}
}

req.decode[Multipart[RIO[R, *]]] { m =>
// First bit is always a standard graphql payload, it comes from the `operations` field
val optOperations =
m.parts.find(_.name.contains("operations")).traverse {
_.body
.through(utf8Decode)
.compile
.foldMonoid
.flatMap(body => Task.fromEither(parseGraphQLRequest(body)))
}

// Second bit is the mapping field
val optMap =
m.parts.find(_.name.contains("map")).traverse {
_.body
.through(utf8Decode)
.compile
.foldMonoid
.flatMap { body =>
Task.fromEither(
parse(body)
.flatMap(_.as[Map[String, Seq[String]]])
.leftMap(msg => msg.fillInStackTrace())
)
}
}

for {
ooperations <- optOperations
omap <- optMap
random <- ZIO.service[Random.Service]
result <- (ooperations, omap) match {
case (Some(operations), Some(map)) =>
for {
query <- getUploadQuery(operations, map, m.parts)(random)
queryWithTracing =
req.headers
.find(r =>
r.name == GraphQLRequest.`apollo-federation-include-trace` && r.value == GraphQLRequest.ftv1
)
.foldLeft(query.remap)((q, _) => q.withFederatedTracing)

result <- executeToJsonWithUpload(
interpreter,
queryWithTracing,
skipValidation = skipValidation,
enableIntrospection = enableIntrospection,
queryExecution,
query.fileHandle.toLayerMany
)
response <- Ok(result)
} yield response

case (None, _) => BadRequest("Missing multipart field 'operations'")
case (_, None) => BadRequest("Missing multipart field 'map'")
}
} yield result
}
}
}

def makeHttpService[R, E](
interpreter: GraphQLInterpreter[R, E],
skipValidation: Boolean = false,
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent et massa quam. Etiam maximus, nibh eu facilisis facilisis, tortor ex vestibulum tellus, ac semper elit justo eget purus. Duis iaculis metus elit, a semper sem sodales sed. Nam bibendum gravida gravida. Suspendisse ut ipsum iaculis, posuere enim ac, maximus nisi. Sed eleifend purus nunc. Quisque vel purus ligula. Pellentesque id ligula imperdiet, pulvinar magna sed, ultricies dolor. Donec ac neque mauris. In non est magna. Vivamus porttitor consequat est, quis pharetra odio viverra sit amet. Mauris pretium nunc lobortis nulla ultricies, at tempus quam sodales. Nam a odio dictum, ultricies elit tempor, fermentum urna. Cras lacus sem, luctus sed elementum non, malesuada et massa.
Loading