diff --git a/build.sbt b/build.sbt index bd5bda7e16..956cf2106a 100644 --- a/build.sbt +++ b/build.sbt @@ -350,6 +350,7 @@ lazy val sbtZioHttpGrpcTests = (project in file("sbt-zio-http-grpc-tests")) .settings(publishSetting(false)) .settings( Test / skip := (CrossVersion.partialVersion(scalaVersion.value) != Some((2, 12))), + ideSkipProject := (CrossVersion.partialVersion(scalaVersion.value) != Some((2, 12))), libraryDependencies ++= Seq( `zio-test-sbt`, `zio-test`, @@ -417,3 +418,8 @@ lazy val docs = project .dependsOn(zioHttpJVM) .enablePlugins(WebsitePlugin) .dependsOn(zioHttpTestkit) + +Global / excludeLintKeys ++= Set( + sbtZioHttpGrpcTests / autoAPIMappings, + ideSkipProject, +) diff --git a/project/plugins.sbt b/project/plugins.sbt index 3dfe8c5cd2..0295afcde9 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -14,4 +14,7 @@ addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.16.0") addSbtPlugin("org.portable-scala" % "sbt-scalajs-crossproject" % "1.3.2") addSbtPlugin("com.thesamet" % "sbt-protoc" % "1.0.7") addSbtPlugin("com.thesamet" % "sbt-protoc-gen-project" % "0.1.8") + +addSbtPlugin("org.jetbrains.scala" % "sbt-ide-settings" % "1.1.2") + libraryDependencies += "com.thesamet.scalapb" %% "compilerplugin" % "0.11.15" diff --git a/zio-http-cli/src/main/scala/zio/http/endpoint/cli/CliEndpoint.scala b/zio-http-cli/src/main/scala/zio/http/endpoint/cli/CliEndpoint.scala index 0067004bb9..8d85692b63 100644 --- a/zio-http-cli/src/main/scala/zio/http/endpoint/cli/CliEndpoint.scala +++ b/zio-http-cli/src/main/scala/zio/http/endpoint/cli/CliEndpoint.scala @@ -127,11 +127,8 @@ private[cli] object CliEndpoint { case HttpCodec.Path(pathCodec, _) => CliEndpoint(url = HttpOptions.Path(pathCodec) :: List()) - case HttpCodec.Query(name, textCodec, _, _) => - textCodec.asInstanceOf[TextCodec[_]] match { - case TextCodec.Constant(value) => CliEndpoint(url = HttpOptions.QueryConstant(name, value) :: List()) - case _ => CliEndpoint(url = HttpOptions.Query(name, textCodec) :: List()) - } + case HttpCodec.Query(name, codec, _, _) => + CliEndpoint(url = HttpOptions.Query(name, codec) :: List()) case HttpCodec.Status(_, _) => CliEndpoint.empty diff --git a/zio-http-cli/src/main/scala/zio/http/endpoint/cli/HttpOptions.scala b/zio-http-cli/src/main/scala/zio/http/endpoint/cli/HttpOptions.scala index 3be96b5156..be0c02aba9 100644 --- a/zio-http-cli/src/main/scala/zio/http/endpoint/cli/HttpOptions.scala +++ b/zio-http-cli/src/main/scala/zio/http/endpoint/cli/HttpOptions.scala @@ -3,6 +3,7 @@ package zio.http.endpoint.cli import scala.language.implicitConversions import scala.util.Try +import zio.Chunk import zio.cli._ import zio.json.ast._ @@ -261,11 +262,12 @@ private[cli] object HttpOptions { } - final case class Query(override val name: String, textCodec: TextCodec[_], doc: Doc = Doc.empty) extends URLOptions { + final case class Query(override val name: String, codec: BinaryCodecWithSchema[_], doc: Doc = Doc.empty) + extends URLOptions { self => override val tag = "?" + name - lazy val options: Options[_] = optionsFromTextCodec(textCodec)(name) + lazy val options: Options[_] = optionsFromSchema(codec)(name) override def ??(doc: Doc): Query = self.copy(doc = self.doc + doc) @@ -289,6 +291,76 @@ private[cli] object HttpOptions { } + private[cli] def optionsFromSchema[A](codec: BinaryCodecWithSchema[A]): String => Options[A] = + codec.schema match { + case Schema.Primitive(standardType, _) => + standardType match { + case StandardType.UnitType => + _ => Options.Empty + case StandardType.StringType => + Options.text + case StandardType.BoolType => + Options.boolean(_) + case StandardType.ByteType => + Options.integer(_).map(_.toByte) + case StandardType.ShortType => + Options.integer(_).map(_.toShort) + case StandardType.IntType => + Options.integer(_).map(_.toInt) + case StandardType.LongType => + Options.integer(_).map(_.toLong) + case StandardType.FloatType => + Options.decimal(_).map(_.toFloat) + case StandardType.DoubleType => + Options.decimal(_).map(_.toDouble) + case StandardType.BinaryType => + Options.text(_).map(_.getBytes).map(Chunk.fromArray) + case StandardType.CharType => + Options.text(_).map(_.charAt(0)) + case StandardType.UUIDType => + Options.text(_).map(java.util.UUID.fromString) + case StandardType.CurrencyType => + Options.text(_).map(java.util.Currency.getInstance) + case StandardType.BigDecimalType => + Options.decimal(_).map(_.bigDecimal) + case StandardType.BigIntegerType => + Options.integer(_).map(_.bigInteger) + case StandardType.DayOfWeekType => + Options.integer(_).map(i => java.time.DayOfWeek.of(i.toInt)) + case StandardType.MonthType => + Options.text(_).map(java.time.Month.valueOf) + case StandardType.MonthDayType => + Options.text(_).map(java.time.MonthDay.parse) + case StandardType.PeriodType => + Options.text(_).map(java.time.Period.parse) + case StandardType.YearType => + Options.integer(_).map(i => java.time.Year.of(i.toInt)) + case StandardType.YearMonthType => + Options.text(_).map(java.time.YearMonth.parse) + case StandardType.ZoneIdType => + Options.text(_).map(java.time.ZoneId.of) + case StandardType.ZoneOffsetType => + Options.text(_).map(java.time.ZoneOffset.of) + case StandardType.DurationType => + Options.text(_).map(java.time.Duration.parse) + case StandardType.InstantType => + Options.instant(_) + case StandardType.LocalDateType => + Options.localDate(_) + case StandardType.LocalTimeType => + Options.localTime(_) + case StandardType.LocalDateTimeType => + Options.localDateTime(_) + case StandardType.OffsetTimeType => + Options.text(_).map(java.time.OffsetTime.parse) + case StandardType.OffsetDateTimeType => + Options.text(_).map(java.time.OffsetDateTime.parse) + case StandardType.ZonedDateTimeType => + Options.text(_).map(java.time.ZonedDateTime.parse) + } + case schema => throw new NotImplementedError(s"Schema $schema not yet supported") + } + private[cli] def optionsFromTextCodec[A](textCodec: TextCodec[A]): (String => Options[A]) = textCodec match { case TextCodec.UUIDCodec => diff --git a/zio-http-cli/src/test/scala/zio/http/endpoint/cli/CommandGen.scala b/zio-http-cli/src/test/scala/zio/http/endpoint/cli/CommandGen.scala index ac69bffc7c..7625f97843 100644 --- a/zio-http-cli/src/test/scala/zio/http/endpoint/cli/CommandGen.scala +++ b/zio-http-cli/src/test/scala/zio/http/endpoint/cli/CommandGen.scala @@ -47,7 +47,7 @@ object CommandGen { case _: HttpOptions.Constant => false case _ => true }.map { - case HttpOptions.Path(pathCodec, _) => + case HttpOptions.Path(pathCodec, _) => pathCodec.segments.toList.flatMap { case segment => getSegment(segment) match { case (_, "") => Nil @@ -55,12 +55,12 @@ object CommandGen { case (name, codec) => s"${getName(name, "")} $codec" :: Nil } } - case HttpOptions.Query(name, textCodec, _) => - getType(textCodec) match { + case HttpOptions.Query(name, codec, _) => + getType(codec) match { case "" => s"[${getName(name, "")}]" :: Nil case codec => s"${getName(name, "")} $codec" :: Nil } - case _ => Nil + case _ => Nil }.foldRight(List[String]())(_ ++ _) val headersOptions = cliEndpoint.headers.filter { @@ -121,6 +121,45 @@ object CommandGen { case _ => "" } + def getType[A](codec: BinaryCodecWithSchema[A]): String = + codec.schema match { + case Schema.Primitive(standardType, _) => + standardType match { + case StandardType.UnitType => "" + case StandardType.StringType => "text" + case StandardType.BoolType => "bool" + case StandardType.ByteType => "integer" + case StandardType.ShortType => "integer" + case StandardType.IntType => "integer" + case StandardType.LongType => "integer" + case StandardType.FloatType => "decimal" + case StandardType.DoubleType => "decimal" + case StandardType.BinaryType => "binary" + case StandardType.CharType => "text" + case StandardType.UUIDType => "text" + case StandardType.CurrencyType => "currency" + case StandardType.BigDecimalType => "decimal" + case StandardType.BigIntegerType => "integer" + case StandardType.DayOfWeekType => "integer" + case StandardType.MonthType => "text" + case StandardType.MonthDayType => "text" + case StandardType.PeriodType => "text" + case StandardType.YearType => "integer" + case StandardType.YearMonthType => "text" + case StandardType.ZoneIdType => "text" + case StandardType.ZoneOffsetType => "text" + case StandardType.DurationType => "text" + case StandardType.InstantType => "instant" + case StandardType.LocalDateType => "date" + case StandardType.LocalTimeType => "time" + case StandardType.LocalDateTimeType => "datetime" + case StandardType.OffsetTimeType => "time" + case StandardType.OffsetDateTimeType => "datetime" + case StandardType.ZonedDateTimeType => "datetime" + } + case _ => "" + } + def getPrimitive(schema: Schema[_]): String = schema match { case Schema.Primitive(standardType, _) => diff --git a/zio-http-cli/src/test/scala/zio/http/endpoint/cli/EndpointGen.scala b/zio-http-cli/src/test/scala/zio/http/endpoint/cli/EndpointGen.scala index 9c5bb97b2c..153bb24507 100644 --- a/zio-http-cli/src/test/scala/zio/http/endpoint/cli/EndpointGen.scala +++ b/zio-http-cli/src/test/scala/zio/http/endpoint/cli/EndpointGen.scala @@ -3,9 +3,12 @@ package zio.http.endpoint.cli import zio.ZNothing import zio.test._ +import zio.schema.{Schema, StandardType} + import zio.http._ import zio.http.codec.HttpCodec.Query.QueryParamHint import zio.http.codec._ +import zio.http.codec.internal.TextBinaryCodec import zio.http.endpoint._ import zio.http.endpoint.cli.AuxGen._ import zio.http.endpoint.cli.CliRepr.CliReprOf @@ -99,13 +102,12 @@ object EndpointGen { } lazy val anyQuery: Gen[Any, CliReprOf[Codec[_]]] = - Gen.alphaNumericStringBounded(1, 30).zip(anyTextCodec).map { case (name, codec) => + Gen.alphaNumericStringBounded(1, 30).zip(anyStandardType).map { case (name, schema0) => + val schema = schema0.asInstanceOf[Schema[Any]] + val codec = BinaryCodecWithSchema(TextBinaryCodec.fromSchema(schema), schema) CliRepr( HttpCodec.Query(name, codec, QueryParamHint.Any), - codec match { - case TextCodec.Constant(value) => CliEndpoint(url = HttpOptions.QueryConstant(name, value) :: Nil) - case _ => CliEndpoint(url = HttpOptions.Query(name, codec) :: Nil) - }, + CliEndpoint(url = HttpOptions.Query(name, codec) :: Nil), ) } diff --git a/zio-http-cli/src/test/scala/zio/http/endpoint/cli/OptionsGen.scala b/zio-http-cli/src/test/scala/zio/http/endpoint/cli/OptionsGen.scala index 07e0b08629..2604ba4f44 100644 --- a/zio-http-cli/src/test/scala/zio/http/endpoint/cli/OptionsGen.scala +++ b/zio-http-cli/src/test/scala/zio/http/endpoint/cli/OptionsGen.scala @@ -4,8 +4,11 @@ import zio._ import zio.cli._ import zio.test.Gen +import zio.schema.Schema + import zio.http._ import zio.http.codec._ +import zio.http.codec.internal.TextBinaryCodec import zio.http.endpoint.cli.AuxGen._ import zio.http.endpoint.cli.CliRepr._ @@ -30,6 +33,11 @@ object OptionsGen { .optionsFromTextCodec(textCodec)(name) .map(value => textCodec.encode(value)) + def encodeOptions[A](name: String, codec: BinaryCodecWithSchema[A]): Options[String] = + HttpOptions + .optionsFromSchema(codec)(name) + .map(value => codec.codec.encode(value).asString) + lazy val anyBodyOption: Gen[Any, CliReprOf[Options[Retriever]]] = Gen .alphaNumericStringBounded(1, 30) @@ -76,25 +84,22 @@ object OptionsGen { }, Gen .alphaNumericStringBounded(1, 30) - .zip(anyTextCodec) - .map { - case (name, TextCodec.Constant(value)) => - CliRepr( - Options.Empty.map(_ => value), - CliEndpoint(url = HttpOptions.QueryConstant(name, value) :: Nil), - ) - case (name, codec) => - CliRepr( - encodeOptions(name, codec), - CliEndpoint(url = HttpOptions.Query(name, codec) :: Nil), - ) + .zip(anyStandardType.map { s => + val schema = s.asInstanceOf[Schema[Any]] + BinaryCodecWithSchema(TextBinaryCodec.fromSchema(schema), schema) + }) + .map { case (name, codec) => + CliRepr( + encodeOptions(name, codec), + CliEndpoint(url = HttpOptions.Query(name, codec) :: Nil), + ) }, ) lazy val anyMethod: Gen[Any, CliReprOf[Method]] = - Gen.fromIterable(List(Method.GET, Method.DELETE, Method.POST, Method.PUT)).map { case method => - CliRepr(method, CliEndpoint(methods = method)) - } + Gen + .fromIterable(List(Method.GET, Method.DELETE, Method.POST, Method.PUT)) + .map(method => CliRepr(method, CliEndpoint(methods = method))) lazy val anyCliEndpoint: Gen[Any, CliReprOf[Options[CliRequest]]] = Gen diff --git a/zio-http-example/src/main/scala/example/SSEServer.scala b/zio-http-example/src/main/scala/example/SSEServer.scala index cf9d30beef..4348f71901 100644 --- a/zio-http-example/src/main/scala/example/SSEServer.scala +++ b/zio-http-example/src/main/scala/example/SSEServer.scala @@ -3,7 +3,7 @@ package example import java.time.LocalDateTime import java.time.format.DateTimeFormatter.ISO_LOCAL_TIME -import zio.{ExitCode, Schedule, URIO, ZIOAppDefault, durationInt} +import zio._ import zio.stream.ZStream @@ -24,3 +24,22 @@ object SSEServer extends ZIOAppDefault { Server.serve(app).provide(Server.default).exitCode } } + +object SSEClient extends ZIOAppDefault { + + override def run: ZIO[Any with ZIOAppArgs with Scope, Any, Any] = + ZIO + .scoped(for { + client <- ZIO.service[Client] + response <- client + .url(url"http://localhost:8080") + .request( + Request(method = Method.GET, url = url"http://localhost:8080/sse", body = Body.empty) + .addHeader(Header.Accept(MediaType.text.`event-stream`)), + ) + _ <- response.body.asServerSentEvents[String].foreach { event => + ZIO.logInfo(event.data) + } + } yield ()) + .provide(ZClient.default) +} diff --git a/zio-http-example/src/main/scala/example/ServerSentEventAsJsonEndpoint.scala b/zio-http-example/src/main/scala/example/ServerSentEventAsJsonEndpoint.scala index 0f671050d7..c4c3d77b42 100644 --- a/zio-http-example/src/main/scala/example/ServerSentEventAsJsonEndpoint.scala +++ b/zio-http-example/src/main/scala/example/ServerSentEventAsJsonEndpoint.scala @@ -1,6 +1,6 @@ package example -import java.time.{Instant, LocalDateTime} +import java.time.Instant import zio._ @@ -10,11 +10,10 @@ import zio.schema.{DeriveSchema, Schema} import zio.http._ import zio.http.codec.HttpCodec -import zio.http.endpoint.Endpoint import zio.http.endpoint.EndpointMiddleware.None +import zio.http.endpoint.{Endpoint, EndpointExecutor, EndpointLocator} object ServerSentEventAsJsonEndpoint extends ZIOAppDefault { - import HttpCodec._ case class Payload(timeStamp: Instant, message: String) @@ -26,7 +25,9 @@ object ServerSentEventAsJsonEndpoint extends ZIOAppDefault { ZStream.repeatWithSchedule(ServerSentEvent(Payload(Instant.now(), "message")), Schedule.spaced(1.second)) val sseEndpoint: Endpoint[Unit, Unit, ZNothing, ZStream[Any, Nothing, ServerSentEvent[Payload]], None] = - Endpoint(Method.GET / "sse").outStream[ServerSentEvent[Payload]] + Endpoint(Method.GET / "sse") + .outStream[ServerSentEvent[Payload]] + .inCodec(HttpCodec.header(Header.Accept).const(Header.Accept(MediaType.text.`event-stream`))) val sseRoute = sseEndpoint.implementHandler(Handler.succeed(stream)) @@ -37,3 +38,17 @@ object ServerSentEventAsJsonEndpoint extends ZIOAppDefault { } } + +object ServerSentEventAsJsonEndpointClient extends ZIOAppDefault { + val locator: EndpointLocator = EndpointLocator.fromURL(url"http://localhost:8080") + + private val invocation = ServerSentEventAsJsonEndpoint.sseEndpoint(()) + + override def run: ZIO[Any with ZIOAppArgs with Scope, Any, Any] = + (for { + client <- ZIO.service[Client] + executor = EndpointExecutor(client, locator, ZIO.unit) + stream <- executor(invocation) + _ <- stream.foreach(event => ZIO.logInfo(event.data.toString)) + } yield ()).provideSome[Scope](ZClient.default) +} diff --git a/zio-http-example/src/main/scala/example/ServerSentEventEndpoint.scala b/zio-http-example/src/main/scala/example/ServerSentEventEndpoint.scala index 2621cbb614..fc27c99e9b 100644 --- a/zio-http-example/src/main/scala/example/ServerSentEventEndpoint.scala +++ b/zio-http-example/src/main/scala/example/ServerSentEventEndpoint.scala @@ -9,24 +9,41 @@ import zio.stream.ZStream import zio.http._ import zio.http.codec.HttpCodec -import zio.http.endpoint.Endpoint import zio.http.endpoint.EndpointMiddleware.None +import zio.http.endpoint.{Endpoint, EndpointExecutor, EndpointLocator, Invocation} object ServerSentEventEndpoint extends ZIOAppDefault { - import HttpCodec._ + + val sseEndpoint: Endpoint[Unit, Unit, ZNothing, ZStream[Any, Nothing, ServerSentEvent[String]], None] = + Endpoint(Method.GET / "sse") + .outStream[ServerSentEvent[String]](MediaType.text.`event-stream`) + .inCodec(HttpCodec.header(Header.Accept).const(Header.Accept(MediaType.text.`event-stream`))) val stream: ZStream[Any, Nothing, ServerSentEvent[String]] = ZStream.repeatWithSchedule(ServerSentEvent(ISO_LOCAL_TIME.format(LocalDateTime.now)), Schedule.spaced(1.second)) - val sseEndpoint: Endpoint[Unit, Unit, ZNothing, ZStream[Any, Nothing, ServerSentEvent[String]], None] = - Endpoint(Method.GET / "sse").outStream[ServerSentEvent[String]] - val sseRoute = sseEndpoint.implementHandler(Handler.succeed(stream)) - val routes: Routes[Any, Response] = sseRoute.toRoutes + val routes: Routes[Any, Response] = + sseRoute.toRoutes @@ Middleware.requestLogging(logRequestBody = true) @@ Middleware.debug override def run: ZIO[Any with ZIOAppArgs with Scope, Any, Any] = { Server.serve(routes).provide(Server.default).exitCode } } + +object ServerSentEventEndpointClient extends ZIOAppDefault { + val locator: EndpointLocator = EndpointLocator.fromURL(url"http://localhost:8080") + + private val invocation: Invocation[Unit, Unit, ZNothing, ZStream[Any, Nothing, ServerSentEvent[String]], None] = + ServerSentEventEndpoint.sseEndpoint(()) + + override def run: ZIO[Any with ZIOAppArgs with Scope, Any, Any] = + (for { + client <- ZIO.service[Client] + executor = EndpointExecutor(client, locator, ZIO.unit) + stream <- executor(invocation) + _ <- stream.foreach(event => ZIO.logInfo(event.data)) + } yield ()).provideSome[Scope](ZClient.default) +} diff --git a/zio-http-gen/src/test/scala/zio/http/gen/scala/CodeGenSpec.scala b/zio-http-gen/src/test/scala/zio/http/gen/scala/CodeGenSpec.scala index be1ce84075..1aafb2bc2e 100644 --- a/zio-http-gen/src/test/scala/zio/http/gen/scala/CodeGenSpec.scala +++ b/zio-http-gen/src/test/scala/zio/http/gen/scala/CodeGenSpec.scala @@ -8,7 +8,7 @@ import scala.meta._ import scala.meta.parsers._ import scala.util.{Failure, Success, Try} -import zio.json.JsonDecoder +import zio.json.{JsonDecoder, JsonEncoder} import zio.test.Assertion.{hasSameElements, isFailure, isSuccess, throws} import zio.test.TestAspect.{blocking, flaky} import zio.test.TestFailure.fail diff --git a/zio-http/jvm/src/test/scala/zio/http/ServerSentEventSpec.scala b/zio-http/jvm/src/test/scala/zio/http/ServerSentEventSpec.scala new file mode 100644 index 0000000000..f97e349e2f --- /dev/null +++ b/zio-http/jvm/src/test/scala/zio/http/ServerSentEventSpec.scala @@ -0,0 +1,50 @@ +package zio.http + +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter.ISO_LOCAL_TIME + +import scala.util.Try + +import zio._ +import zio.test._ + +import zio.stream.ZStream + +object ServerSentEventSpec extends ZIOSpecDefault { + + val stream: ZStream[Any, Nothing, ServerSentEvent[String]] = + ZStream.repeatWithSchedule(ServerSentEvent(ISO_LOCAL_TIME.format(LocalDateTime.now)), Schedule.spaced(1.second)) + + val app: Routes[Any, Response] = + Routes( + Method.GET / "sse" -> + handler(Response.fromServerSentEvents(stream)), + ) + + val server = + Server.install(app) + + def client(port: Int): ZIO[Any, Throwable, Chunk[ServerSentEvent[String]]] = ZIO + .scoped(for { + client <- ZIO.service[Client] + response <- client + .url(url"http://localhost:$port") + .request( + Request(method = Method.GET, url = url"http://localhost:$port/sse", body = Body.empty) + .addHeader(Header.Accept(MediaType.text.`event-stream`)), + ) + events <- response.body.asServerSentEvents[String].take(5).runCollect + } yield events) + .provide(ZClient.default) + + override def spec: Spec[TestEnvironment with Scope, Any] = + suite("ServerSentEventSpec")( + test("Send and receive ServerSentEvent with string payload") { + for { + _ <- server.fork + port <- ZIO.serviceWithZIO[Server](_.port) + events <- client(port) + } yield assertTrue(events.size == 5 && events.forall(event => Try(ISO_LOCAL_TIME.parse(event.data)).isSuccess)) + }.provide(Server.defaultWithPort(0)), + ) @@ TestAspect.withLiveClock +} diff --git a/zio-http/jvm/src/test/scala/zio/http/endpoint/QueryParameterSpec.scala b/zio-http/jvm/src/test/scala/zio/http/endpoint/QueryParameterSpec.scala index 768de94dff..a6cdb2412e 100644 --- a/zio-http/jvm/src/test/scala/zio/http/endpoint/QueryParameterSpec.scala +++ b/zio-http/jvm/src/test/scala/zio/http/endpoint/QueryParameterSpec.scala @@ -16,8 +16,6 @@ package zio.http.endpoint -import java.time.Instant - import zio._ import zio.test._ @@ -296,9 +294,7 @@ object QueryParameterSpec extends ZIOHttpSpec { .query(queryAllInt("ints")) .out[String] .implementHandler { - Handler.fromFunction { case queryParams => - s"path(users, $queryParams)" - } + Handler.fromFunction { queryParams => s"path(users, $queryParams)" } }, ), ) _ 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 aef2838f4c..e0ce3c8501 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 @@ -25,6 +25,8 @@ import zio.test._ import zio.stream.ZStream +import zio.schema.annotation.validate +import zio.schema.validation.Validation import zio.schema.{DeriveSchema, Schema} import zio.http.Header.Authorization @@ -32,7 +34,7 @@ import zio.http.Method._ import zio.http._ import zio.http.codec.HttpCodec.authorization import zio.http.codec.HttpContentCodec.protobuf -import zio.http.codec.{Doc, HeaderCodec, HttpCodec, HttpContentCodec, QueryCodec} +import zio.http.codec._ import zio.http.endpoint.EndpointSpec.ImageMetadata import zio.http.netty.NettyConfig import zio.http.netty.server.NettyDriver @@ -63,6 +65,17 @@ object RoundtripSpec extends ZIOHttpSpec { implicit val schema: Schema[Post] = DeriveSchema.gen[Post] } + case class Age(@validate(Validation.greaterThan(18)) ignoredFieldName: Int) + object Age { + implicit val schema: Schema[Age] = DeriveSchema.gen[Age] + } + + final case class PostWithAge(id: Int, title: String, body: String, userId: Int, age: Age) + + object PostWithAge { + implicit val schema: Schema[PostWithAge] = DeriveSchema.gen[PostWithAge] + } + def makeExecutor(client: Client, port: Int): EndpointExecutor[Unit] = { val locator = EndpointLocator.fromURL( URL.decode(s"http://localhost:$port").toOption.get, @@ -209,31 +222,66 @@ object RoundtripSpec extends ZIOHttpSpec { .query(HttpCodec.queryInt("id")) .query(HttpCodec.query("name").optional) .query(HttpCodec.query("details").optional) - .out[Post] + .query(HttpCodec.queryTo[Age]("age").optional) + .out[PostWithAge] val handler = api.implementHandler { - Handler.fromFunction { case (id, userId, name, details) => - Post(id, name.getOrElse("-"), details.getOrElse("-"), userId) + Handler.fromFunction { case (id, userId, name, details, age) => + PostWithAge(id, name.getOrElse("-"), details.getOrElse("-"), userId, age.getOrElse(Age(20))) } } testEndpoint( api, Routes(handler), - (10, 20, None, Some("x")), - Post(10, "-", "x", 20), + (10, 20, None, Some("x"), None), + PostWithAge(10, "-", "x", 20, Age(20)), ) && testEndpoint( api, Routes(handler), - (10, 20, None, None), - Post(10, "-", "-", 20), + (10, 20, None, None, None), + PostWithAge(10, "-", "-", 20, Age(20)), ) && testEndpoint( api, Routes(handler), - (10, 20, Some("x"), Some("y")), - Post(10, "x", "y", 20), + (10, 20, Some("x"), Some("y"), Some(Age(23))), + PostWithAge(10, "x", "y", 20, Age(23)), + ) + }, + test("simple get with query params that fails validation") { + val api = + Endpoint(GET / "users" / int("userId")) + .query(HttpCodec.queryInt("id")) + .query(HttpCodec.query("name").optional) + .query(HttpCodec.query("details").optional) + .query(HttpCodec.queryTo[Age]("age").optional) + .out[PostWithAge] + + val handler = + api.implementHandler { + Handler.fromFunction { case (id, userId, name, details, age) => + PostWithAge(id, name.getOrElse("-"), details.getOrElse("-"), userId, age.getOrElse(Age(0))) + } + } + + testEndpoint( + api, + Routes(handler), + (10, 20, Some("x"), Some("y"), Some(Age(17))), + PostWithAge(10, "x", "y", 20, Age(17)), + ).catchAllCause(t => + ZIO.succeed( + assertTrue( + t.dieOption.contains( + HttpCodecError.CustomError( + name = "InvalidEntity", + message = "A well-formed entity failed validation: 17 should be greater than 18", + ), + ), + ), + ), ) }, test("throwing error in handler") { diff --git a/zio-http/jvm/src/test/scala/zio/http/endpoint/ServerSentEventEndpointSpec.scala b/zio-http/jvm/src/test/scala/zio/http/endpoint/ServerSentEventEndpointSpec.scala new file mode 100644 index 0000000000..8112dbb921 --- /dev/null +++ b/zio-http/jvm/src/test/scala/zio/http/endpoint/ServerSentEventEndpointSpec.scala @@ -0,0 +1,107 @@ +package zio.http.endpoint + +import java.time.format.DateTimeFormatter.ISO_LOCAL_TIME +import java.time.{Instant, LocalDateTime} + +import scala.util.Try + +import zio._ +import zio.test._ + +import zio.stream.ZStream + +import zio.schema.{DeriveSchema, Schema} + +import zio.http._ +import zio.http.codec.HttpCodec +import zio.http.endpoint.EndpointMiddleware.None + +object ServerSentEventEndpointSpec extends ZIOSpecDefault { + + object StringPayload { + val sseEndpoint: Endpoint[Unit, Unit, ZNothing, ZStream[Any, Nothing, ServerSentEvent[String]], None] = + Endpoint(Method.GET / "sse") + .outStream[ServerSentEvent[String]](MediaType.text.`event-stream`) + .inCodec(HttpCodec.header(Header.Accept).const(Header.Accept(MediaType.text.`event-stream`))) + + val stream: ZStream[Any, Nothing, ServerSentEvent[String]] = + ZStream.repeatWithSchedule(ServerSentEvent(ISO_LOCAL_TIME.format(LocalDateTime.now)), Schedule.spaced(1.second)) + + val sseRoute: Route[Any, Nothing] = sseEndpoint.implementHandler(Handler.succeed(stream)) + + val routes: Routes[Any, Response] = + sseRoute.toRoutes @@ Middleware.requestLogging(logRequestBody = true) @@ Middleware.debug + + val server: ZIO[Server, Throwable, Nothing] = + Server.serve(routes) + + def locator(port: Int): EndpointLocator = EndpointLocator.fromURL(url"http://localhost:$port") + + private val invocation: Invocation[Unit, Unit, ZNothing, ZStream[Any, Nothing, ServerSentEvent[String]], None] = + sseEndpoint(()) + + def client(port: Int): ZIO[Scope, Throwable, Chunk[ServerSentEvent[String]]] = + (for { + client <- ZIO.service[Client] + executor = EndpointExecutor(client, locator(port), ZIO.unit) + stream <- executor(invocation) + events <- stream.take(5).runCollect + } yield events).provideSome[Scope](ZClient.default) + } + + object JsonPayload { + case class Payload(timeStamp: Instant, message: String) + + object Payload { + implicit val schema: Schema[Payload] = DeriveSchema.gen[Payload] + } + + val stream: ZStream[Any, Nothing, ServerSentEvent[Payload]] = + ZStream.repeatWithSchedule(ServerSentEvent(Payload(Instant.now(), "message")), Schedule.spaced(1.second)) + + val sseEndpoint: Endpoint[Unit, Unit, ZNothing, ZStream[Any, Nothing, ServerSentEvent[Payload]], None] = + Endpoint(Method.GET / "sse") + .outStream[ServerSentEvent[Payload]] + .inCodec(HttpCodec.header(Header.Accept).const(Header.Accept(MediaType.text.`event-stream`))) + + val sseRoute: Route[Any, Nothing] = sseEndpoint.implementHandler(Handler.succeed(stream)) + + val routes: Routes[Any, Response] = sseRoute.toRoutes + + val server: URIO[Server, Nothing] = + Server.serve(routes) + + def locator(port: Int): EndpointLocator = EndpointLocator.fromURL(url"http://localhost:$port") + + private val invocation: Invocation[Unit, Unit, ZNothing, ZStream[Any, Nothing, ServerSentEvent[Payload]], None] = + sseEndpoint(()) + + def client(port: Int): ZIO[Scope, Throwable, Chunk[ServerSentEvent[Payload]]] = + (for { + client <- ZIO.service[Client] + executor = EndpointExecutor(client, locator(port), ZIO.unit) + stream <- executor(invocation) + events <- stream.take(5).runCollect + } yield events).provideSome[Scope](ZClient.default) + } + + override def spec: Spec[TestEnvironment with Scope, Any] = + suite("ServerSentEventSpec")( + test("Send and receive ServerSentEvent with string payload") { + import StringPayload._ + for { + _ <- server.fork + port <- ZIO.serviceWithZIO[Server](_.port) + events <- client(port) + } yield assertTrue(events.size == 5 && events.forall(event => Try(ISO_LOCAL_TIME.parse(event.data)).isSuccess)) + }.provideSome[Scope](Server.defaultWithPort(0)), + test("Send and receive ServerSentEvent with json payload") { + import JsonPayload._ + for { + _ <- server.fork + port <- ZIO.serviceWithZIO[Server](_.port) + events <- client(port) + } yield assertTrue(events.size == 5 && events.forall(event => Try(event.data.timeStamp).isSuccess)) + }.provideSome[Scope](Server.defaultWithPort(0)), + ) @@ TestAspect.withLiveClock +} 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 26b26f642c..ddfd2500d8 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 @@ -138,6 +138,15 @@ object OpenAPIGenSpec extends ZIOSpecDefault { implicit val schema: Schema[Payload] = DeriveSchema.gen[Payload] } + object Lazy { + case class A(b: B) + + object A { + implicit val schema: Schema[A] = DeriveSchema.gen + } + case class B(i: Int) + } + private val simpleEndpoint = Endpoint( (GET / "static" / int("id") / uuid("uuid") ?? Doc.p("user id") / string("name")) ?? Doc.p("get path"), @@ -171,7 +180,7 @@ object OpenAPIGenSpec extends ZIOSpecDefault { override def spec: Spec[TestEnvironment with Scope, Any] = suite("OpenAPIGenSpec")( test("simple endpoint to OpenAPI") { - val generated = OpenAPIGen.fromEndpoints("Simple Endpoint", "1.0", simpleEndpoint) + val generated = OpenAPIGen.fromEndpoints("Simple Endpoint", "1.0", simpleEndpoint.tag("simple", "endpoint")) val json = toJsonAst(generated) val expectedJson = """{ | "openapi" : "3.1.0", @@ -181,55 +190,49 @@ object OpenAPIGenSpec extends ZIOSpecDefault { | }, | "paths" : { | "/static/{id}/{uuid}/{name}" : { + | "description" : "- simple\n- endpoint\n", | "get" : { + | "tags" : [ + | "simple", + | "endpoint" + | ], | "description" : "get path\n\n", | "parameters" : [ - | - | { + | { | "name" : "id", | "in" : "path", | "required" : true, - | "schema" : - | { - | "type" : - | "integer", + | "schema" : { + | "type" : "integer", | "format" : "int32" | }, | "style" : "simple" | }, - | - | { + | { | "name" : "uuid", | "in" : "path", | "description" : "user id\n\n", | "required" : true, - | "schema" : - | { - | "type" : - | "string", + | "schema" : { + | "type" : "string", | "format" : "uuid" | }, | "style" : "simple" | }, - | - | { + | { | "name" : "name", | "in" : "path", | "required" : true, - | "schema" : - | { - | "type" : - | "string" + | "schema" : { + | "type" : "string" | }, | "style" : "simple" | } | ], - | "requestBody" : - | { + | "requestBody" : { | "content" : { | "application/json" : { - | "schema" : - | { + | "schema" : { | "$ref" : "#/components/schemas/SimpleInputBody", | "description" : "input body\n\n" | } @@ -238,24 +241,20 @@ object OpenAPIGenSpec extends ZIOSpecDefault { | "required" : true | }, | "responses" : { - | "200" : - | { + | "200" : { | "content" : { | "application/json" : { - | "schema" : - | { + | "schema" : { | "$ref" : "#/components/schemas/SimpleOutputBody", | "description" : "output body\n\n" | } | } | } | }, - | "404" : - | { + | "404" : { | "content" : { | "application/json" : { - | "schema" : - | { + | "schema" : { | "$ref" : "#/components/schemas/NotFoundError", | "description" : "not found\n\n" | } @@ -268,32 +267,25 @@ object OpenAPIGenSpec extends ZIOSpecDefault { | }, | "components" : { | "schemas" : { - | "NotFoundError" : - | { - | "type" : - | "object", + | "NotFoundError" : { + | "type" : "object", | "properties" : { | "message" : { - | "type" : - | "string" + | "type" : "string" | } | }, | "required" : [ | "message" | ] | }, - | "SimpleInputBody" : - | { - | "type" : - | "object", + | "SimpleInputBody" : { + | "type" : "object", | "properties" : { | "name" : { - | "type" : - | "string" + | "type" : "string" | }, | "age" : { - | "type" : - | "integer", + | "type" : "integer", | "format" : "int32" | } | }, @@ -302,18 +294,14 @@ object OpenAPIGenSpec extends ZIOSpecDefault { | "age" | ] | }, - | "SimpleOutputBody" : - | { - | "type" : - | "object", + | "SimpleOutputBody" : { + | "type" : "object", | "properties" : { | "userName" : { - | "type" : - | "string" + | "type" : "string" | }, | "score" : { - | "type" : - | "integer", + | "type" : "integer", | "format" : "int32" | } | }, @@ -2572,6 +2560,76 @@ object OpenAPIGenSpec extends ZIOSpecDefault { |""".stripMargin assertTrue(json == toJsonAst(expectedJson)) }, + test("Lazy schema") { + val endpoint = Endpoint(RoutePattern.POST / "lazy") + .in[Lazy.A] + .out[Unit] + val openApi = OpenAPIGen.fromEndpoints(endpoint) + val json = toJsonAst(openApi) + val expectedJson = """{ + | "openapi" : "3.1.0", + | "info": { + | "title": "", + | "version": "" + | }, + | "paths" : { + | "/lazy" : { + | "post" : { + | "requestBody" : { + | "content" : { + | "application/json" : { + | "schema" : { + | "$ref" : "#/components/schemas/A" + | } + | } + | }, + | "required" : true + | }, + | "responses" : { + | "200" : { + | "content" : { + | "application/json" : { + | "schema": { + | "type" : "null" + | } + | } + | } + | } + | } + | } + | } + | }, + | "components" : { + | "schemas" : { + | "A" : { + | "type" : "object", + | "properties" : { + | "b" : { + | "$ref" : "#/components/schemas/B" + | } + | }, + | "required" : [ + | "b" + | ] + | }, + | "B" : { + | "type" : "object", + | "properties" : { + | "i" : { + | "type" : "integer", + | "format" : "int32" + | } + | }, + | "required" : [ + | "i" + | ] + | } + | } + | } + |}""".stripMargin + val expected = toJsonAst(expectedJson) + assertTrue(json == expected) + }, ) } diff --git a/zio-http/jvm/src/test/scala/zio/http/template/DomSpec.scala b/zio-http/jvm/src/test/scala/zio/http/template/DomSpec.scala index cabccc3749..62def40fb0 100644 --- a/zio-http/jvm/src/test/scala/zio/http/template/DomSpec.scala +++ b/zio-http/jvm/src/test/scala/zio/http/template/DomSpec.scala @@ -80,6 +80,22 @@ object DomSpec extends ZIOHttpSpec { assertTrue(dom.encode == """""") }, ), + test("element with non value required attribute") { + val dom = Dom.element( + "input", + Dom.booleanAttr("required"), + ) + + assertTrue(dom.encode == """""") + }, + test("element with value required attribute") { + val dom = Dom.element( + "input", + Dom.booleanAttr("required", Some(true)), + ) + + assertTrue(dom.encode == """""") + }, test("element with attribute & children") { val dom = Dom.element( "a", diff --git a/zio-http/jvm/src/test/scala/zio/http/template/HtmlSpec.scala b/zio-http/jvm/src/test/scala/zio/http/template/HtmlSpec.scala index 5ca60fc3ce..da7438ca69 100644 --- a/zio-http/jvm/src/test/scala/zio/http/template/HtmlSpec.scala +++ b/zio-http/jvm/src/test/scala/zio/http/template/HtmlSpec.scala @@ -63,6 +63,16 @@ case object HtmlSpec extends ZIOHttpSpec { val expected = """
Hello!
""" assert(view.encode)(equalTo(expected.stripMargin)) }, + test("tags with default boolean attributes") { + val view = input(typeAttr := "text", requiredAttr) + val expected = """""" + assert(view.encode)(equalTo(expected.stripMargin)) + }, + test("tags with boolean attributes") { + val view = input(typeAttr := "text", requiredAttr := false) + val expected = """""" + assert(view.encode)(equalTo(expected.stripMargin)) + }, suite("implicit conversions")( test("from unit") { val view: Html = {} diff --git a/zio-http/shared/src/main/scala/zio/http/Body.scala b/zio-http/shared/src/main/scala/zio/http/Body.scala index c625e83ee4..f88915793a 100644 --- a/zio-http/shared/src/main/scala/zio/http/Body.scala +++ b/zio-http/shared/src/main/scala/zio/http/Body.scala @@ -25,6 +25,7 @@ import zio.stacktracer.TracingImplicits.disableAutoTrace import zio.stream.ZStream +import zio.schema.Schema import zio.schema.codec.BinaryCodec import zio.http.internal.BodyEncoding @@ -125,6 +126,11 @@ trait Body { self => } .orElseFail(new IllegalStateException("Cannot decode body as multipart/mixed without a known boundary")) + def asServerSentEvents[T: Schema](implicit trace: Trace): ZStream[Any, Throwable, ServerSentEvent[T]] = { + val codec = ServerSentEvent.defaultBinaryCodec[T] + (asStream >>> codec.streamDecoder).debug("steam events") + } + /** * Returns a stream that contains the bytes of the body. This method is safe * to use with large bodies, because the elements of the returned stream are diff --git a/zio-http/shared/src/main/scala/zio/http/ServerSentEvent.scala b/zio-http/shared/src/main/scala/zio/http/ServerSentEvent.scala index 5c2a338882..5704439137 100644 --- a/zio-http/shared/src/main/scala/zio/http/ServerSentEvent.scala +++ b/zio-http/shared/src/main/scala/zio/http/ServerSentEvent.scala @@ -47,7 +47,9 @@ final case class ServerSentEvent[T]( def encode(implicit binaryCodec: BinaryCodec[T]): String = { val sb = new StringBuilder - sb.append("data: ").append(binaryCodec.encode(data).asString) + binaryCodec.encode(data).asString.linesIterator.foreach { line => + sb.append("data: ").append(line).append('\n') + } eventType.foreach { et => sb.append("event: ").append(et.linesIterator.mkString(" ")).append('\n') } diff --git a/zio-http/shared/src/main/scala/zio/http/codec/Doc.scala b/zio-http/shared/src/main/scala/zio/http/codec/Doc.scala index 628cc59856..dd89d4890f 100644 --- a/zio-http/shared/src/main/scala/zio/http/codec/Doc.scala +++ b/zio-http/shared/src/main/scala/zio/http/codec/Doc.scala @@ -17,7 +17,6 @@ package zio.http.codec import zio.Chunk -import zio.stacktracer.TracingImplicits.disableAutoTrace import zio.schema.Schema @@ -54,6 +53,24 @@ sealed trait Doc { self => case x => Chunk(x) } + def tag(tags: Seq[String]): Doc = self match { + case Tagged(doc, existingTags) => Tagged(doc, existingTags ++ tags) + case _ => Tagged(self, tags.toList) + } + + def tag(tag: String): Doc = + self match { + case Tagged(doc, tags) => Tagged(doc, tags :+ tag) + case _ => Tagged(self, List(tag)) + } + + def tag(tag: String, tags: String*): Doc = self.tag(tag +: tags) + + def tags: List[String] = self match { + case Tagged(_, tags) => tags + case _ => Nil + } + def toCommonMark: String = { val writer = new StringBuilder @@ -131,20 +148,27 @@ sealed trait Doc { self => case Doc.Raw(_, docType) => throw new IllegalArgumentException(s"Unsupported raw doc type: $docType") + case Doc.Tagged(_, _) => + } } render(this) + val tags = self.tags + if (tags.nonEmpty) { + // Add all tags to the end of the document as an unordered list + render(Doc.unorderedListing(tags.map(Doc.p): _*)) + } writer.toString() } def toHtml: template.Html = { import template._ - self match { - case Doc.Empty => + val html: Html = self match { + case Doc.Empty => Html.Empty - case Header(value, level) => + case Header(value, level) => level match { case 1 => h1(value) case 2 => h2(value) @@ -154,9 +178,9 @@ sealed trait Doc { self => case 6 => h6(value) case _ => throw new IllegalArgumentException(s"Invalid header level: $level") } - case Paragraph(value) => + case Paragraph(value) => p(value.toHtml) - case DescriptionList(definitions) => + case DescriptionList(definitions) => dl( definitions.flatMap { case (span, helpDoc) => Seq( @@ -165,9 +189,11 @@ sealed trait Doc { self => ) }, ) - case Sequence(left, right) => + case Sequence(left, right) => left.toHtml ++ right.toHtml - case Listing(elements, listingType) => + case Listing(elements, _) if elements.isEmpty => + Html.Empty + case Listing(elements, listingType) => val elementsHtml = elements.map { doc => li(doc.toHtml) @@ -181,7 +207,10 @@ sealed trait Doc { self => Html.fromString(value) case Raw(_, docType) => throw new IllegalArgumentException(s"Unsupported raw doc type: $docType") + case Tagged(doc, _) => + doc.toHtml } + html ++ (if (tags.nonEmpty) Doc.unorderedListing(self.tags.map(Doc.p): _*).toHtml else Html.Empty) } def toHtmlSnippet: String = @@ -275,6 +304,9 @@ sealed trait Doc { self => case Doc.Raw(_, docType) => throw new IllegalArgumentException(s"Unsupported raw doc type: $docType") + + case Tagged(doc, _) => + renderHelpDoc(doc) } def renderSpan(span: Span): Unit = { @@ -321,6 +353,11 @@ sealed trait Doc { self => renderHelpDoc(this) + val tags = self.tags + if (tags.nonEmpty) { + writer.append("\n") + renderHelpDoc(Doc.unorderedListing(tags.map(Doc.p): _*)) + } writer.toString() + (if (color) Console.RESET else "") } @@ -350,6 +387,7 @@ object Doc { final case class DescriptionList(definitions: List[(Span, Doc)]) extends Doc final case class Sequence(left: Doc, right: Doc) extends Doc final case class Listing(elements: List[Doc], listingType: ListingType) extends Doc + final case class Tagged(doc: Doc, tgs: List[String]) extends Doc sealed trait ListingType object ListingType { case object Unordered extends ListingType diff --git a/zio-http/shared/src/main/scala/zio/http/codec/HttpCodec.scala b/zio-http/shared/src/main/scala/zio/http/codec/HttpCodec.scala index cd5175e70c..1789f654e6 100644 --- a/zio-http/shared/src/main/scala/zio/http/codec/HttpCodec.scala +++ b/zio-http/shared/src/main/scala/zio/http/codec/HttpCodec.scala @@ -25,6 +25,8 @@ import zio._ import zio.stream.ZStream import zio.schema.Schema +import zio.schema.annotation.validate +import zio.schema.validation.Validation import zio.http.Header.Accept.MediaTypeWithQFactor import zio.http._ @@ -584,7 +586,7 @@ object HttpCodec extends ContentCodecs with HeaderCodecs with MethodCodecs with } private[http] final case class Query[A]( name: String, - textCodec: TextCodec[A], + codec: BinaryCodecWithSchema[A], hint: Query.QueryParamHint, index: Int = 0, ) extends Atom[HttpCodecType.Query, Chunk[A]] { diff --git a/zio-http/shared/src/main/scala/zio/http/codec/HttpCodecError.scala b/zio-http/shared/src/main/scala/zio/http/codec/HttpCodecError.scala index cd0645fde2..bd266d3e38 100644 --- a/zio-http/shared/src/main/scala/zio/http/codec/HttpCodecError.scala +++ b/zio-http/shared/src/main/scala/zio/http/codec/HttpCodecError.scala @@ -21,6 +21,7 @@ import scala.util.control.NoStackTrace import zio.stacktracer.TracingImplicits.disableAutoTrace import zio.{Cause, Chunk} +import zio.schema.codec.DecodeError import zio.schema.validation.ValidationError import zio.http.{Path, Status} @@ -51,8 +52,8 @@ object HttpCodecError { final case class MissingQueryParam(queryParamName: String) extends HttpCodecError { def message = s"Missing query parameter $queryParamName" } - final case class MalformedQueryParam(queryParamName: String, textCodec: TextCodec[_]) extends HttpCodecError { - def message = s"Malformed query parameter $queryParamName failed to decode using $textCodec" + final case class MalformedQueryParam(queryParamName: String, cause: DecodeError) extends HttpCodecError { + def message = s"Malformed query parameter $queryParamName could not be decoded: $cause" } final case class MalformedBody(details: String, cause: Option[Throwable] = None) extends HttpCodecError { def message = s"Malformed request body failed to decode: $details" @@ -63,7 +64,7 @@ object HttpCodecError { object InvalidEntity { def wrap(errors: Chunk[ValidationError]): InvalidEntity = InvalidEntity( - errors.foldLeft("")((acc, err) => acc + err.message + "\n"), + errors.map(err => err.message).mkString("\n"), errors, ) } diff --git a/zio-http/shared/src/main/scala/zio/http/codec/QueryCodecs.scala b/zio-http/shared/src/main/scala/zio/http/codec/QueryCodecs.scala index 7a07f78cba..3320adee3b 100644 --- a/zio-http/shared/src/main/scala/zio/http/codec/QueryCodecs.scala +++ b/zio-http/shared/src/main/scala/zio/http/codec/QueryCodecs.scala @@ -18,34 +18,37 @@ package zio.http.codec import zio.Chunk import zio.stacktracer.TracingImplicits.disableAutoTrace +import zio.schema.Schema + import zio.http.codec.HttpCodec.Query.QueryParamHint +import zio.http.codec.internal.TextBinaryCodec private[codec] trait QueryCodecs { - def query(name: String): QueryCodec[String] = singleValueCodec(name, TextCodec.string) + def query(name: String): QueryCodec[String] = singleValueCodec(name, Schema[String]) - def queryBool(name: String): QueryCodec[Boolean] = singleValueCodec(name, TextCodec.boolean) + def queryBool(name: String): QueryCodec[Boolean] = singleValueCodec(name, Schema[Boolean]) - def queryInt(name: String): QueryCodec[Int] = singleValueCodec(name, TextCodec.int) + def queryInt(name: String): QueryCodec[Int] = singleValueCodec(name, Schema[Int]) - def queryTo[A](name: String)(implicit codec: TextCodec[A]): QueryCodec[A] = singleValueCodec(name, codec) + def queryTo[A](name: String)(implicit codec: Schema[A]): QueryCodec[A] = singleValueCodec(name, codec) - def queryAll(name: String): QueryCodec[Chunk[String]] = multiValueCodec(name, TextCodec.string) + def queryAll(name: String): QueryCodec[Chunk[String]] = multiValueCodec(name, Schema[String]) - def queryAllBool(name: String): QueryCodec[Chunk[Boolean]] = multiValueCodec(name, TextCodec.boolean) + def queryAllBool(name: String): QueryCodec[Chunk[Boolean]] = multiValueCodec(name, Schema[Boolean]) - def queryAllInt(name: String): QueryCodec[Chunk[Int]] = multiValueCodec(name, TextCodec.int) + def queryAllInt(name: String): QueryCodec[Chunk[Int]] = multiValueCodec(name, Schema[Int]) - def queryAllTo[A](name: String)(implicit codec: TextCodec[A]): QueryCodec[Chunk[A]] = multiValueCodec(name, codec) + def queryAllTo[A](name: String)(implicit codec: Schema[A]): QueryCodec[Chunk[A]] = multiValueCodec(name, codec) - private def singleValueCodec[A](name: String, textCodec: TextCodec[A]): QueryCodec[A] = + private def singleValueCodec[A](name: String, schema: Schema[A]): QueryCodec[A] = HttpCodec - .Query(name, textCodec, QueryParamHint.One) + .Query(name, BinaryCodecWithSchema(TextBinaryCodec.fromSchema(schema), schema), QueryParamHint.One) .transformOrFail { case chunk if chunk.size == 1 => Right(chunk.head) case chunk => Left(s"Expected single value for query parameter $name, but got ${chunk.size} instead") }(s => Right(Chunk(s))) - private def multiValueCodec[A](name: String, textCodec: TextCodec[A]): QueryCodec[Chunk[A]] = - HttpCodec.Query(name, textCodec, QueryParamHint.Many) + private def multiValueCodec[A](name: String, schema: Schema[A]): QueryCodec[Chunk[A]] = + HttpCodec.Query(name, BinaryCodecWithSchema(TextBinaryCodec.fromSchema(schema), schema), QueryParamHint.Many) } diff --git a/zio-http/shared/src/main/scala/zio/http/codec/internal/EncoderDecoder.scala b/zio-http/shared/src/main/scala/zio/http/codec/internal/EncoderDecoder.scala index 7663d2461c..fce7fc96b2 100644 --- a/zio-http/shared/src/main/scala/zio/http/codec/internal/EncoderDecoder.scala +++ b/zio-http/shared/src/main/scala/zio/http/codec/internal/EncoderDecoder.scala @@ -27,6 +27,7 @@ import zio.schema.codec.BinaryCodec import zio.http.Header.Accept.MediaTypeWithQFactor import zio.http._ +import zio.http.codec.HttpCodec.Query import zio.http.codec._ private[codec] trait EncoderDecoder[-AtomTypes, Value] { self => @@ -40,6 +41,8 @@ private[codec] trait EncoderDecoder[-AtomTypes, Value] { self => } private[codec] object EncoderDecoder { + private val emptyStringChunk = Chunk("") + def apply[AtomTypes, Value]( httpCodec: HttpCodec[AtomTypes, Value], ): EncoderDecoder[AtomTypes, Value] = { @@ -284,8 +287,21 @@ private[codec] object EncoderDecoder { if (params.isEmpty) throw HttpCodecError.MissingQueryParam(query.name) - else { - val parsedParams = params.collect(query.textCodec) + else if ( + params == emptyStringChunk + && (query.hint == Query.QueryParamHint.Any || query.hint == Query.QueryParamHint.Many) + ) { + inputs(i) = Chunk.empty + } else { + val parsedParams = params.map { p => + val decoded = query.codec.codec.decode(Chunk.fromArray(p.getBytes(Charsets.Utf8))) + decoded match { + case Left(error) => throw HttpCodecError.MalformedQueryParam(query.name, error) + case Right(value) => value + } + } + val validationErrors = parsedParams.flatMap(p => query.codec.schema.validate(p)(query.codec.schema)) + if (validationErrors.nonEmpty) throw HttpCodecError.InvalidEntity.wrap(validationErrors) inputs(i) = parsedParams } @@ -482,7 +498,7 @@ private[codec] object EncoderDecoder { queryParams.addQueryParams(query.name, Chunk.empty[String]) else inputCoerced.foreach { in => - val value = query.textCodec.encode(in) + val value = query.codec.codec.encode(in).asString queryParams = queryParams.addQueryParam(query.name, value) } diff --git a/zio-http/shared/src/main/scala/zio/http/codec/internal/TextBinaryCodec.scala b/zio-http/shared/src/main/scala/zio/http/codec/internal/TextBinaryCodec.scala index 3131ff769b..ad574e7962 100644 --- a/zio-http/shared/src/main/scala/zio/http/codec/internal/TextBinaryCodec.scala +++ b/zio-http/shared/src/main/scala/zio/http/codec/internal/TextBinaryCodec.scala @@ -32,10 +32,31 @@ object TextBinaryCodec { implicit def fromSchema[A](implicit schema: Schema[A]): BinaryCodec[A] = { schema match { - case enum0: Schema.Enum[_] => errorCodec(enum0) - case record: Schema.Record[_] => errorCodec(record) - case collection: Schema.Collection[_, _] => errorCodec(collection) - case Schema.Transform(schema, f, g, _, _) => + case enum0: Schema.Enum[_] => errorCodec(enum0) + case record: Schema.Record[_] if record.fields.size == 1 => + val fieldSchema = record.fields.head.schema + val codec = fromSchema(fieldSchema).asInstanceOf[BinaryCodec[A]] + new BinaryCodec[A] { + override def encode(a: A): Chunk[Byte] = + codec.encode(record.deconstruct(a)(Unsafe.unsafe).head.get.asInstanceOf[A]) + override def decode(c: Chunk[Byte]): Either[DecodeError, A] = + codec + .decode(c) + .flatMap(a => + record.construct(Chunk(a))(Unsafe.unsafe).left.map(s => DecodeError.ReadError(Cause.empty, s)), + ) + override def streamEncoder: ZPipeline[Any, Nothing, A, Byte] = + ZPipeline.map(a => encode(a)).flattenChunks + override def streamDecoder: ZPipeline[Any, DecodeError, Byte, A] = + codec.streamDecoder.mapZIO(a => + ZIO.fromEither( + record.construct(Chunk(a))(Unsafe.unsafe).left.map(s => DecodeError.ReadError(Cause.empty, s)), + ), + ) + } + case record: Schema.Record[_] => errorCodec(record) + case collection: Schema.Collection[_, _] => errorCodec(collection) + case Schema.Transform(schema, f, g, _, _) => val codec = fromSchema(schema) new BinaryCodec[A] { override def encode(a: A): Chunk[Byte] = codec.encode(g(a).fold(e => throw new Exception(e), identity)) @@ -54,7 +75,7 @@ object TextBinaryCodec { } } } - case Schema.Primitive(_, _) => + case Schema.Primitive(_, _) => new BinaryCodec[A] { val decode0: String => Either[DecodeError, Any] = schema match { @@ -67,10 +88,10 @@ object TextBinaryCodec { (s: String) => Right(s) case StandardType.BoolType => (s: String) => - try { - Right(s.toBoolean) - } catch { - case e: Exception => Left(DecodeError.ReadError(Cause.fail(e), e.getMessage)) + s.toLowerCase match { + case "true" | "on" | "yes" | "1" => Right(true) + case "false" | "off" | "no" | "0" => Right(false) + case _ => Left(DecodeError.ReadError(Cause.fail(new Exception("Invalid boolean value")), s)) } case StandardType.ByteType => (s: String) => @@ -286,8 +307,8 @@ object TextBinaryCodec { .map(s => decode(Chunk.fromArray(s.getBytes)).fold(throw _, identity)) .mapErrorCause(e => Cause.fail(DecodeError.ReadError(e, e.squash.getMessage))) } - case Schema.Lazy(schema0) => fromSchema(schema0()) - case _ => errorCodec(schema) + case Schema.Lazy(schema0) => fromSchema(schema0()) + case _ => errorCodec(schema) } } } diff --git a/zio-http/shared/src/main/scala/zio/http/endpoint/Endpoint.scala b/zio-http/shared/src/main/scala/zio/http/endpoint/Endpoint.scala index 8905a974c6..fe211871bb 100644 --- a/zio-http/shared/src/main/scala/zio/http/endpoint/Endpoint.scala +++ b/zio-http/shared/src/main/scala/zio/http/endpoint/Endpoint.scala @@ -347,7 +347,7 @@ final case class Endpoint[PathInput, Input, Err, Output, Middleware <: EndpointM output, error, codecError, - doc, + self.doc, mw, ) @@ -381,7 +381,7 @@ final case class Endpoint[PathInput, Input, Err, Output, Middleware <: EndpointM output, error, codecError, - doc, + self.doc, mw, ) @@ -611,7 +611,7 @@ final case class Endpoint[PathInput, Input, Err, Output, Middleware <: EndpointM output = (contentCodec ++ StatusCodec.status(Status.Ok) ?? doc) | self.output, error, codecError, - doc, + self.doc, mw, ) } @@ -635,7 +635,7 @@ final case class Endpoint[PathInput, Input, Err, Output, Middleware <: EndpointM output = (contentCodec ++ StatusCodec.status(status) ?? doc) | self.output, error, codecError, - doc, + self.doc, mw, ) } @@ -671,7 +671,7 @@ final case class Endpoint[PathInput, Input, Err, Output, Middleware <: EndpointM output = (contentCodec ++ StatusCodec.status(status)) | self.output, error, codecError, - doc, + self.doc, mw, ) } @@ -689,7 +689,7 @@ final case class Endpoint[PathInput, Input, Err, Output, Middleware <: EndpointM output = ((contentCodec ++ StatusCodec.status(status)) ?? doc) | self.output, error, codecError, - doc, + self.doc, mw, ) } @@ -702,6 +702,18 @@ final case class Endpoint[PathInput, Input, Err, Output, Middleware <: EndpointM ): Endpoint[PathInput, combiner.Out, Err, Output, Middleware] = copy(input = self.input ++ codec) + /** + * Adds tags to the endpoint. The are used for documentation generation. For + * example to group endpoints for OpenAPI. + */ + def tag(tag: String, tags: String*): Endpoint[PathInput, Input, Err, Output, Middleware] = + copy(doc = doc.tag(tag +: tags)) + + /** + * A list of tags for this endpoint. + */ + def tags: List[String] = doc.tags + /** * Transforms the input of this endpoint using the specified functions. This * is useful to build from different http inputs a domain specific input. diff --git a/zio-http/shared/src/main/scala/zio/http/endpoint/internal/EndpointClient.scala b/zio-http/shared/src/main/scala/zio/http/endpoint/internal/EndpointClient.scala index 9f30b2b7fb..4cd1764377 100644 --- a/zio-http/shared/src/main/scala/zio/http/endpoint/internal/EndpointClient.scala +++ b/zio-http/shared/src/main/scala/zio/http/endpoint/internal/EndpointClient.scala @@ -49,7 +49,10 @@ private[endpoint] final case class EndpointClient[P, I, E, O, M <: EndpointMiddl } else if (endpoint.error.matchesStatus(response.status)) { endpoint.error.decodeResponse(response).orDie.flip } else { - ZIO.die(new IllegalStateException(s"Status code: ${response.status} is not defined in the endpoint")) + val error = endpoint.codecError.decodeResponse(response) + error + .flatMap(codecError => ZIO.die(codecError)) + .orElse(ZIO.die(new IllegalStateException(s"Status code: ${response.status} is not defined in the endpoint"))) } } } 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 28e57d2783..74afdc4f90 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 @@ -559,7 +559,7 @@ object OpenAPIGen { def operation(endpoint: Endpoint[_, _, _, _, _]): OpenAPI.Operation = { val maybeDoc = Some(endpoint.doc + pathDoc).filter(!_.isEmpty) OpenAPI.Operation( - tags = Nil, + tags = endpoint.tags, summary = None, description = maybeDoc, externalDocs = None, @@ -615,7 +615,8 @@ object OpenAPIGen { OpenAPI.Parameter.queryParameter( name = name, description = mc.docsOpt, - schema = Some(OpenAPI.ReferenceOr.Or(JsonSchema.fromTextCodec(codec))), + // TODO: For single field case classes we need to use the schema of the field + schema = Some(OpenAPI.ReferenceOr.Or(JsonSchema.fromZSchema(codec.schema))), deprecated = mc.deprecated, style = OpenAPI.Parameter.Style.Form, explode = false, diff --git a/zio-http/shared/src/main/scala/zio/http/template/Attributes.scala b/zio-http/shared/src/main/scala/zio/http/template/Attributes.scala index 15f8e95d03..c9d47ce277 100644 --- a/zio-http/shared/src/main/scala/zio/http/template/Attributes.scala +++ b/zio-http/shared/src/main/scala/zio/http/template/Attributes.scala @@ -16,7 +16,9 @@ package zio.http.template -import zio.http.template.Attributes.PartialAttribute +import scala.language.implicitConversions + +import zio.http.template.Attributes.{PartialAttribute, PartialBooleanAttribute} trait Attributes { final def acceptAttr: PartialAttribute[String] = PartialAttribute("accept") @@ -301,7 +303,7 @@ trait Attributes { final def relAttr: PartialAttribute[String] = PartialAttribute("rel") - final def requiredAttr: PartialAttribute[String] = PartialAttribute("required") + final def requiredAttr: PartialBooleanAttribute = PartialBooleanAttribute("required") final def reversedAttr: PartialAttribute[String] = PartialAttribute("reversed") @@ -365,9 +367,16 @@ trait Attributes { final def cellspacingAttr: PartialAttribute[String] = PartialAttribute("cellspacing") + implicit def partialBooleanToHtml(attr: PartialBooleanAttribute): Html = attr.apply() } object Attributes { + case class PartialBooleanAttribute(name: String) { + def :=(value: Boolean): Html = Dom.booleanAttr(name, Some(value)) + def apply(value: Boolean): Html = Dom.booleanAttr(name, Some(value)) + def apply(): Html = Dom.booleanAttr(name, None) + } + case class PartialAttribute[A](name: String) { def :=(value: A)(implicit ev: IsAttributeValue[A]): Html = Dom.attr(name, ev(value)) def apply(value: A)(implicit ev: IsAttributeValue[A]): Html = Dom.attr(name, ev(value)) diff --git a/zio-http/shared/src/main/scala/zio/http/template/Dom.scala b/zio-http/shared/src/main/scala/zio/http/template/Dom.scala index 593a402f77..056e4ceca6 100644 --- a/zio-http/shared/src/main/scala/zio/http/template/Dom.scala +++ b/zio-http/shared/src/main/scala/zio/http/template/Dom.scala @@ -43,7 +43,10 @@ sealed trait Dom { self => private[template] def encode(state: EncodingState, encodeHtml: Boolean = true): CharSequence = self match { case Dom.Element(name, children) => val encode = if (name == "script" || name == "style") false else encodeHtml - val attributes = children.collect { case self: Dom.Attribute => self.encode } + val attributes = children.collect { + case self: Dom.Attribute => self.encode + case self: Dom.BooleanAttribute => self.encode + } val innerState = state.inner val elements = children.collect { @@ -76,6 +79,9 @@ sealed trait Dom { self => case Dom.Attribute(name, value) => s"""$name="${OutputEncoder.encodeHtml(value.toString)}"""" case Dom.Empty => "" case Dom.Raw(raw) => raw + + case Dom.BooleanAttribute(name, None) => s"$name" + case Dom.BooleanAttribute(name, Some(value)) => s"""$name="${value}"""" } } @@ -94,6 +100,8 @@ object Dom { def attr(name: CharSequence, value: CharSequence): Dom = Dom.Attribute(name, value) + def booleanAttr(name: CharSequence, value: Option[Boolean] = None): Dom = Dom.BooleanAttribute(name, value) + def element(name: CharSequence, children: Dom*): Dom = Dom.Element(name, children) def empty: Dom = Empty @@ -110,5 +118,7 @@ object Dom { private[zio] final case class Attribute(name: CharSequence, value: CharSequence) extends Dom + private[zio] final case class BooleanAttribute(name: CharSequence, value: Option[Boolean] = None) extends Dom + private[zio] object Empty extends Dom }