diff --git a/build.sbt b/build.sbt index cc48774a24..e2ad47a064 100644 --- a/build.sbt +++ b/build.sbt @@ -12,6 +12,8 @@ val _ = sys.props += ("ZIOHttpLogLevel" -> Debug.ZIOHttpLogLevel) ThisBuild / githubWorkflowEnv += ("JDK_JAVA_OPTIONS" -> "-Xms4G -Xmx8G -XX:+UseG1GC -Xss10M -XX:ReservedCodeCacheSize=1G -XX:NonProfiledCodeHeapSize=512m -Dfile.encoding=UTF-8") ThisBuild / githubWorkflowEnv += ("SBT_OPTS" -> "-Xms4G -Xmx8G -XX:+UseG1GC -Xss10M -XX:ReservedCodeCacheSize=1G -XX:NonProfiledCodeHeapSize=512m -Dfile.encoding=UTF-8") +ThisBuild / resolvers ++= Resolver.sonatypeOssRepos("snapshots") + ThisBuild / githubWorkflowJavaVersions := Seq( JavaSpec.graalvm(Graalvm.Distribution("graalvm"), "17"), JavaSpec.graalvm(Graalvm.Distribution("graalvm"), "21"), diff --git a/project/Dependencies.scala b/project/Dependencies.scala index abca59886e..65007e599b 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -9,7 +9,7 @@ object Dependencies { val ZioCliVersion = "0.5.0" val ZioJsonVersion = "0.7.1" val ZioParserVersion = "0.1.10" - val ZioSchemaVersion = "1.4.1" + val ZioSchemaVersion = "1.5.0" val SttpVersion = "3.3.18" val ZioConfigVersion = "4.0.2" diff --git a/zio-http/jvm/src/test/scala/zio/http/BodySchemaOpsSpec.scala b/zio-http/jvm/src/test/scala/zio/http/BodySchemaOpsSpec.scala index 8dee92abb6..5bbc0a915d 100644 --- a/zio-http/jvm/src/test/scala/zio/http/BodySchemaOpsSpec.scala +++ b/zio-http/jvm/src/test/scala/zio/http/BodySchemaOpsSpec.scala @@ -39,7 +39,9 @@ object BodySchemaOpsSpec extends ZIOHttpSpec { }, test("Body.fromStream") { val body = Body.fromStream(persons) - val expected = """{"name":"John","age":42}{"name":"Jane","age":43}""" + val expected = + """{"name":"John","age":42} + |{"name":"Jane","age":43}""".stripMargin body.asString.map(s => assertTrue(s == expected)) }, test("Body#to") { diff --git a/zio-http/jvm/src/test/scala/zio/http/endpoint/RoundtripSpec.scala b/zio-http/jvm/src/test/scala/zio/http/endpoint/RoundtripSpec.scala index 00062ede9a..3b6fa237f6 100644 --- a/zio-http/jvm/src/test/scala/zio/http/endpoint/RoundtripSpec.scala +++ b/zio-http/jvm/src/test/scala/zio/http/endpoint/RoundtripSpec.scala @@ -369,6 +369,43 @@ object RoundtripSpec extends ZIOHttpSpec { (stream: ZStream[Any, Nothing, Byte]) => stream.runCount.map(c => assert(c)(equalTo(1024L * 1024L))), ) }, + test("string stream output") { + val api = Endpoint(GET / "download").query(HttpCodec.query[Int]("count")).outStream[String] + val route = api.implementHandler { + Handler.fromFunctionZIO { count => + ZIO.succeed(ZStream.fromIterable((0 until count).map(_.toString))) + } + } + + testEndpointZIO( + api, + Routes(route), + 1024 * 1024, + (stream: ZStream[Any, Nothing, String]) => + stream.zipWithIndex + .runFold((true, 0)) { case ((allOk, count), (str, idx)) => + (allOk && str == idx.toString, count + 1) + } + .map { case (allOk, c) => + assertTrue(allOk && c == 1024 * 1024) + }, + ) + }, + test("string output") { + val api = Endpoint(GET / "download").query(HttpCodec.query[String]("param")).out[String] + val route = api.implementHandler { + Handler.fromFunctionZIO { param => + ZIO.succeed(param) + } + } + + testEndpointZIO( + api, + Routes(route), + "test", + (str: String) => assertTrue(str == "test"), + ) + }, test("multi-part input") { val api = Endpoint(POST / "test") .in[String]("name") diff --git a/zio-http/jvm/src/test/scala/zio/http/endpoint/openapi/OpenAPIGenSpec.scala b/zio-http/jvm/src/test/scala/zio/http/endpoint/openapi/OpenAPIGenSpec.scala index 99faac7f3d..06e00af622 100644 --- a/zio-http/jvm/src/test/scala/zio/http/endpoint/openapi/OpenAPIGenSpec.scala +++ b/zio-http/jvm/src/test/scala/zio/http/endpoint/openapi/OpenAPIGenSpec.scala @@ -11,7 +11,7 @@ import zio.schema.{DeriveSchema, Schema} import zio.http.Method.{GET, POST} import zio.http._ import zio.http.codec.PathCodec.string -import zio.http.codec.{ContentCodec, Doc, HttpCodec, HttpContentCodec, QueryCodec} +import zio.http.codec.{ContentCodec, Doc, HttpCodec} import zio.http.endpoint._ object OpenAPIGenSpec extends ZIOSpecDefault { @@ -2854,6 +2854,119 @@ object OpenAPIGenSpec extends ZIOSpecDefault { |""".stripMargin assertTrue(json == toJsonAst(expectedJson)) }, + test("Stream schema") { + val endpoint = Endpoint(RoutePattern.POST / "folder") + .outStream[Int] + val openApi = OpenAPIGen.fromEndpoints(endpoint) + val json = toJsonAst(openApi) + val expectedJson = + """ + |{ + | "openapi" : "3.1.0", + | "info" : { + | "title" : "", + | "version" : "" + | }, + | "paths" : { + | "/folder" : { + | "post" : { + | "responses" : { + | "200" : + | { + | "content" : { + | "application/json" : { + | "schema" : + | { + | "type" : + | "array", + | "items" : { + | "type" : + | "integer", + | "format" : "int32" + | } + | } + | } + | } + | } + | } + | } + | } + | }, + | "components" : { + | + | } + |} + |""".stripMargin + assertTrue(json == toJsonAst(expectedJson)) + }, + test("Stream schema multipart") { + val endpoint = Endpoint(RoutePattern.POST / "folder") + .outCodec( + HttpCodec.contentStream[String]("strings") ++ + HttpCodec.contentStream[Int]("ints"), + ) + val openApi = OpenAPIGen.fromEndpoints(endpoint) + val json = toJsonAst(openApi) + val expectedJson = + """ + |{ + | "openapi" : "3.1.0", + | "info" : { + | "title" : "", + | "version" : "" + | }, + | "paths" : { + | "/folder" : { + | "post" : { + | "responses" : { + | "default" : + | { + | "content" : { + | "multipart/form-data" : { + | "schema" : + | { + | "type" : + | "object", + | "properties" : { + | "strings" : { + | "type" : + | "array", + | "items" : { + | "type" : + | "string" + | } + | }, + | "ints" : { + | "type" : + | "array", + | "items" : { + | "type" : + | "integer", + | "format" : "int32" + | } + | } + | }, + | "additionalProperties" : + | false, + | "required" : [ + | "strings", + | "ints" + | ] + | } + | } + | } + | } + | } + | } + | } + | }, + | "components" : { + | + | } + |} + |""".stripMargin + assertTrue(json == toJsonAst(expectedJson)) + }, test("Lazy schema") { val endpoint = Endpoint(RoutePattern.POST / "lazy") .in[Lazy.A] diff --git a/zio-http/shared/src/main/scala/zio/http/codec/HttpContentCodec.scala b/zio-http/shared/src/main/scala/zio/http/codec/HttpContentCodec.scala index 0c92a45d89..c0223e4fe6 100644 --- a/zio-http/shared/src/main/scala/zio/http/codec/HttpContentCodec.scala +++ b/zio-http/shared/src/main/scala/zio/http/codec/HttpContentCodec.scala @@ -1,11 +1,15 @@ package zio.http.codec +import java.nio.charset.StandardCharsets + import scala.collection.immutable.ListMap import zio._ -import zio.stream.ZPipeline +import zio.stream.{ZChannel, ZPipeline} +import zio.schema.codec.DecodeError.ReadError +import zio.schema.codec.JsonCodec.{JsonDecoder, JsonEncoder} import zio.schema.codec._ import zio.schema.{DeriveSchema, Schema} @@ -273,6 +277,7 @@ object HttpContentCodec { } object json { + private var jsonCodecCache: Map[Schema[_], HttpContentCodec[_]] = Map.empty def only[A](implicit schema: Schema[A]): HttpContentCodec[A] = if (jsonCodecCache.contains(schema)) { @@ -282,10 +287,12 @@ object HttpContentCodec { ListMap( MediaType.application.`json` -> BinaryCodecWithSchema( - config => - JsonCodec.schemaBasedBinaryCodec[A]( - JsonCodec.Config(ignoreEmptyCollections = config.ignoreEmptyCollections), - )(schema), + config => { + JsonCodec.schemaBasedBinaryCodec( + JsonCodec + .Config(ignoreEmptyCollections = config.ignoreEmptyCollections, treatStreamsAsArrays = true), + )(schema) + }, schema, ), ), diff --git a/zio-http/shared/src/main/scala/zio/http/endpoint/openapi/OpenAPIGen.scala b/zio-http/shared/src/main/scala/zio/http/endpoint/openapi/OpenAPIGen.scala index 8506980560..9b12066770 100644 --- a/zio-http/shared/src/main/scala/zio/http/endpoint/openapi/OpenAPIGen.scala +++ b/zio-http/shared/src/main/scala/zio/http/endpoint/openapi/OpenAPIGen.scala @@ -314,7 +314,17 @@ object OpenAPIGen { findName(metadata).orElse(maybeName).getOrElse(throw new Exception("Multipart content without name")) JsonSchema.obj( name -> JsonSchema - .fromZSchema(codec.lookup(mediaType).map(_._2.schema).getOrElse(codec.defaultSchema), referenceType) + .ArrayType( + Some( + JsonSchema + .fromZSchema( + codec.lookup(mediaType).map(_._2.schema).getOrElse(codec.defaultSchema), + referenceType, + ), + ), + None, + uniqueItems = false, + ) .description(descriptionFromMeta) .deprecated(deprecated(metadata)) .nullable(optional(metadata)), @@ -327,7 +337,14 @@ object OpenAPIGen { .nullable(optional(metadata)) case HttpCodec.ContentStream(codec, _, _) => JsonSchema - .fromZSchema(codec.lookup(mediaType).map(_._2.schema).getOrElse(codec.defaultSchema), referenceType) + .ArrayType( + Some( + JsonSchema + .fromZSchema(codec.lookup(mediaType).map(_._2.schema).getOrElse(codec.defaultSchema), referenceType), + ), + None, + uniqueItems = false, + ) .description(descriptionFromMeta) .deprecated(deprecated(metadata)) .nullable(optional(metadata))