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 = """