From 590de5d58e948102b25ee1fe5615af044f4cde95 Mon Sep 17 00:00:00 2001 From: Grigory Date: Sun, 4 Jul 2021 08:53:08 -0400 Subject: [PATCH] Http4s Module upload support (#945) * Initial Upload support * Make this PR work * Make uploadService independent * Generate UUID via ZIO Random * Change rootUploadPath type * Code cleanup * Use Random.Service * Move uploads into the core project * Add Http4sAdapterSpec * use a test cope variable instead of a string * Change http4s tests blaze bindings * Enable http4s tests in CI * Get rid of asInstanceOf in tests --- .circleci/config.yml | 4 +- .../main/scala/caliban/Http4sAdapter.scala | 179 ++++++++++++++- ...5ed1567650399e58648a6b8340f636243962c0.png | Bin 0 -> 3291 bytes ...b228c6030f11806e380c29c3f5d8db608c399b.txt | 1 + .../scala/caliban/Http4sAdapterSpec.scala | 209 ++++++++++++++++++ .../src/main/scala/caliban/PlayAdapter.scala | 2 +- ...dapterSpec.scala => PlayAdapterSpec.scala} | 3 +- build.sbt | 58 ++--- .../main/scala/caliban/uploads/Upload.scala | 6 +- .../main/scala/caliban/uploads/package.scala | 18 +- 10 files changed, 435 insertions(+), 45 deletions(-) create mode 100644 adapters/http4s/src/test/resources/64498927ff9cd735daefebe7175ed1567650399e58648a6b8340f636243962c0.png create mode 100644 adapters/http4s/src/test/resources/d6359a52607b6953b2cb96be00b228c6030f11806e380c29c3f5d8db608c399b.txt create mode 100644 adapters/http4s/src/test/scala/caliban/Http4sAdapterSpec.scala rename adapters/play/src/test/scala/caliban/{AdapterSpec.scala => PlayAdapterSpec.scala} (99%) rename {adapters/play => core}/src/main/scala/caliban/uploads/Upload.scala (98%) rename {adapters/play => core}/src/main/scala/caliban/uploads/package.scala (58%) diff --git a/.circleci/config.yml b/.circleci/config.yml index 27687cb5e..c2e133b89 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -21,7 +21,7 @@ jobs: - checkout - restore_cache: key: sbtcache - - run: sbt ++2.12.13! core/test http4s/compile akkaHttp/test finch/compile play/test zioHttp/compile examples/compile catsInterop/compile benchmarks/compile tools/test codegenSbt/test clientJVM/test monixInterop/compile tapirInterop/test federation/test codegenSbt/scripted + - run: sbt ++2.12.13! core/test http4s/test akkaHttp/test finch/compile play/test zioHttp/compile examples/compile catsInterop/compile benchmarks/compile tools/test codegenSbt/test clientJVM/test monixInterop/compile tapirInterop/test federation/test codegenSbt/scripted - save_cache: key: sbtcache paths: @@ -35,7 +35,7 @@ jobs: - checkout - restore_cache: key: sbtcache - - run: sbt ++2.13.6! core/test http4s/compile akkaHttp/test finch/compile play/test zioHttp/compile examples/compile catsInterop/compile monixInterop/compile tapirInterop/test clientJVM/test federation/test + - run: sbt ++2.13.6! core/test http4s/test akkaHttp/test finch/compile play/test zioHttp/compile examples/compile catsInterop/compile monixInterop/compile tapirInterop/test clientJVM/test federation/test - save_cache: key: sbtcache paths: diff --git a/adapters/http4s/src/main/scala/caliban/Http4sAdapter.scala b/adapters/http4s/src/main/scala/caliban/Http4sAdapter.scala index b88daf139..70c0f8416 100644 --- a/adapters/http4s/src/main/scala/caliban/Http4sAdapter.scala +++ b/adapters/http4s/src/main/scala/caliban/Http4sAdapter.scala @@ -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, @@ -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) @@ -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, diff --git a/adapters/http4s/src/test/resources/64498927ff9cd735daefebe7175ed1567650399e58648a6b8340f636243962c0.png b/adapters/http4s/src/test/resources/64498927ff9cd735daefebe7175ed1567650399e58648a6b8340f636243962c0.png new file mode 100644 index 0000000000000000000000000000000000000000..e68fdff1963b61dcfed306f77771d35beb738b1b GIT binary patch literal 3291 zcma)9eNM~Yg44HM?v z&Ji34N4iC3hO_wxnMg_NYp}eU(1c^)k%>CYFqQaN&-1=_W13%o?4EO<_rA}^@A>_n z_ub~_GSuV7M2-=OMB^BZYJo^}4-3B?qep@zt-IkRk!Wl+qe^)p&+TnJsqM|2klOKr z=*5%=PM+aH4@S;B)L6T_L24^_Xhh+J+Po$8(OUTwpKkJ2r?b5|(Npo*fGjLzi%uFd zk2i9ug`9-hVzT>R8xzgp(^xehMHV$~|7+)D{AxNIMi!-B|Ld-a@U7IV$y90F+w7wm zD=}uBRaJDp}ylk z?5p}TqI@BGipb@qT3*=a=n6HT;N%Xgg6ZD}IY{3k5fv=tg;`)d0b9v^0b5t7ZdA}- zi^5UHHA%Wci<=~vT5&vUma)Z9M3W>UM>mSeV27=(ZM)D$Q^~ohEkR#~2$=v` z`3ZuD12Tz2ARor`{ICh>-K;b%0S@5z!vrY@+w65mXs_bgVw+6MlV0fzb@v8R&a?nJ z5Pi*b=4#q$Z}QvTP{SsOepomjK8F+%T$@U6NrF{UP3aD?&6zn2kSFUtQtV9kFOv{&H>wpFSHxCyD zU&piHWVXd#n*N?KcD(uR^*w*-UfjRk{;~s^BCclo2Y~4oy9tHklw<=DfEjz4Ei@@u zK*R^y#_J5OWXRuYe}e~7lMNQ^^E9lGE;y0xrZMQ!zhbz~748$Z;6jni+xslmG7r&< z(-*;73Pg~_z>gF;yx^$>R#Y@wxrS7*EUi~^OM^|`^Fo}rDyqYlK2rEb+ zO9nmOLFlfax-cR|ycB#Z7e56$s3xL?%z&N-aOSYWL_>`+Zr1`3UIxw^;06j0odJbv za7rLX>ph{z4s=@FU3+(Hn4q_Zt!b7qe-D10%dPkg?~r>54NK6zwd;;EfSl6|GF=dHf* zpN{%AT&Z;(8#==dO=>uhA5u7^zC5w}(_c@C6BqL@y<6eyJgCcO>egSXi9&;|(l;`W zv=tgfXe$s7+HHV7;h2Fg-EwUah>?neOzAS<7TJj+MZy!8P;!9sK`sff2rn`cH5I1^ z(%mN5?bd=4=^iJQ3|_1~lh#0dmMIfVtqQ|@ji4j`0s^QAjL5R~vuL%~TZ3I4t$A@JDBd zF#Tdvl{n@of4EVTG>R#gIA_G9CFL0(Sr-}w1D^EFTc82X0sj6Xam*7GEDAz(JXuMj zI|@FBW>sTxqf9UVCsJ(rKsxF7#uQe|$nr|4|rCP)LMr*&@wInqfC zxRApUiba)@faGK)3KyseM=&`$@PHjox&%_NKY_;~8HIF$!huaYPeIv*`p<=n65c`l zh|ucf|&34$LZU|69LKw*QxCZ|{6QW4ss$Az{}3+~_fIJL~3S6V<%c z^ac;tK`ZwITC4VC!&Re($(`2;Ta4T$n6%sM0V|Kz<}?`q(%jbJ`QqAu$$pGW1qq^G zn29iiof7;g!xDbyXqbc43nJF!uh+N_%wS{RZ5QwTvoERT3V45~U8lO=)siSkT+L@? zU&^Nupn8K2G~w-g6OugCDE0Owy-p^AQ)1-eWAHnQw-U{H^`z46@O_dRnFR&AUCZz* zmQF9Y1%nSWoG!_RCP`)NA?QCDEO&@%20GFK3P^6xtCxE7Ltc&a^d)(suZY*ySJvvX zvSV$Y^Td!pXNl+A#;j~tOYf@Yv~L!MjRZ1vijq|HXBp&hYQf{c+Y+0e@OVOlpSet9eT| F{s-7)L2Cd2 literal 0 HcmV?d00001 diff --git a/adapters/http4s/src/test/resources/d6359a52607b6953b2cb96be00b228c6030f11806e380c29c3f5d8db608c399b.txt b/adapters/http4s/src/test/resources/d6359a52607b6953b2cb96be00b228c6030f11806e380c29c3f5d8db608c399b.txt new file mode 100644 index 000000000..2f5a0f825 --- /dev/null +++ b/adapters/http4s/src/test/resources/d6359a52607b6953b2cb96be00b228c6030f11806e380c29c3f5d8db608c399b.txt @@ -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. \ No newline at end of file diff --git a/adapters/http4s/src/test/scala/caliban/Http4sAdapterSpec.scala b/adapters/http4s/src/test/scala/caliban/Http4sAdapterSpec.scala new file mode 100644 index 000000000..b97f055fe --- /dev/null +++ b/adapters/http4s/src/test/scala/caliban/Http4sAdapterSpec.scala @@ -0,0 +1,209 @@ +package caliban + +import caliban.GraphQL.graphQL +import caliban.schema.GenericSchema +import caliban.uploads.{ Upload, Uploads } +import cats.effect.Blocker +import cats.syntax.semigroupk._ +import io.circe.parser.parse +import io.circe.generic.auto._ +import org.http4s.syntax.all._ +import org.http4s.server.Server +import org.http4s.server.blaze.BlazeServerBuilder +import zio._ +import zio.blocking.Blocking +import zio.clock.Clock +import zio.console.Console +import zio.internal.Platform +import sttp.client3._ +import sttp.client3.asynchttpclient.zio.{ AsyncHttpClientZioBackend, _ } +import zio.random.Random +import zio.test._ +import zio.test.Assertion._ +import zio.test.environment.TestEnvironment +import zio.interop.catz._ +import sttp.model._ + +import java.io.File +import java.math.BigInteger +import java.net.URL +import java.nio.file.Paths +import java.security.MessageDigest + +case class Response[A](data: A) +case class UploadFile(uploadFile: TestAPI.File) +case class UploadFiles(uploadFiles: List[TestAPI.File]) + +object Service { + def uploadFile(file: Upload): ZIO[Uploads with Blocking, Throwable, TestAPI.File] = + for { + bytes <- file.allBytes + meta <- file.meta + } yield TestAPI.File( + Service.hex(Service.sha256(bytes.toArray)), + meta.map(_.path.toAbsolutePath.toString).getOrElse(""), + meta.map(_.fileName).getOrElse(""), + meta.flatMap(_.contentType).getOrElse("") + ) + + def uploadFiles(files: List[Upload]): ZIO[Uploads with Blocking, Throwable, List[TestAPI.File]] = + ZIO.collectAllPar( + for { + file <- files + } yield for { + bytes <- file.allBytes + meta <- file.meta + } yield TestAPI.File( + Service.hex(Service.sha256(bytes.toArray)), + meta.map(_.path.toAbsolutePath.toString).getOrElse(""), + meta.map(_.fileName).getOrElse(""), + meta.flatMap(_.contentType).getOrElse("") + ) + ) + + def sha256(b: Array[Byte]) = + MessageDigest.getInstance("SHA-256").digest(b) + + def hex(b: Array[Byte]): String = + String.format("%032x", new BigInteger(1, b)) +} + +case class UploadFileArgs(file: Upload) +case class UploadFilesArgs(files: List[Upload]) + +object TestAPI extends GenericSchema[Blocking with Uploads with Console with Clock] { + val api: GraphQL[Blocking with Uploads with Console with Clock] = + graphQL( + RootResolver( + Queries(args => UIO("stub")), + Mutations(args => Service.uploadFile(args.file), args => Service.uploadFiles(args.files)) + ) + ) + + implicit val uploadFileArgsSchema = gen[UploadFileArgs] + implicit val mutationsSchema = gen[Mutations] + implicit val queriesSchema = gen[Queries] + + case class File(hash: String, path: String, filename: String, mimetype: String) + + case class Queries(stub: Unit => UIO[String]) + + case class Mutations( + uploadFile: UploadFileArgs => ZIO[Blocking with Uploads, Throwable, File], + uploadFiles: UploadFilesArgs => ZIO[Blocking with Uploads, Throwable, List[File]] + ) +} + +object Http4sAdapterSpec extends DefaultRunnableSpec { + type R = Console with Clock with Blocking with Random with Uploads + implicit val runtime: Runtime[R] = + Runtime.unsafeFromLayer( + Console.live ++ Clock.live ++ Blocking.live ++ Random.live ++ Uploads.empty, + Platform.default + ) + + val blocker = Blocker.liftExecutionContext(runtime.platform.executor.asEC) + + val uri = Uri.unsafeParse("http://127.0.0.1:8089/") + + val apiLayer: RLayer[R, Has[Server[RIO[R, *]]]] = + (for { + interpreter <- TestAPI.api.interpreter.toManaged_ + server <- BlazeServerBuilder(runtime.platform.executor.asEC) + .bindHttp(uri.port.get, uri.host.get) + .withHttpApp( + (Http4sAdapter.makeHttpUploadService( + interpreter, + Paths.get(System.getProperty("java.io.tmpdir")), + blocker + ) <+> Http4sAdapter + .makeHttpService(interpreter)).orNotFound + ) + .resource + .toManagedZIO + } yield server).toLayer + + val specLayer = ZLayer.requires[ZEnv] ++ Uploads.empty >>> apiLayer + + def spec: ZSpec[TestEnvironment, Any] = + suite("Requests")( + testM("multipart request with one file") { + val fileHash = "64498927ff9cd735daefebe7175ed1567650399e58648a6b8340f636243962c0" + val fileName: String = s"$fileHash.png" + val fileURL: URL = getClass.getResource(s"/$fileName") + + val query: String = + """{ "query": "mutation ($file: Upload!) { uploadFile(file: $file) { hash, path, filename, mimetype } }", "variables": { "file": null }}""" + + val request = basicRequest + .post(uri) + .multipartBody( + multipart("operations", query).contentType("application/json"), + multipart("map", """{ "0": ["variables.file"] }"""), + multipartFile("0", new File(fileURL.getPath)).contentType("image/png") + ) + .contentType("multipart/form-data") + + val body = for { + response <- send( + request.mapResponse { strRespOrError => + for { + resp <- strRespOrError + json <- parse(resp) + fileUploadResp <- json.as[Response[UploadFile]] + } yield fileUploadResp + } + ) + } yield response.body + + assertM(body.map(_.toOption.get.data.uploadFile))( + hasField("hash", (f: TestAPI.File) => f.hash, equalTo(fileHash)) && + hasField("filename", (f: TestAPI.File) => f.filename, equalTo(fileName)) && + hasField("mimetype", (f: TestAPI.File) => f.mimetype, equalTo("image/png")) + ) + }, + testM("multipart request with several files") { + val file1Hash = "64498927ff9cd735daefebe7175ed1567650399e58648a6b8340f636243962c0" + val file1Name: String = s"$file1Hash.png" + val file1URL: URL = getClass.getResource(s"/$file1Name") + + val file2Hash = "d6359a52607b6953b2cb96be00b228c6030f11806e380c29c3f5d8db608c399b" + val file2Name: String = s"$file2Hash.txt" + val file2URL: URL = getClass.getResource(s"/$file2Name") + + val query: String = + """{ "query": "mutation ($files: [Upload!]!) { uploadFiles(files: $files) { hash, path, filename, mimetype } }", "variables": { "files": [null, null] }}""" + + val request = basicRequest + .post(uri) + .contentType("multipart/form-data") + .multipartBody( + multipart("operations", query).contentType("application/json"), + multipart("map", """{ "0": ["variables.files.0"], "1": ["variables.files.1"]}"""), + multipartFile("0", new File(file1URL.getPath)).contentType("image/png"), + multipartFile("1", new File(file2URL.getPath)).contentType("text/plain") + ) + + val body = for { + response <- send( + request.mapResponse { strRespOrError => + for { + resp <- strRespOrError + json <- parse(resp) + fileUploadResp <- json.as[Response[UploadFiles]] + } yield fileUploadResp + } + ) + } yield response.body + + assertM(body.map(_.toOption.get.data.uploadFiles))( + hasField("hash", (fl: List[TestAPI.File]) => fl(0).hash, equalTo(file1Hash)) && + hasField("hash", (fl: List[TestAPI.File]) => fl(1).hash, equalTo(file2Hash)) && + hasField("filename", (fl: List[TestAPI.File]) => fl(0).filename, equalTo(file1Name)) && + hasField("filename", (fl: List[TestAPI.File]) => fl(1).filename, equalTo(file2Name)) && + hasField("mimetype", (fl: List[TestAPI.File]) => fl(0).mimetype, equalTo("image/png")) && + hasField("mimetype", (fl: List[TestAPI.File]) => fl(1).mimetype, equalTo("text/plain")) + ) + } + ).provideCustomLayerShared(AsyncHttpClientZioBackend.layer() ++ specLayer).mapError(TestFailure.fail) +} diff --git a/adapters/play/src/main/scala/caliban/PlayAdapter.scala b/adapters/play/src/main/scala/caliban/PlayAdapter.scala index 4714df18b..b5cb1d068 100644 --- a/adapters/play/src/main/scala/caliban/PlayAdapter.scala +++ b/adapters/play/src/main/scala/caliban/PlayAdapter.scala @@ -99,7 +99,7 @@ trait PlayAdapter[R <: Has[_] with Blocking with Random] { FileMeta( uuid.toString, fp.ref.path, - fp.dispositionType, + Option(fp.dispositionType), fp.contentType, fp.filename, fp.fileSize diff --git a/adapters/play/src/test/scala/caliban/AdapterSpec.scala b/adapters/play/src/test/scala/caliban/PlayAdapterSpec.scala similarity index 99% rename from adapters/play/src/test/scala/caliban/AdapterSpec.scala rename to adapters/play/src/test/scala/caliban/PlayAdapterSpec.scala index 004ed0b65..0c51e1019 100644 --- a/adapters/play/src/test/scala/caliban/AdapterSpec.scala +++ b/adapters/play/src/test/scala/caliban/PlayAdapterSpec.scala @@ -4,7 +4,6 @@ import java.io.File import java.math.BigInteger import java.net.URL import java.security.MessageDigest - import caliban.GraphQL.graphQL import caliban.schema.GenericSchema import caliban.uploads._ @@ -90,7 +89,7 @@ object TestAPI extends GenericSchema[Blocking with Uploads with Console with Clo ) } -object AdapterSpec extends DefaultRunnableSpec { +object PlayAdapterSpec extends DefaultRunnableSpec { val runtime: Runtime[Console with Clock with Blocking with Random with Uploads] = Runtime.unsafeFromLayer( Console.live ++ Clock.live ++ Blocking.live ++ Random.live ++ Uploads.empty, diff --git a/build.sbt b/build.sbt index e2fb9ea4b..0a841df81 100644 --- a/build.sbt +++ b/build.sbt @@ -125,8 +125,8 @@ lazy val core = project "dev.zio" %% "zio" % zioVersion, "dev.zio" %% "zio-streams" % zioVersion, "dev.zio" %% "zio-query" % zqueryVersion, - "dev.zio" %% "zio-test" % zioVersion % "test", - "dev.zio" %% "zio-test-sbt" % zioVersion % "test", + "dev.zio" %% "zio-test" % zioVersion % Test, + "dev.zio" %% "zio-test-sbt" % zioVersion % Test, "io.circe" %% "circe-core" % circeVersion % Optional, "io.circe" %% "circe-parser" % circeVersion % Test ) @@ -150,8 +150,8 @@ lazy val tools = project "com.softwaremill.sttp.client3" %% "async-http-client-backend-zio" % sttpVersion, "dev.zio" %% "zio-config" % zioConfigVersion, "dev.zio" %% "zio-config-magnolia" % zioConfigVersion, - "dev.zio" %% "zio-test" % zioVersion % "test", - "dev.zio" %% "zio-test-sbt" % zioVersion % "test" + "dev.zio" %% "zio-test" % zioVersion % Test, + "dev.zio" %% "zio-test-sbt" % zioVersion % Test ) ) .dependsOn(core, clientJVM) @@ -165,7 +165,7 @@ lazy val codegenSbt = project crossScalaVersions := Seq(scala212), testFrameworks := Seq(new TestFramework("zio.test.sbt.ZTestFramework")), libraryDependencies ++= Seq( - "dev.zio" %% "zio-test-sbt" % zioVersion % "test" + "dev.zio" %% "zio-test-sbt" % zioVersion % Test ) ) .enablePlugins(SbtPlugin) @@ -219,8 +219,8 @@ lazy val tapirInterop = project testFrameworks := Seq(new TestFramework("zio.test.sbt.ZTestFramework")), libraryDependencies ++= Seq( "com.softwaremill.sttp.tapir" %% "tapir-core" % tapirVersion, - "dev.zio" %% "zio-test" % zioVersion % "test", - "dev.zio" %% "zio-test-sbt" % zioVersion % "test", + "dev.zio" %% "zio-test" % zioVersion % Test, + "dev.zio" %% "zio-test-sbt" % zioVersion % Test, compilerPlugin(("org.typelevel" %% "kind-projector" % "0.13.0").cross(CrossVersion.full)) ) ) @@ -232,14 +232,20 @@ lazy val http4s = project .settings(commonSettings) .settings( crossScalaVersions -= scala3, + testFrameworks := Seq(new TestFramework("zio.test.sbt.ZTestFramework")), libraryDependencies ++= Seq( - "dev.zio" %% "zio-interop-cats" % zioInteropCatsVersion, - "org.typelevel" %% "cats-effect" % catsEffectVersion, - "org.http4s" %% "http4s-dsl" % http4sVersion, - "org.http4s" %% "http4s-circe" % http4sVersion, - "org.http4s" %% "http4s-blaze-server" % http4sVersion, - "io.circe" %% "circe-parser" % circeVersion, - compilerPlugin(("org.typelevel" %% "kind-projector" % "0.13.0").cross(CrossVersion.full)) + "dev.zio" %% "zio-interop-cats" % zioInteropCatsVersion, + "org.typelevel" %% "cats-effect" % catsEffectVersion, + "org.http4s" %% "http4s-dsl" % http4sVersion, + "org.http4s" %% "http4s-circe" % http4sVersion, + "org.http4s" %% "http4s-blaze-server" % http4sVersion, + "io.circe" %% "circe-parser" % circeVersion, + "dev.zio" %% "zio-test" % zioVersion % Test, + "dev.zio" %% "zio-test-sbt" % zioVersion % Test, + "com.softwaremill.sttp.client3" %% "async-http-client-backend-zio" % sttpVersion % Test, + "com.softwaremill.sttp.client3" %% "circe" % sttpVersion % Test, + "io.circe" %% "circe-generic" % circeVersion % Test, + compilerPlugin(("org.typelevel" %% "kind-projector" % "0.13.0").cross(CrossVersion.full)) ) ) .dependsOn(core) @@ -274,8 +280,8 @@ lazy val akkaHttp = project "de.heikoseeberger" %% "akka-http-circe" % "1.36.0" % Optional, "de.heikoseeberger" %% "akka-http-play-json" % "1.36.0" % Optional, "de.heikoseeberger" %% "akka-http-zio-json" % "1.36.0" % Optional, - "dev.zio" %% "zio-test" % zioVersion % "test", - "dev.zio" %% "zio-test-sbt" % zioVersion % "test", + "dev.zio" %% "zio-test" % zioVersion % Test, + "dev.zio" %% "zio-test-sbt" % zioVersion % Test, compilerPlugin(("org.typelevel" %% "kind-projector" % "0.13.0").cross(CrossVersion.full)) ) ) @@ -306,12 +312,12 @@ lazy val play = project testFrameworks := Seq(new TestFramework("zio.test.sbt.ZTestFramework")), libraryDependencies ++= Seq( "com.typesafe.play" %% "play" % playVersion, - "dev.zio" %% "zio-test" % zioVersion % "test", - "dev.zio" %% "zio-test-sbt" % zioVersion % "test", - "com.typesafe.play" %% "play-akka-http-server" % playVersion % "test", - "io.circe" %% "circe-generic" % circeVersion % "test", - "com.softwaremill.sttp.client3" %% "async-http-client-backend-zio" % sttpVersion % "test", - "com.softwaremill.sttp.client3" %% "circe" % sttpVersion % "test" + "dev.zio" %% "zio-test" % zioVersion % Test, + "dev.zio" %% "zio-test-sbt" % zioVersion % Test, + "com.typesafe.play" %% "play-akka-http-server" % playVersion % Test, + "io.circe" %% "circe-generic" % circeVersion % Test, + "com.softwaremill.sttp.client3" %% "async-http-client-backend-zio" % sttpVersion % Test, + "com.softwaremill.sttp.client3" %% "circe" % sttpVersion % Test ) ) .dependsOn(core) @@ -327,8 +333,8 @@ lazy val client = crossProject(JSPlatform, JVMPlatform) "io.circe" %%% "circe-parser" % circeVersion, "com.softwaremill.sttp.client3" %%% "core" % sttpVersion, "com.softwaremill.sttp.client3" %%% "circe" % sttpVersion, - "dev.zio" %%% "zio-test" % zioVersion % "test", - "dev.zio" %%% "zio-test-sbt" % zioVersion % "test" + "dev.zio" %%% "zio-test" % zioVersion % Test, + "dev.zio" %%% "zio-test-sbt" % zioVersion % Test ) ) lazy val clientJVM = client.jvm @@ -363,8 +369,8 @@ lazy val clientLaminext = crossProject(JSPlatform) "io.laminext" %%% "fetch-circe" % laminextVersion, "io.laminext" %%% "websocket" % laminextVersion, "io.laminext" %%% "websocket-circe" % laminextVersion, - "dev.zio" %%% "zio-test" % zioVersion % "test", - "dev.zio" %%% "zio-test-sbt" % zioVersion % "test" + "dev.zio" %%% "zio-test" % zioVersion % Test, + "dev.zio" %%% "zio-test-sbt" % zioVersion % Test ) ) diff --git a/adapters/play/src/main/scala/caliban/uploads/Upload.scala b/core/src/main/scala/caliban/uploads/Upload.scala similarity index 98% rename from adapters/play/src/main/scala/caliban/uploads/Upload.scala rename to core/src/main/scala/caliban/uploads/Upload.scala index 099589cf7..769a2a79d 100644 --- a/adapters/play/src/main/scala/caliban/uploads/Upload.scala +++ b/core/src/main/scala/caliban/uploads/Upload.scala @@ -1,7 +1,5 @@ package caliban.uploads -import java.nio.file.Path - import caliban.InputValue.ListValue import caliban.Value.{ NullValue, StringValue } import caliban.schema.Annotations.GQLName @@ -10,6 +8,8 @@ import zio.blocking.Blocking import zio.stream.{ ZSink, ZStream } import zio.{ Chunk, RIO, UIO, URIO, ZIO } +import java.nio.file.Path + @GQLName("Upload") final case class Upload(name: String) { @@ -24,7 +24,7 @@ final case class Upload(name: String) { case class FileMeta( id: String, path: Path, - dispositionType: String, + dispositionType: Option[String], contentType: Option[String], fileName: String, fileSize: Long diff --git a/adapters/play/src/main/scala/caliban/uploads/package.scala b/core/src/main/scala/caliban/uploads/package.scala similarity index 58% rename from adapters/play/src/main/scala/caliban/uploads/package.scala rename to core/src/main/scala/caliban/uploads/package.scala index 390e64d35..0e387e7ae 100644 --- a/adapters/play/src/main/scala/caliban/uploads/package.scala +++ b/core/src/main/scala/caliban/uploads/package.scala @@ -1,10 +1,10 @@ package caliban -import java.nio.file.Files - import zio.blocking.Blocking import zio.stream.{ Stream, ZStream } -import zio.{ Has, Layer, UIO, ZIO, ZLayer } +import zio.{ Has, Layer, UIO, URIO, ZIO, ZLayer } + +import java.nio.file.Files package object uploads { type Uploads = Has[Multipart] @@ -12,28 +12,28 @@ package object uploads { object Uploads { val empty: Layer[Nothing, Uploads] = ZLayer.succeed(new Multipart { - override def stream(name: String): ZStream[Blocking, Throwable, Byte] = Stream.empty + def stream(name: String): ZStream[Blocking, Throwable, Byte] = Stream.empty - override def file(name: String): ZIO[Any, Nothing, Option[FileMeta]] = ZIO.none + def file(name: String): UIO[Option[FileMeta]] = ZIO.none }) def stream(name: String): ZStream[Uploads with Blocking, Throwable, Byte] = ZStream.accessStream(_.get.stream(name)) - def fileMeta(name: String): ZIO[Uploads, Nothing, Option[FileMeta]] = + def fileMeta(name: String): URIO[Uploads, Option[FileMeta]] = ZIO.accessM(_.get.file(name)) - def handler(fileHandle: String => UIO[Option[FileMeta]]): ZIO[Any, Nothing, Uploads] = + def handler(fileHandle: String => UIO[Option[FileMeta]]): UIO[Uploads] = ZIO .succeed(new Multipart { - override def stream(name: String): ZStream[Blocking, Throwable, Byte] = + def stream(name: String): ZStream[Blocking, Throwable, Byte] = for { ref <- ZStream.fromEffectOption(fileHandle(name).some) bytes <- ZStream .fromInputStream(Files.newInputStream(ref.path)) } yield bytes - override def file(name: String): ZIO[Any, Nothing, Option[FileMeta]] = + def file(name: String): UIO[Option[FileMeta]] = fileHandle(name) }) .asService