From 40f24ce27fd02763551947b7d6eb992c770824a1 Mon Sep 17 00:00:00 2001 From: Matthias Berndt Date: Thu, 23 May 2024 17:28:41 +0200 Subject: [PATCH 1/4] remove unused parameter (#2849) this is just clutter afaics Co-authored-by: Nabil Abdel-Hafeez <7283535+987Nabil@users.noreply.github.com> --- .../scala/zio/http/netty/client/NettyClientDriver.scala | 6 ++---- .../src/main/scala/zio/http/netty/server/NettyDriver.scala | 2 +- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/zio-http/jvm/src/main/scala/zio/http/netty/client/NettyClientDriver.scala b/zio-http/jvm/src/main/scala/zio/http/netty/client/NettyClientDriver.scala index 638740a08a..0a0b4e44a5 100644 --- a/zio-http/jvm/src/main/scala/zio/http/netty/client/NettyClientDriver.scala +++ b/zio-http/jvm/src/main/scala/zio/http/netty/client/NettyClientDriver.scala @@ -37,7 +37,6 @@ final case class NettyClientDriver private[netty] ( channelFactory: ChannelFactory[Channel], eventLoopGroup: EventLoopGroup, nettyRuntime: NettyRuntime, - clientConfig: NettyConfig, ) extends ClientDriver { override type Connection = Channel @@ -187,15 +186,14 @@ final case class NettyClientDriver private[netty] ( object NettyClientDriver { private implicit val trace: Trace = Trace.empty - val live: ZLayer[NettyConfig, Throwable, ClientDriver] = + val live: URLayer[EventLoopGroups.Config, ClientDriver] = (EventLoopGroups.live ++ ChannelFactories.Client.live ++ NettyRuntime.live) >>> ZLayer { for { eventLoopGroup <- ZIO.service[EventLoopGroup] channelFactory <- ZIO.service[ChannelFactory[Channel]] nettyRuntime <- ZIO.service[NettyRuntime] - clientConfig <- ZIO.service[NettyConfig] - } yield NettyClientDriver(channelFactory, eventLoopGroup, nettyRuntime, clientConfig) + } yield NettyClientDriver(channelFactory, eventLoopGroup, nettyRuntime) } } diff --git a/zio-http/jvm/src/main/scala/zio/http/netty/server/NettyDriver.scala b/zio-http/jvm/src/main/scala/zio/http/netty/server/NettyDriver.scala index 89e4710259..7c4132dfe1 100644 --- a/zio-http/jvm/src/main/scala/zio/http/netty/server/NettyDriver.scala +++ b/zio-http/jvm/src/main/scala/zio/http/netty/server/NettyDriver.scala @@ -76,7 +76,7 @@ private[zio] final case class NettyDriver( channelFactory <- ChannelFactories.Client.live.build .provideSomeEnvironment[Scope](_ ++ ZEnvironment[ChannelType.Config](nettyConfig)) nettyRuntime <- NettyRuntime.live.build - } yield NettyClientDriver(channelFactory.get, eventLoopGroup, nettyRuntime.get, nettyConfig) + } yield NettyClientDriver(channelFactory.get, eventLoopGroup, nettyRuntime.get) override def toString: String = s"NettyDriver($serverConfig)" } From 9ff331e5f4de178801bfd2cca7d62252f4dfdb1c Mon Sep 17 00:00:00 2001 From: Nabil Abdel-Hafeez <7283535+987Nabil@users.noreply.github.com> Date: Fri, 24 May 2024 19:24:10 +0200 Subject: [PATCH 2/4] Add brotli compression (#2646) (#2857) * Add brotli compression (#2646) * Migrate main --- project/Dependencies.scala | 1 + .../zio/http/netty/model/Conversions.scala | 29 ++-- .../netty/server/ServerInboundHandler.scala | 2 +- .../src/main/scala/zio/http/Server.scala | 146 ++++++++++++------ 4 files changed, 118 insertions(+), 60 deletions(-) diff --git a/project/Dependencies.scala b/project/Dependencies.scala index 63937065b4..3b933d7b9f 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -27,6 +27,7 @@ object Dependencies { "io.netty" % "netty-transport-native-kqueue" % NettyVersion, "io.netty" % "netty-transport-native-kqueue" % NettyVersion % Runtime classifier "osx-x86_64", "io.netty" % "netty-transport-native-kqueue" % NettyVersion % Runtime classifier "osx-aarch_64", + "com.aayushatharva.brotli4j" % "brotli4j" % "1.16.0" % "provided", ) val `netty-incubator` = diff --git a/zio-http/jvm/src/main/scala/zio/http/netty/model/Conversions.scala b/zio-http/jvm/src/main/scala/zio/http/netty/model/Conversions.scala index 9ceb2d1a1f..9658e437ef 100644 --- a/zio-http/jvm/src/main/scala/zio/http/netty/model/Conversions.scala +++ b/zio-http/jvm/src/main/scala/zio/http/netty/model/Conversions.scala @@ -18,12 +18,11 @@ package zio.http.netty.model import scala.collection.AbstractIterator -import zio.stacktracer.TracingImplicits.disableAutoTrace - import zio.http.Server.Config.CompressionOptions import zio.http._ -import io.netty.handler.codec.compression.{DeflateOptions, StandardCompressionOptions} +import com.aayushatharva.brotli4j.encoder.Encoder +import io.netty.handler.codec.compression.StandardCompressionOptions import io.netty.handler.codec.http._ import io.netty.handler.codec.http.websocketx.WebSocketScheme @@ -132,14 +131,26 @@ private[netty] object Conversions { case _ => None } - def compressionOptionsToNetty(compressionOptions: CompressionOptions): DeflateOptions = - compressionOptions.kind match { - case CompressionOptions.CompressionType.GZip => - StandardCompressionOptions.gzip(compressionOptions.level, compressionOptions.bits, compressionOptions.mem) - case CompressionOptions.CompressionType.Deflate => - StandardCompressionOptions.deflate(compressionOptions.level, compressionOptions.bits, compressionOptions.mem) + def compressionOptionsToNetty( + compressionOptions: CompressionOptions, + ): io.netty.handler.codec.compression.CompressionOptions = + compressionOptions match { + case CompressionOptions.GZip(cfg) => + StandardCompressionOptions.gzip(cfg.level, cfg.bits, cfg.mem) + case CompressionOptions.Deflate(cfg) => + StandardCompressionOptions.deflate(cfg.level, cfg.bits, cfg.mem) + case CompressionOptions.Brotli(cfg) => + StandardCompressionOptions.brotli( + new Encoder.Parameters().setQuality(cfg.quality).setWindow(cfg.lgwin).setMode(brotliModeToJava(cfg.mode)), + ) } + def brotliModeToJava(brotli: CompressionOptions.Mode): Encoder.Mode = brotli match { + case CompressionOptions.Mode.Font => Encoder.Mode.FONT + case CompressionOptions.Mode.Text => Encoder.Mode.TEXT + case CompressionOptions.Mode.Generic => Encoder.Mode.GENERIC + } + def versionToNetty(version: Version): HttpVersion = version match { case Version.Http_1_0 => HttpVersion.HTTP_1_0 case Version.Http_1_1 => HttpVersion.HTTP_1_1 diff --git a/zio-http/jvm/src/main/scala/zio/http/netty/server/ServerInboundHandler.scala b/zio-http/jvm/src/main/scala/zio/http/netty/server/ServerInboundHandler.scala index 8d43c1f88e..5298e14c6f 100644 --- a/zio-http/jvm/src/main/scala/zio/http/netty/server/ServerInboundHandler.scala +++ b/zio-http/jvm/src/main/scala/zio/http/netty/server/ServerInboundHandler.scala @@ -213,7 +213,7 @@ private[zio] final case class ServerInboundHandler( case Some(cfg) => val headers = req.headers() val headerName = Header.AcceptEncoding.name - cfg.options.exists(opt => headers.containsValue(headerName, opt.kind.name, true)) + cfg.options.exists(opt => headers.containsValue(headerName, opt.name, true)) } } diff --git a/zio-http/shared/src/main/scala/zio/http/Server.scala b/zio-http/shared/src/main/scala/zio/http/Server.scala index 00ed14acd0..c5e0913b8b 100644 --- a/zio-http/shared/src/main/scala/zio/http/Server.scala +++ b/zio-http/shared/src/main/scala/zio/http/Server.scala @@ -20,7 +20,6 @@ import java.net.{InetAddress, InetSocketAddress} import java.util.concurrent.atomic._ import zio._ -import zio.stacktracer.TracingImplicits.disableAutoTrace import zio.http.Server.Config.ResponseCompressionConfig @@ -248,71 +247,118 @@ object Server extends ServerPlatformSpecific { ResponseCompressionConfig(0, IndexedSeq(CompressionOptions.gzip(), CompressionOptions.deflate())) } - /** - * @param level - * defines compression level, {@code 1} yields the fastest compression and - * {@code 9} yields the best compression. {@code 0} means no compression. - * @param bits - * defines windowBits, The base two logarithm of the size of the history - * buffer. The value should be in the range {@code 9} to {@code 15} - * inclusive. Larger values result in better compression at the expense of - * memory usage - * @param mem - * defines memlevel, How much memory should be allocated for the internal - * compression state. {@code 1} uses minimum memory and {@code 9} uses - * maximum memory. Larger values result in better and faster compression - * at the expense of memory usage - */ - final case class CompressionOptions( - level: Int, - bits: Int, - mem: Int, - kind: CompressionOptions.CompressionType, - ) + sealed trait CompressionOptions { + val name: String + } object CompressionOptions { - val DefaultLevel = 6 - val DefaultBits = 15 - val DefaultMem = 8 + + final case class GZip(cfg: DeflateConfig) extends CompressionOptions { val name = "gzip" } + final case class Deflate(cfg: DeflateConfig) extends CompressionOptions { val name = "deflate" } + final case class Brotli(cfg: BrotliConfig) extends CompressionOptions { val name = "brotli" } + + /** + * @param level + * defines compression level, {@code 1} yields the fastest compression + * and {@code 9} yields the best compression. {@code 0} means no + * compression. + * @param bits + * defines windowBits, The base two logarithm of the size of the history + * buffer. The value should be in the range {@code 9} to {@code 15} + * inclusive. Larger values result in better compression at the expense + * of memory usage + * @param mem + * defines memlevel, How much memory should be allocated for the + * internal compression state. {@code 1} uses minimum memory and + * {@code 9} uses maximum memory. Larger values result in better and + * faster compression at the expense of memory usage + */ + final case class DeflateConfig( + level: Int, + bits: Int, + mem: Int, + ) + + object DeflateConfig { + val DefaultLevel = 6 + val DefaultBits = 15 + val DefaultMem = 8 + } + + final case class BrotliConfig( + quality: Int, + lgwin: Int, + mode: Mode, + ) + + object BrotliConfig { + val DefaultQuality = 4 + val DefaultLgwin = -1 + val DefaultMode = Mode.Text + } + + sealed trait Mode + object Mode { + case object Generic extends Mode + case object Text extends Mode + case object Font extends Mode + + def fromString(s: String): Mode = s.toLowerCase match { + case "generic" => Generic + case "text" => Text + case "font" => Font + case _ => Text + } + } /** * Creates GZip CompressionOptions. Defines defaults as per * io.netty.handler.codec.compression.GzipOptions#DEFAULT */ - def gzip(level: Int = DefaultLevel, bits: Int = DefaultBits, mem: Int = DefaultMem): CompressionOptions = - CompressionOptions(level, bits, mem, CompressionType.GZip) + def gzip( + level: Int = DeflateConfig.DefaultLevel, + bits: Int = DeflateConfig.DefaultBits, + mem: Int = DeflateConfig.DefaultMem, + ): CompressionOptions = + CompressionOptions.GZip(DeflateConfig(level, bits, mem)) /** * Creates Deflate CompressionOptions. Defines defaults as per * io.netty.handler.codec.compression.DeflateOptions#DEFAULT */ - def deflate(level: Int = DefaultLevel, bits: Int = DefaultBits, mem: Int = DefaultMem): CompressionOptions = - CompressionOptions(level, bits, mem, CompressionType.Deflate) - - sealed trait CompressionType { - val name: String - } - - private[http] object CompressionType { - case object GZip extends CompressionType { val name = "gzip" } - case object Deflate extends CompressionType { val name = "deflate" } + def deflate( + level: Int = DeflateConfig.DefaultLevel, + bits: Int = DeflateConfig.DefaultBits, + mem: Int = DeflateConfig.DefaultMem, + ): CompressionOptions = + CompressionOptions.Deflate(DeflateConfig(level, bits, mem)) - lazy val config: zio.Config[CompressionType] = - zio.Config.string.mapOrFail { - case "gzip" => Right(GZip) - case "deflate" => Right(Deflate) - case other => Left(zio.Config.Error.InvalidData(message = s"Invalid compression type: $other")) - } - } + /** + * Creates Brotli CompressionOptions. Defines defaults as per + * io.netty.handler.codec.compression.BrotliOptions#DEFAULT + */ + def brotli( + quality: Int = BrotliConfig.DefaultQuality, + lgwin: Int = BrotliConfig.DefaultLgwin, + mode: Mode = BrotliConfig.DefaultMode, + ): CompressionOptions = + CompressionOptions.Brotli(BrotliConfig(quality, lgwin, mode)) lazy val config: zio.Config[CompressionOptions] = ( - zio.Config.int("level").withDefault(DefaultLevel) ++ - zio.Config.int("bits").withDefault(DefaultBits) ++ - zio.Config.int("mem").withDefault(DefaultMem) ++ - CompressionOptions.CompressionType.config.nested("type") - ).map { case (level, bits, mem, kind) => - CompressionOptions(level, bits, mem, kind) + (zio.Config.int("level").withDefault(DeflateConfig.DefaultLevel) ++ + zio.Config.int("bits").withDefault(DeflateConfig.DefaultBits) ++ + zio.Config.int("mem").withDefault(DeflateConfig.DefaultMem)) ++ + zio.Config.int("quantity").withDefault(BrotliConfig.DefaultQuality) ++ + zio.Config.int("lgwin").withDefault(BrotliConfig.DefaultLgwin) ++ + zio.Config.string("mode").map(Mode.fromString).withDefault(BrotliConfig.DefaultMode) ++ + zio.Config.string("type") + ).map { case (level, bits, mem, quantity, lgwin, mode, typ) => + typ.toLowerCase match { + case "gzip" => gzip(level, bits, mem) + case "deflate" => deflate(level, bits, mem) + case "brotli" => brotli(quantity, lgwin, mode) + } } } } From c2a3249bda3c974a988096e0941c29147feb4069 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 26 May 2024 17:44:04 +0200 Subject: [PATCH 3/4] Update README.md (#2863) Co-authored-by: github-actions[bot] --- README.md | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index f2a68d3e0a..edd2f749f5 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,26 @@ ZIO HTTP is a scala library for building http apps. It is powered by ZIO and [Netty](https://netty.io/) and aims at being the defacto solution for writing, highly scalable and performant web applications using idiomatic Scala. +ZIO HTTP is designed in terms of **HTTP as function**, where both server and client are a function from a request to a response, with a focus on type safety, composability, and testability. + [![Development](https://img.shields.io/badge/Project%20Stage-Development-green.svg)](https://github.com/zio/zio/wiki/Project-Stages) ![CI Badge](https://github.com/zio/zio-http/workflows/Continuous%20Integration/badge.svg) [![Sonatype Releases](https://img.shields.io/nexus/r/https/oss.sonatype.org/dev.zio/zio-http_2.13.svg?label=Sonatype%20Release)](https://oss.sonatype.org/content/repositories/releases/dev/zio/zio-http_2.13/) [![Sonatype Snapshots](https://img.shields.io/nexus/s/https/oss.sonatype.org/dev.zio/zio-http_2.13.svg?label=Sonatype%20Snapshot)](https://oss.sonatype.org/content/repositories/snapshots/dev/zio/zio-http_2.13/) [![javadoc](https://javadoc.io/badge2/dev.zio/zio-http-docs_2.13/javadoc.svg)](https://javadoc.io/doc/dev.zio/zio-http-docs_2.13) [![ZIO Http](https://img.shields.io/github/stars/zio/zio-http?style=social)](https://github.com/zio/zio-http) +Some of the key features of ZIO HTTP are: + +**ZIO Native**: ZIO HTTP is built atop ZIO, a type-safe, composable, and asynchronous effect system for Scala. It inherits all the benefits of ZIO, including testability, composability, and type safety. +**Cloud-Native**: ZIO HTTP is designed for cloud-native environments and supports building highly scalable and performant web applications. Built atop ZIO, it features built-in support for concurrency, parallelism, resource management, error handling, structured logging, configuration management, and metrics instrumentation. +**Imperative and Declarative Endpoints**: ZIO HTTP provides a declarative API for defining HTTP endpoints besides the imperative API. With imperative endpoints, both the shape of the endpoint and the logic are defined together, while with declarative endpoints, the description of the endpoint is separated from its logic. Developers can choose the style that best fit their needs. +**Type-Driven API Design**: Beside the fact that ZIO HTTP supports declarative endpoint descriptions, it also provides a type-driven API design that leverages Scala's type system to ensure correctness and safety at compile time. So the implementation of the endpoint is type-checked against the description of the endpoint. +**Middleware Support**: ZIO HTTP offers middleware support for incorporating cross-cutting concerns such as logging, metrics, authentication, and more into your services. +**Error Handling**: Built-in support exists for handling errors at the HTTP layer, distinguishing between handled and unhandled errors. +**WebSockets**: Built-in support for WebSockets allows for the creation of real-time applications using ZIO HTTP. +**Testkit**: ZIO HTTP provides first-class testing utilities that facilitate test writing without requiring a live server instance. +**Interoperability**: Interoperability with existing Scala/Java libraries is provided, enabling seamless integration with functionality from the Scala/Java ecosystem through the importation of blocking and non-blocking operations. +**JSON and Binary Codecs**: Built-in support for ZIO Schema enables encoding and decoding of request/response bodies, supporting various data types including JSON, Protobuf, Avro, and Thrift. +**Template System**: A built-in DSL facilitates writing HTML templates using Scala code. +**OpenAPI Support**: Built-in support is available for generating OpenAPI documentation for HTTP applications, and conversely, for generating HTTP endpoints from OpenAPI documentation. +**ZIO HTTP CLI**: Command-line applications can be built to interact with HTTP APIs by leveraging the power of [ZIO CLI](https://zio.dev/zio-cli) and ZIO HTTP. + ## Installation Setup via `build.sbt`: @@ -18,14 +36,12 @@ libraryDependencies += "dev.zio" %% "zio-http" % "3.0.0-RC7" **NOTES ON VERSIONING:** -- Older library versions `1.x` or `2.x` with organization `io.d11` of ZIO Http are derived from Dream11, the organization that donated ZIO Http to the ZIO organization in 2022. +- Older library versions `1.x` or `2.x` with organization `io.d11` of ZIO HTTP are derived from Dream11, the organization that donated ZIO HTTP to the ZIO organization in 2022. - Newer library versions, starting in 2023 and resulting from the [ZIO organization](https://dev.zio) started with `0.0.x`, reaching `1.0.0` release candidates in April of 2023 ## Getting Started -ZIO HTTP provides a simple and expressive API for building HTTP applications. It supports both server and client-side APIs. - -ZIO HTTP is designed in terms of **HTTP as function**, where both server and client are a function from `Request` to `Response`. +ZIO HTTP provides a simple and expressive API for building HTTP applications. It supports both server and client-side APIs. Let's see how it is simple to build a greeting server and call it using the client API. ### Greeting Server From 87c3683ef79e9331251a074ecd8c7084681fd2f2 Mon Sep 17 00:00:00 2001 From: Gilad Hoch Date: Sun, 26 May 2024 19:51:14 +0300 Subject: [PATCH 4/4] [gen] fix for codegen of sumtypes with reusable fields (#2850) * [gen] sum types (enums) are now generated with cases in companion that actually extend the sealed trait. Sum types abstract members generation by reusable components in the schema, as an opt-in, configurable feature. Only generating sealed trait body if all subtypes include same reusable component, with it's fields as the abstract trait's members. Redundant duplicate classes for each case is now omitted. Validate fields in case classes and traits does not contain duplicates that cannot be reconciled. Existing tests has been amended to reflect the fix. New tests were added. Some utilities were added. * Update zio-http-gen/src/main/scala/zio/http/gen/openapi/Config.scala * Update zio-http-gen/src/main/scala/zio/http/gen/openapi/Config.scala * fmt --------- Co-authored-by: Nabil Abdel-Hafeez <7283535+987Nabil@users.noreply.github.com> --- build.sbt | 1 + project/Dependencies.scala | 2 + .../scala/zio/http/gen/openapi/Config.scala | 15 ++ .../zio/http/gen/openapi/EndpointGen.scala | 240 ++++++++++++++--- .../main/scala/zio/http/gen/scala/Code.scala | 6 +- .../scala/zio/http/gen/scala/CodeGen.scala | 39 ++- .../src/test/resources/ComponentAnimal.scala | 31 +++ .../ComponentAnimalWithAbstractMembers.scala | 34 +++ .../test/resources/ComponentHttpError.scala | 12 + .../src/test/resources/EndpointForZoo.scala | 15 ++ .../resources/EndpointForZooNoError.scala | 14 + .../GeneratedPaymentNamedDiscriminator.scala | 4 +- .../GeneratedPaymentNoDiscriminator.scala | 4 +- ...ultiple_contradicting_reusable_fields.yaml | 80 ++++++ ...ple_non_contradicting_reusable_fields.yaml | 80 ++++++ ...sumtype_with_multiple_reusable_fields.yaml | 76 ++++++ ...e_schema_sumtype_with_reusable_fields.yaml | 81 ++++++ .../http/gen/openapi/EndpointGenSpec.scala | 20 +- .../zio/http/gen/scala/CodeGenSpec.scala | 248 +++++++++++++++++- 19 files changed, 945 insertions(+), 57 deletions(-) create mode 100644 zio-http-gen/src/main/scala/zio/http/gen/openapi/Config.scala create mode 100644 zio-http-gen/src/test/resources/ComponentAnimal.scala create mode 100644 zio-http-gen/src/test/resources/ComponentAnimalWithAbstractMembers.scala create mode 100644 zio-http-gen/src/test/resources/ComponentHttpError.scala create mode 100644 zio-http-gen/src/test/resources/EndpointForZoo.scala create mode 100644 zio-http-gen/src/test/resources/EndpointForZooNoError.scala create mode 100644 zio-http-gen/src/test/resources/inline_schema_sumtype_with_multiple_contradicting_reusable_fields.yaml create mode 100644 zio-http-gen/src/test/resources/inline_schema_sumtype_with_multiple_non_contradicting_reusable_fields.yaml create mode 100644 zio-http-gen/src/test/resources/inline_schema_sumtype_with_multiple_reusable_fields.yaml create mode 100644 zio-http-gen/src/test/resources/inline_schema_sumtype_with_reusable_fields.yaml diff --git a/build.sbt b/build.sbt index 335d1b7fff..89767d5d66 100644 --- a/build.sbt +++ b/build.sbt @@ -293,6 +293,7 @@ lazy val zioHttpGen = (project in file("zio-http-gen")) `zio-test-sbt`, scalafmt.cross(CrossVersion.for3Use2_13), scalametaParsers.cross(CrossVersion.for3Use2_13).exclude("org.scala-lang.modules", "scala-collection-compat_2.13"), + `zio-json-yaml` % Test ), ) .settings( diff --git a/project/Dependencies.scala b/project/Dependencies.scala index 3b933d7b9f..a2b0be1619 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -7,6 +7,7 @@ object Dependencies { val ScalaCompactCollectionVersion = "2.12.0" val ZioVersion = "2.1.1" val ZioCliVersion = "0.5.0" + val ZioJsonVersion = "0.6.2" val ZioSchemaVersion = "1.1.1" val SttpVersion = "3.3.18" val ZioConfigVersion = "4.0.2" @@ -35,6 +36,7 @@ object Dependencies { val zio = "dev.zio" %% "zio" % ZioVersion val `zio-cli` = "dev.zio" %% "zio-cli" % ZioCliVersion + val `zio-json-yaml` = "dev.zio" %% "zio-json-yaml" % ZioJsonVersion val `zio-streams` = "dev.zio" %% "zio-streams" % ZioVersion val `zio-schema` = "dev.zio" %% "zio-schema" % ZioSchemaVersion val `zio-schema-json` = "dev.zio" %% "zio-schema-json" % ZioSchemaVersion diff --git a/zio-http-gen/src/main/scala/zio/http/gen/openapi/Config.scala b/zio-http-gen/src/main/scala/zio/http/gen/openapi/Config.scala new file mode 100644 index 0000000000..a212e48a44 --- /dev/null +++ b/zio-http-gen/src/main/scala/zio/http/gen/openapi/Config.scala @@ -0,0 +1,15 @@ +package zio.http.gen.openapi + +final case class Config(commonFieldsOnSuperType: Boolean) +object Config { + + val default: Config = Config( + commonFieldsOnSuperType = false, + ) + + lazy val config: zio.Config[Config] = + zio.Config + .boolean("common-fields-on-super-type") + .withDefault(Config.default.commonFieldsOnSuperType) + .map(Config.apply) +} diff --git a/zio-http-gen/src/main/scala/zio/http/gen/openapi/EndpointGen.scala b/zio-http-gen/src/main/scala/zio/http/gen/openapi/EndpointGen.scala index 30a6184994..b7d4ea0b02 100644 --- a/zio-http-gen/src/main/scala/zio/http/gen/openapi/EndpointGen.scala +++ b/zio-http-gen/src/main/scala/zio/http/gen/openapi/EndpointGen.scala @@ -7,7 +7,7 @@ import zio.Chunk import zio.http.Method import zio.http.endpoint.openapi.OpenAPI.ReferenceOr import zio.http.endpoint.openapi.{JsonSchema, OpenAPI} -import zio.http.gen.scala.Code.ScalaType +import zio.http.gen.scala.Code.Collection import zio.http.gen.scala.{Code, CodeGen} object EndpointGen { @@ -28,33 +28,154 @@ object EndpointGen { private val SchemaRef = "#/components/schemas/(.*)".r private val ResponseRef = "#/components/responses/(.*)".r - def fromOpenAPI(openAPI: OpenAPI): Code.Files = - EndpointGen().fromOpenAPI(openAPI) + def fromOpenAPI(openAPI: OpenAPI, config: Config = Config.default): Code.Files = + EndpointGen(config).fromOpenAPI(openAPI) + implicit class MapCompatOps[K, V](m: Map[K, V]) { + // scala 2.12 collection does not support updatedWith natively, so we're adding this as an extension. + def updatedWith(k: K)(f: Option[V] => Option[V]): Map[K, V] = + f(m.get(k)) match { + case Some(v) => m.updated(k, v) + case None => m - k + } + } } -final case class EndpointGen() { +final case class EndpointGen(config: Config) { import EndpointGen._ private var anonymousTypes: Map[String, Code.Object] = Map.empty[String, Code.Object] - def fromOpenAPI(openAPI: OpenAPI): Code.Files = - Code.Files { - val componentsCode = openAPI.components.toList.flatMap { components => - components.schemas.flatMap { case (OpenAPI.Key(name), refOrSchema) => - var annotations: Chunk[JsonSchema.MetaData] = Chunk.empty - val schema = refOrSchema match { - case ReferenceOr.Or(schema: JsonSchema) => - annotations = schema.annotations - schema.withoutAnnotations - case ReferenceOr.Reference(ref, _, _) => - val schema = resolveSchemaRef(openAPI, ref) - annotations = schema.annotations - schema.withoutAnnotations - } - schemaToCode(schema, openAPI, name, annotations) + object OneOfAllReferencesAsSimpleNames { + // if all oneOf schemas are references, + // we should render the oneOf as a sealed trait, + // and make all objects case classes extending that sealed trait. + def unapply(schema: JsonSchema.OneOfSchema): Option[List[String]] = + schema.oneOf.foldRight(Option(List.empty[String])) { + case (JsonSchema.RefSchema(SchemaRef(simpleName)), simpleNames) => + simpleNames.map(simpleName :: _) + case _ => None + } + } + + object AllOfSchemaExistsReferencesAsSimpleNames { + // if all subtypes of a shared trait has same set of allOf schemas, + // then we can render a sealed trait whose abstract methods are the fields shared by all subtypes. + def unapply(schema: JsonSchema.AllOfSchema): Some[List[String]] = Some( + schema.allOf.foldRight(List.empty[String]) { + case (JsonSchema.RefSchema(SchemaRef(simpleName)), simpleNames) => + simpleName :: simpleNames + case (_, simpleNames) => simpleNames + }, + ) + } + + private def extractComponents(openAPI: OpenAPI): List[Code.File] = { + + // maps and inverse bookkeeping for later. + // We'll collect all components relations, + // and use it to amend generated code file. + var traitToSubtypes = Map.empty[String, Set[String]] + var subtypeToTraits = Map.empty[String, Set[String]] + var caseClassToSharedFields = Map.empty[String, Set[String]] + var nameToSchemaAndAnnotations = Map.empty[String, (JsonSchema, Chunk[JsonSchema.MetaData])] + + openAPI.components.toList.foreach { components => + components.schemas.foreach { case (OpenAPI.Key(name), refOrSchema) => + var annotations: Chunk[JsonSchema.MetaData] = Chunk.empty + val schema = refOrSchema match { + case ReferenceOr.Or(schema: JsonSchema) => + annotations = schema.annotations + schema.withoutAnnotations + case ReferenceOr.Reference(ref, _, _) => + val schema = resolveSchemaRef(openAPI, ref) + annotations = schema.annotations + schema.withoutAnnotations } + + schema match { + case OneOfAllReferencesAsSimpleNames(refNames) => + traitToSubtypes = traitToSubtypes.updatedWith(name) { + case Some(subtypes) => Some(subtypes ++ refNames) + case None => Some(refNames.toSet) + } + refNames.foreach { refName => + subtypeToTraits = subtypeToTraits.updatedWith(refName) { + case Some(traits) => Some(traits + name) + case None => Some(Set(name)) + } + } + case AllOfSchemaExistsReferencesAsSimpleNames(refNames) => + if (config.commonFieldsOnSuperType && refNames.nonEmpty) { + caseClassToSharedFields = caseClassToSharedFields.updatedWith(name) { + case Some(fields) => Some(fields ++ refNames) + case None => Some(refNames.toSet) + } + } + case _ => // do nothing + } + + nameToSchemaAndAnnotations = nameToSchemaAndAnnotations.updated(name, schema -> annotations) } + } + + // generate code per component by name + // the generated code will emit file per component, + // even when sum type (sealed trait) is used, + // which in this case, the sealed trait companion contains incomplete case classes (these do not extend anything), + // but we have the complete case classes in separate files. + // so the map will be used to replace inner incomplete enum case classes with complete stand alone files. + val componentNameToCodeFile: Map[String, Code.File] = nameToSchemaAndAnnotations.view.map { + case (name, (schema, annotations)) => + val abstractMembersOfTrait: List[JsonSchema.Object] = + traitToSubtypes + .get(name) + .fold(List.empty[JsonSchema.Object]) { subtypes => + if (subtypes.isEmpty) Nil + else + subtypes.view + .map(caseClassToSharedFields.getOrElse(_, Set.empty)) + .reduce(_ intersect _) + .map(nameToSchemaAndAnnotations) + .collect { case (o: JsonSchema.Object, _) => o } + .toList + } + + val mixins = subtypeToTraits.get(name).fold(List.empty[String])(_.toList) + + name -> schemaToCode(schema, openAPI, name, annotations, mixins, abstractMembersOfTrait) + }.collect { case (name, Some(file)) => name -> file }.toMap + + // for every case class that extends a sealed trait, + // we don't need a separate code file, as it will be included in the sealed trait companion. + // this var stores the bookkeeping of such case classes, and is later used to omit the redundant code files. + var replacedCasesToOmitAsTopComponents = Set.empty[String] + val allComponents = componentNameToCodeFile.view.map { case (name, codeFile) => + traitToSubtypes + .get(name) + .fold(codeFile) { subtypes => + codeFile.copy(enums = codeFile.enums.map { anEnum => + val (shouldBeReplaced, shouldBePreserved) = anEnum.cases.partition(cc => subtypes.contains(cc.name)) + if (shouldBeReplaced.isEmpty) anEnum + else + anEnum.copy(cases = shouldBePreserved ++ shouldBeReplaced.flatMap { cc => + replacedCasesToOmitAsTopComponents = replacedCasesToOmitAsTopComponents + cc.name + componentNameToCodeFile(cc.name).caseClasses + }) + }) + } + }.toList + + allComponents.filterNot { cf => + cf.enums.isEmpty && cf.objects.isEmpty && cf.caseClasses.nonEmpty && cf.caseClasses.forall(cc => + replacedCasesToOmitAsTopComponents(cc.name), + ) + } + } + + def fromOpenAPI(openAPI: OpenAPI): Code.Files = + Code.Files { + val componentsCode = extractComponents(openAPI) openAPI.paths.map { case (path, pathItem) => val pathSegments = path.name.tail.replace('-', '_').split('/').toList val packageName = pathSegments.init.mkString(".").replace("{", "").replace("}", "") @@ -442,11 +563,25 @@ final case class EndpointGen() { } } + private def fieldsOfObject(openAPI: OpenAPI, annotations: Chunk[JsonSchema.MetaData])( + obj: JsonSchema.Object, + ): List[Code.Field] = + obj.properties.map { case (name, schema) => + val field = schemaToField(schema, openAPI, name, annotations) + .getOrElse( + throw new Exception(s"Could not generate code for field $name of object $name"), + ) + .asInstanceOf[Code.Field] + if (obj.required.contains(name)) field else field.copy(fieldType = field.fieldType.opt) + }.toList + def schemaToCode( schema: JsonSchema, openAPI: OpenAPI, name: String, annotations: Chunk[JsonSchema.MetaData], + mixins: List[String] = Nil, + abstractMembers: List[JsonSchema.Object] = Nil, ): Option[Code.File] = { schema match { case JsonSchema.AnnotatedSchema(s, _) => @@ -492,14 +627,14 @@ final case class EndpointGen() { case JsonSchema.OneOfSchema(schemas) if schemas.exists(_.isPrimitive) => throw new Exception("OneOf schemas with primitive types are not supported") case JsonSchema.OneOfSchema(schemas) => - val discriminatorInfo = + val discriminatorInfo = annotations.collectFirst { case JsonSchema.MetaData.Discriminator(discriminator) => discriminator } - val discriminator: Option[String] = discriminatorInfo.map(_.propertyName) - val caseNameMapping: Map[String, String] = discriminatorInfo.map(_.mapping).getOrElse(Map.empty).map { + val discriminator: Option[String] = discriminatorInfo.map(_.propertyName) + val caseNameMapping: Map[String, String] = discriminatorInfo.map(_.mapping).getOrElse(Map.empty).map { case (k, v) => v -> k } - var caseNames: List[String] = Nil - val caseClasses = schemas + var caseNames: List[String] = Nil + val caseClasses = schemas .map(_.withoutAnnotations) .flatMap { case schema @ JsonSchema.Object(properties, _, _) if singleFieldTypeTag(schema) => @@ -527,7 +662,9 @@ final case class EndpointGen() { throw new Exception(s"Unexpected subtype $other for oneOf schema $schema") } .toList - val noDiscriminator = caseNames.isEmpty + val noDiscriminator = caseNames.isEmpty + val unvalidatedFields: List[Code.Field] = abstractMembers.flatMap(fieldsOfObject(openAPI, annotations)) + val abstractMembersFields: List[Code.Field] = validateFields(unvalidatedFields) Some( Code.File( List("component", name.capitalize + ".scala"), @@ -544,13 +681,14 @@ final case class EndpointGen() { discriminator = discriminator, noDiscriminator = noDiscriminator, schema = true, + abstractMembers = abstractMembersFields, ), ), ), ) case JsonSchema.AllOfSchema(schemas) => val genericFieldIndex = Iterator.from(0) - val fields = schemas.map(_.withoutAnnotations).flatMap { + val unvalidatedFields = schemas.map(_.withoutAnnotations).flatMap { case schema @ JsonSchema.Object(_, _, _) => schemaToCode(schema, openAPI, name, annotations) .getOrElse( @@ -575,6 +713,7 @@ final case class EndpointGen() { case other => throw new Exception(s"Unexpected subtype $other for allOf schema $schema") } + val fields = validateFields(unvalidatedFields) Some( Code.File( List("component", name.capitalize + ".scala"), @@ -586,6 +725,7 @@ final case class EndpointGen() { name, fields.toList, companionObject = Some(Code.Object.schemaCompanion(name)), + mixins = mixins, ), ), enums = Nil, @@ -655,15 +795,9 @@ final case class EndpointGen() { case JsonSchema.ArrayType(Some(schema)) => schemaToCode(schema, openAPI, name, annotations) // TODO use additionalProperties - case JsonSchema.Object(properties, _, required) => - val fields = properties.map { case (name, schema) => - val field = schemaToField(schema, openAPI, name, annotations) - .getOrElse( - throw new Exception(s"Could not generate code for field $name of object $name"), - ) - .asInstanceOf[Code.Field] - if (required.contains(name)) field else field.copy(fieldType = field.fieldType.opt) - }.toList + case obj @ JsonSchema.Object(properties, _, _) => + val unvalidatedFields = fieldsOfObject(openAPI, annotations)(obj) + val fields = validateFields(unvalidatedFields) val nested = properties.map { case (name, schema) => name -> schema.withoutAnnotations }.collect { case (name, schema) @@ -688,6 +822,7 @@ final case class EndpointGen() { name, fields, companionObject = Some(Code.Object.schemaCompanion(name)), + mixins = mixins, ), ) ++ nestedCaseClasses, enums = Nil, @@ -706,7 +841,7 @@ final case class EndpointGen() { Code.Enum( name, enums.flatMap { - case JsonSchema.EnumValue.Str(e) => Some(Code.CaseClass(e)) + case JsonSchema.EnumValue.Str(e) => Some(Code.CaseClass(e, mixins)) case JsonSchema.EnumValue.Null => None // can be ignored here, but field of this type should be optional case other => throw new Exception(s"OpenAPI Enums of value $other, are currently unsupported") @@ -720,6 +855,31 @@ final case class EndpointGen() { } } + private val reconcileFieldTypes: (String, Seq[Code.Field]) => Code.Field = (sameName, fields) => { + val reconciledFieldType = fields.view.map(_.fieldType).reduce[Code.ScalaType] { + case (maybeBoth, areTheSame) if maybeBoth == areTheSame => areTheSame + case (Collection.Opt(maybeInner), isTheSame) if maybeInner == isTheSame => isTheSame + case (maybe, Collection.Opt(innerIsTheSame)) if maybe == innerIsTheSame => innerIsTheSame + case (a, b) => + throw new Exception( + s"Fields with the same name $sameName have different types that cannot be reconciled: $a != $b", + ) + } + // smart constructor will double-encode invalid scala term names, + // so we use copy instead of creating a new instance. + // name is the same for all, as we `.groupBy(_.name)` + // and .head is safe (or else `reduce` would have thrown), + // since groupBy returns a non-empty list for each key. + fields.head.copy(fieldType = reconciledFieldType) + } + + private def validateFields(fields: Seq[Code.Field]): List[Code.Field] = + fields + .groupBy(_.name) + .map(reconcileFieldTypes.tupled) + .toList + .sortBy(cf => fields.iterator.map(_.name).indexOf(cf.name)) // preserve original order of fields + private def singleFieldTypeTag(schema: JsonSchema.Object) = schema.properties.size == 1 && schema.properties.head._2.isInstanceOf[JsonSchema.RefSchema] && @@ -757,7 +917,7 @@ final case class EndpointGen() { .map(_.withoutAnnotations) .flatMap(schemaToField(_, openAPI, "unused", annotations)) .map(_.fieldType) - .reduceLeft(ScalaType.Or(_, _)) + .reduceLeft(Code.ScalaType.Or.apply) Some(Code.Field(name, tpe)) case JsonSchema.AllOfSchema(_) => throw new Exception("Inline allOf schemas are not supported for fields") @@ -767,7 +927,7 @@ final case class EndpointGen() { .map(_.withoutAnnotations) .flatMap(schemaToField(_, openAPI, "unused", annotations)) .map(_.fieldType) - .reduceLeft(ScalaType.Or(_, _)) + .reduceLeft(Code.ScalaType.Or.apply) Some(Code.Field(name, tpe)) case JsonSchema.Number(JsonSchema.NumberFormat.Double) => Some(Code.Field(name, Code.Primitive.ScalaDouble)) @@ -786,9 +946,9 @@ final case class EndpointGen() { case JsonSchema.Enum(_) => Some(Code.Field(name, Code.TypeRef(name.capitalize))) case JsonSchema.Null => - Some(Code.Field(name, ScalaType.Unit)) + Some(Code.Field(name, Code.ScalaType.Unit)) case JsonSchema.AnyJson => - Some(Code.Field(name, ScalaType.JsonAST)) + Some(Code.Field(name, Code.ScalaType.JsonAST)) } } diff --git a/zio-http-gen/src/main/scala/zio/http/gen/scala/Code.scala b/zio-http-gen/src/main/scala/zio/http/gen/scala/Code.scala index 2ce8184cac..a151abfcac 100644 --- a/zio-http-gen/src/main/scala/zio/http/gen/scala/Code.scala +++ b/zio-http-gen/src/main/scala/zio/http/gen/scala/Code.scala @@ -62,10 +62,11 @@ object Code { Object(name, schema = false, endpoints, Nil, Nil, Nil) } - final case class CaseClass(name: String, fields: List[Field], companionObject: Option[Object]) extends ScalaType + final case class CaseClass(name: String, fields: List[Field], companionObject: Option[Object], mixins: List[String]) + extends ScalaType object CaseClass { - def apply(name: String): CaseClass = CaseClass(name, Nil, None) + def apply(name: String, mixins: List[String]): CaseClass = CaseClass(name, Nil, None, mixins) } final case class Enum( @@ -75,6 +76,7 @@ object Code { discriminator: Option[String] = None, noDiscriminator: Boolean = false, schema: Boolean = true, + abstractMembers: List[Field] = Nil, ) extends ScalaType sealed abstract case class Field private (name: String, fieldType: ScalaType) extends Code { diff --git a/zio-http-gen/src/main/scala/zio/http/gen/scala/CodeGen.scala b/zio-http-gen/src/main/scala/zio/http/gen/scala/CodeGen.scala index ebc37df40a..da11489647 100644 --- a/zio-http-gen/src/main/scala/zio/http/gen/scala/CodeGen.scala +++ b/zio-http-gen/src/main/scala/zio/http/gen/scala/CodeGen.scala @@ -86,20 +86,24 @@ object CodeGen { "\n}" Nil -> content - case Code.CaseClass(name, fields, companionObject) => + case Code.CaseClass(name, fields, companionObject, mixins) => val (imports, contents) = fields.map(render(basePackage)).unzip val (coImports, coContent) = companionObject.map { co => val (coImports, coContent) = render(basePackage)(co) (coImports, s"\n$coContent") }.getOrElse(Nil -> "") + val mixinsString = mixins match { + case Nil => "" + case _ => mixins.mkString(" extends ", " with ", "") + } val content = s"case class $name(\n" + contents.mkString(",\n").replace("val ", " ") + - "\n)" + coContent + "\n)" + mixinsString + coContent (imports.flatten ++ coImports).distinct -> content - case Code.Enum(name, cases, caseNames, discriminator, noDiscriminator, schema) => + case Code.Enum(name, cases, caseNames, discriminator, noDiscriminator, schema, abstractMembers) => val discriminatorAnnotation = if (noDiscriminator) "@noDiscriminator\n" else "" val discriminatorNameAnnotation = @@ -118,15 +122,40 @@ object CodeGen { imports -> contents.mkString("\n") } + val (traitBodyImports, traitBody) = { + val traitBodyBuilder = new StringBuilder().append(' ') + var pre = '{' + val imports = abstractMembers.foldLeft(List.empty[Code.Import]) { + case (importsAcc, Code.Field(name, fieldType)) => + val (imports, tpe) = render(basePackage)(fieldType) + if (tpe.isEmpty) importsAcc + else { + traitBodyBuilder += pre + pre = '\n' + traitBodyBuilder ++= "def " + traitBodyBuilder ++= name + traitBodyBuilder ++= ": " + traitBodyBuilder ++= tpe + + imports ::: importsAcc + } + } + val body = + if (pre == '{') "\n" + else traitBodyBuilder.append("\n}\n").result() + + imports -> body + } + val content = discriminatorAnnotation + discriminatorNameAnnotation + - s"sealed trait $name\n" + + s"sealed trait $name" + traitBody + s"object $name {\n" + (if (schema) s"\n\n implicit val codec: Schema[$name] = DeriveSchema.gen[$name]\n" else "") + casesContent + "\n}" - casesImports.flatten.distinct -> content + casesImports.foldRight(traitBodyImports)(_ ::: _).distinct -> content case col: Code.Collection => col match { diff --git a/zio-http-gen/src/test/resources/ComponentAnimal.scala b/zio-http-gen/src/test/resources/ComponentAnimal.scala new file mode 100644 index 0000000000..ee5995849e --- /dev/null +++ b/zio-http-gen/src/test/resources/ComponentAnimal.scala @@ -0,0 +1,31 @@ +package test.component + +import zio.schema._ +import zio.schema.annotation._ + +@noDiscriminator +sealed trait Animal +object Animal { + + implicit val codec: Schema[Animal] = DeriveSchema.gen[Animal] + case class Alligator( + age: Int, + weight: Float, + num_teeth: Int, + ) extends Animal + object Alligator { + + implicit val codec: Schema[Alligator] = DeriveSchema.gen[Alligator] + + } + case class Zebra( + age: Int, + weight: Float, + num_stripes: Int, + ) extends Animal + object Zebra { + + implicit val codec: Schema[Zebra] = DeriveSchema.gen[Zebra] + + } +} diff --git a/zio-http-gen/src/test/resources/ComponentAnimalWithAbstractMembers.scala b/zio-http-gen/src/test/resources/ComponentAnimalWithAbstractMembers.scala new file mode 100644 index 0000000000..31d33428a9 --- /dev/null +++ b/zio-http-gen/src/test/resources/ComponentAnimalWithAbstractMembers.scala @@ -0,0 +1,34 @@ +package test.component + +import zio.schema._ +import zio.schema.annotation._ + +@noDiscriminator +sealed trait Animal { + def age: Int + def weight: Float +} +object Animal { + + implicit val codec: Schema[Animal] = DeriveSchema.gen[Animal] + case class Alligator( + age: Int, + weight: Float, + num_teeth: Int, + ) extends Animal + object Alligator { + + implicit val codec: Schema[Alligator] = DeriveSchema.gen[Alligator] + + } + case class Zebra( + age: Int, + weight: Float, + num_stripes: Int, + ) extends Animal + object Zebra { + + implicit val codec: Schema[Zebra] = DeriveSchema.gen[Zebra] + + } +} diff --git a/zio-http-gen/src/test/resources/ComponentHttpError.scala b/zio-http-gen/src/test/resources/ComponentHttpError.scala new file mode 100644 index 0000000000..3fa594a62f --- /dev/null +++ b/zio-http-gen/src/test/resources/ComponentHttpError.scala @@ -0,0 +1,12 @@ +package test.component + +import zio.schema._ + +case class HttpError( + messages: Option[String], +) +object HttpError { + + implicit val codec: Schema[HttpError] = DeriveSchema.gen[HttpError] + +} diff --git a/zio-http-gen/src/test/resources/EndpointForZoo.scala b/zio-http-gen/src/test/resources/EndpointForZoo.scala new file mode 100644 index 0000000000..37f3384f54 --- /dev/null +++ b/zio-http-gen/src/test/resources/EndpointForZoo.scala @@ -0,0 +1,15 @@ +package test.api.v1.zoo + +import test.component._ +import zio.Chunk + +object Animal { + import zio.http._ + import zio.http.endpoint._ + import zio.http.codec._ + val get_animal = Endpoint(Method.GET / "api" / "v1" / "zoo" / string("animal")) + .in[Unit] + .out[Chunk[Animal]](status = Status.Ok) + .outError[HttpError](status = Status.InternalServerError) + +} diff --git a/zio-http-gen/src/test/resources/EndpointForZooNoError.scala b/zio-http-gen/src/test/resources/EndpointForZooNoError.scala new file mode 100644 index 0000000000..c72727a668 --- /dev/null +++ b/zio-http-gen/src/test/resources/EndpointForZooNoError.scala @@ -0,0 +1,14 @@ +package test.api.v1.zoo + +import test.component._ +import zio.Chunk + +object Animal { + import zio.http._ + import zio.http.endpoint._ + import zio.http.codec._ + val get_animal = Endpoint(Method.GET / "api" / "v1" / "zoo" / string("animal")) + .in[Unit] + .out[Chunk[Animal]](status = Status.Ok) + +} diff --git a/zio-http-gen/src/test/resources/GeneratedPaymentNamedDiscriminator.scala b/zio-http-gen/src/test/resources/GeneratedPaymentNamedDiscriminator.scala index 843175202d..a893218ba1 100644 --- a/zio-http-gen/src/test/resources/GeneratedPaymentNamedDiscriminator.scala +++ b/zio-http-gen/src/test/resources/GeneratedPaymentNamedDiscriminator.scala @@ -12,7 +12,7 @@ object PaymentNamedDiscriminator { case class Card( number: String, cvv: String, - ) + ) extends PaymentNamedDiscriminator object Card { implicit val codec: Schema[Card] = DeriveSchema.gen[Card] @@ -21,7 +21,7 @@ object PaymentNamedDiscriminator { @caseName("cash") case class Cash( amount: Int, - ) + ) extends PaymentNamedDiscriminator object Cash { implicit val codec: Schema[Cash] = DeriveSchema.gen[Cash] diff --git a/zio-http-gen/src/test/resources/GeneratedPaymentNoDiscriminator.scala b/zio-http-gen/src/test/resources/GeneratedPaymentNoDiscriminator.scala index 64c0b250f7..9684806325 100644 --- a/zio-http-gen/src/test/resources/GeneratedPaymentNoDiscriminator.scala +++ b/zio-http-gen/src/test/resources/GeneratedPaymentNoDiscriminator.scala @@ -11,7 +11,7 @@ object PaymentNoDiscriminator { case class Card( number: String, cvv: String, - ) + ) extends PaymentNoDiscriminator object Card { implicit val codec: Schema[Card] = DeriveSchema.gen[Card] @@ -19,7 +19,7 @@ object PaymentNoDiscriminator { } case class Cash( amount: Int, - ) + ) extends PaymentNoDiscriminator object Cash { implicit val codec: Schema[Cash] = DeriveSchema.gen[Cash] diff --git a/zio-http-gen/src/test/resources/inline_schema_sumtype_with_multiple_contradicting_reusable_fields.yaml b/zio-http-gen/src/test/resources/inline_schema_sumtype_with_multiple_contradicting_reusable_fields.yaml new file mode 100644 index 0000000000..357e137126 --- /dev/null +++ b/zio-http-gen/src/test/resources/inline_schema_sumtype_with_multiple_contradicting_reusable_fields.yaml @@ -0,0 +1,80 @@ +info: + title: Animals Service + version: 0.0.1 +tags: + - name: Animals_API +paths: + /api/v1/zoo/{animal}: + get: + operationId: get_animal + parameters: + - in: path + name: animal + schema: + type: string + required: true + tags: + - Animals_API + description: Get animals by species name + responses: + "200": + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Animal' + description: OK +openapi: 3.0.3 +components: + schemas: + Animal: + oneOf: + - $ref: '#/components/schemas/Alligator' + - $ref: '#/components/schemas/Zebra' + HasAgeAndWeight: + type: object + required: + - age + properties: + age: + type: integer + format: int32 + minimum: 0 + weight: + type: number + format: float + minimum: 0 + HasWeight: + type: object + required: + - weight + properties: + weight: + type: number + format: double + minimum: 0 + Alligator: + allOf: + - $ref: '#/components/schemas/HasAgeAndWeight' + - $ref: '#/components/schemas/HasWeight' + - type: object + required: + - num_teeth + properties: + num_teeth: + type: integer + format: int32 + minimum: 0 + Zebra: + allOf: + - $ref: '#/components/schemas/HasAgeAndWeight' + - $ref: '#/components/schemas/HasWeight' + - type: object + required: + - num_stripes + properties: + num_stripes: + type: integer + format: int32 + minimum: 0 diff --git a/zio-http-gen/src/test/resources/inline_schema_sumtype_with_multiple_non_contradicting_reusable_fields.yaml b/zio-http-gen/src/test/resources/inline_schema_sumtype_with_multiple_non_contradicting_reusable_fields.yaml new file mode 100644 index 0000000000..442c50dd97 --- /dev/null +++ b/zio-http-gen/src/test/resources/inline_schema_sumtype_with_multiple_non_contradicting_reusable_fields.yaml @@ -0,0 +1,80 @@ +info: + title: Animals Service + version: 0.0.1 +tags: + - name: Animals_API +paths: + /api/v1/zoo/{animal}: + get: + operationId: get_animal + parameters: + - in: path + name: animal + schema: + type: string + required: true + tags: + - Animals_API + description: Get animals by species name + responses: + "200": + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Animal' + description: OK +openapi: 3.0.3 +components: + schemas: + Animal: + oneOf: + - $ref: '#/components/schemas/Alligator' + - $ref: '#/components/schemas/Zebra' + HasAgeAndWeight: + type: object + required: + - age + properties: + age: + type: integer + format: int32 + minimum: 0 + weight: + type: number + format: float + minimum: 0 + HasWeight: + type: object + required: + - weight + properties: + weight: + type: number + format: float + minimum: 0 + Alligator: + allOf: + - $ref: '#/components/schemas/HasAgeAndWeight' + - $ref: '#/components/schemas/HasWeight' + - type: object + required: + - num_teeth + properties: + num_teeth: + type: integer + format: int32 + minimum: 0 + Zebra: + allOf: + - $ref: '#/components/schemas/HasAgeAndWeight' + - $ref: '#/components/schemas/HasWeight' + - type: object + required: + - num_stripes + properties: + num_stripes: + type: integer + format: int32 + minimum: 0 diff --git a/zio-http-gen/src/test/resources/inline_schema_sumtype_with_multiple_reusable_fields.yaml b/zio-http-gen/src/test/resources/inline_schema_sumtype_with_multiple_reusable_fields.yaml new file mode 100644 index 0000000000..d9558384b3 --- /dev/null +++ b/zio-http-gen/src/test/resources/inline_schema_sumtype_with_multiple_reusable_fields.yaml @@ -0,0 +1,76 @@ +info: + title: Animals Service + version: 0.0.1 +tags: + - name: Animals_API +paths: + /api/v1/zoo/{animal}: + get: + operationId: get_animal + parameters: + - in: path + name: animal + schema: + type: string + required: true + tags: + - Animals_API + description: Get animals by species name + responses: + "200": + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Animal' + description: OK +openapi: 3.0.3 +components: + schemas: + Animal: + oneOf: + - $ref: '#/components/schemas/Alligator' + - $ref: '#/components/schemas/Zebra' + HasAge: + type: object + required: + - age + properties: + age: + type: integer + format: int32 + minimum: 0 + HasWeight: + type: object + required: + - weight + properties: + weight: + type: number + format: float + minimum: 0 + Alligator: + allOf: + - $ref: '#/components/schemas/HasAge' + - $ref: '#/components/schemas/HasWeight' + - type: object + required: + - num_teeth + properties: + num_teeth: + type: integer + format: int32 + minimum: 0 + Zebra: + allOf: + - $ref: '#/components/schemas/HasAge' + - $ref: '#/components/schemas/HasWeight' + - type: object + required: + - num_stripes + properties: + num_stripes: + type: integer + format: int32 + minimum: 0 diff --git a/zio-http-gen/src/test/resources/inline_schema_sumtype_with_reusable_fields.yaml b/zio-http-gen/src/test/resources/inline_schema_sumtype_with_reusable_fields.yaml new file mode 100644 index 0000000000..30c8807af8 --- /dev/null +++ b/zio-http-gen/src/test/resources/inline_schema_sumtype_with_reusable_fields.yaml @@ -0,0 +1,81 @@ +info: + title: Animals Service + version: 0.0.1 +tags: + - name: Animals_API +paths: + /api/v1/zoo/{animal}: + get: + operationId: get_animal + parameters: + - in: path + name: animal + schema: + type: string + required: true + tags: + - Animals_API + description: Get animals by species name + responses: + "200": + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Animal' + description: OK + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/HttpError' + description: Internal Server Error +openapi: 3.0.3 +components: + schemas: + Animal: + oneOf: + - $ref: '#/components/schemas/Alligator' + - $ref: '#/components/schemas/Zebra' + AnimalSharedFields: + type: object + required: + - age + - weight + properties: + age: + type: integer + format: int32 + minimum: 0 + weight: + type: number + format: float + minimum: 0 + Alligator: + allOf: + - $ref: '#/components/schemas/AnimalSharedFields' + - type: object + required: + - num_teeth + properties: + num_teeth: + type: integer + format: int32 + minimum: 0 + Zebra: + allOf: + - $ref: '#/components/schemas/AnimalSharedFields' + - type: object + required: + - num_stripes + properties: + num_stripes: + type: integer + format: int32 + minimum: 0 + HttpError: + type: object + properties: + messages: + type: string diff --git a/zio-http-gen/src/test/scala/zio/http/gen/openapi/EndpointGenSpec.scala b/zio-http-gen/src/test/scala/zio/http/gen/openapi/EndpointGenSpec.scala index eed0daab83..b28cadd39b 100644 --- a/zio-http-gen/src/test/scala/zio/http/gen/openapi/EndpointGenSpec.scala +++ b/zio-http-gen/src/test/scala/zio/http/gen/openapi/EndpointGenSpec.scala @@ -676,6 +676,7 @@ object EndpointGenSpec extends ZIOSpecDefault { Code.Field("name", Code.Primitive.ScalaString), ), companionObject = Some(Code.Object.schemaCompanion("User")), + mixins = Nil, ), ), Nil, @@ -696,10 +697,10 @@ object EndpointGenSpec extends ZIOSpecDefault { Code.Enum( "Direction", List( - Code.CaseClass("North"), - Code.CaseClass("South"), - Code.CaseClass("East"), - Code.CaseClass("West"), + Code.CaseClass("North", Nil), + Code.CaseClass("South", Nil), + Code.CaseClass("East", Nil), + Code.CaseClass("West", Nil), ), schema = true, ), @@ -731,6 +732,7 @@ object EndpointGenSpec extends ZIOSpecDefault { Code.Field("cvv", Code.Primitive.ScalaString), ), companionObject = Some(Code.Object.schemaCompanion("Card")), + mixins = Nil, ), Code.CaseClass( "Cash", @@ -738,6 +740,7 @@ object EndpointGenSpec extends ZIOSpecDefault { Code.Field("amount", Code.Primitive.ScalaInt), ), companionObject = Some(Code.Object.schemaCompanion("Cash")), + mixins = Nil, ), ), caseNames = List("Card", "cash"), @@ -771,6 +774,7 @@ object EndpointGenSpec extends ZIOSpecDefault { Code.Field("cvv", Code.Primitive.ScalaString), ), companionObject = Some(Code.Object.schemaCompanion("Card")), + mixins = List("PaymentNamedDiscriminator"), ), Code.CaseClass( "Cash", @@ -778,6 +782,7 @@ object EndpointGenSpec extends ZIOSpecDefault { Code.Field("amount", Code.Primitive.ScalaInt), ), companionObject = Some(Code.Object.schemaCompanion("Cash")), + mixins = List("PaymentNamedDiscriminator"), ), ), caseNames = List("Card", "cash"), @@ -813,6 +818,7 @@ object EndpointGenSpec extends ZIOSpecDefault { Code.Field("cvv", Code.Primitive.ScalaString), ), companionObject = Some(Code.Object.schemaCompanion("Card")), + mixins = List("PaymentNoDiscriminator"), ), Code.CaseClass( "Cash", @@ -820,6 +826,7 @@ object EndpointGenSpec extends ZIOSpecDefault { Code.Field("amount", Code.Primitive.ScalaInt), ), companionObject = Some(Code.Object.schemaCompanion("Cash")), + mixins = List("PaymentNoDiscriminator"), ), ), caseNames = Nil, @@ -871,6 +878,7 @@ object EndpointGenSpec extends ZIOSpecDefault { "RequestBody", fields = fields, companionObject = Some(Code.Object.schemaCompanion("RequestBody")), + mixins = Nil, ), ), enums = Nil, @@ -926,6 +934,7 @@ object EndpointGenSpec extends ZIOSpecDefault { "ResponseBody", fields = fields, companionObject = Some(Code.Object.schemaCompanion("ResponseBody")), + mixins = Nil, ), ), enums = Nil, @@ -981,11 +990,13 @@ object EndpointGenSpec extends ZIOSpecDefault { "RequestBody", fields = fields, companionObject = Some(Code.Object.schemaCompanion("RequestBody")), + mixins = Nil, ), Code.CaseClass( "ResponseBody", fields = fields, companionObject = Some(Code.Object.schemaCompanion("ResponseBody")), + mixins = Nil, ), ), enums = Nil, @@ -1209,6 +1220,7 @@ object EndpointGenSpec extends ZIOSpecDefault { enums = Nil, ), ), + mixins = Nil, ), ), enums = Nil, 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 ab70e02c60..84c0518b1f 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,16 +8,21 @@ import scala.meta._ import scala.meta.parsers._ import scala.util.{Failure, Success, Try} -import zio.Scope +import zio.json.JsonDecoder +import zio.test.Assertion.{hasSameElements, isFailure, isSuccess, throws} import zio.test.TestAspect.{blocking, flaky} +import zio.test.TestFailure.fail import zio.test._ +import zio.{Scope, ZIO} + +import zio.schema.codec.JsonCodec import zio.http._ import zio.http.codec._ import zio.http.endpoint.Endpoint import zio.http.endpoint.openapi.{OpenAPI, OpenAPIGen} import zio.http.gen.model._ -import zio.http.gen.openapi.EndpointGen +import zio.http.gen.openapi.{Config, EndpointGen} @nowarn("msg=missing interpolator") object CodeGenSpec extends ZIOSpecDefault { @@ -43,6 +48,18 @@ object CodeGenSpec extends ZIOSpecDefault { ) })) + private def allFilesShouldBe(base: java.io.File, expectedSubPaths: List[String]): TestResult = { + def recurse(cd: java.io.File, acc: List[String]): List[String] = + cd.listFiles() + .toList + .foldLeft(acc) { (subPaths, file) => + if (file.isDirectory) recurse(file, subPaths) + else file.getAbsolutePath.drop(base.getAbsolutePath.length + 1) :: subPaths + } + + assert(recurse(base, Nil))(hasSameElements(expectedSubPaths)) + } + private val java11OrNewer = { val version = System.getProperty("java.version") if (version.takeWhile(_ != '.').toInt >= 11) TestAspect.identity else TestAspect.ignore @@ -247,6 +264,233 @@ object CodeGenSpec extends ZIOSpecDefault { fileShouldBe(tempDir, "test/api/v1/Keywords.scala", "/EndpointWithRequestResponseBodyWithKeywordsInline.scala") } @@ TestAspect.exceptScala3, // for some reason, the temp dir is empty in Scala 3 + test("OpenAPI spec with inline schema response body of reusable fields") { + + import zio.json.yaml.DecoderYamlOps + implicit val decoder: JsonDecoder[OpenAPI] = JsonCodec.jsonDecoder(OpenAPI.schema) + + val openAPIString = + Files + .readAllLines(Paths.get(getClass.getResource("/inline_schema_sumtype_with_reusable_fields.yaml").toURI)) + .asScala + .mkString("\n") + + openAPIString.fromYaml match { + case Left(error) => TestResult(TestArrow.make(_ => TestTrace.fail(ErrorMessage.text(error)))) + case Right(oapi) => + val code = EndpointGen.fromOpenAPI(oapi) + + val tempDir = Files.createTempDirectory("codegen") + val testDir = tempDir.resolve("test") + + CodeGen.writeFiles(code, testDir, "test", Some(scalaFmtPath)) + + allFilesShouldBe( + testDir.toFile, + List( + "api/v1/zoo/Animal.scala", + "component/Animal.scala", + "component/AnimalSharedFields.scala", + "component/HttpError.scala", + ), + ) && fileShouldBe( + testDir, + "api/v1/zoo/Animal.scala", + "/EndpointForZoo.scala", + ) && fileShouldBe( + testDir, + "component/HttpError.scala", + "/ComponentHttpError.scala", + ) && fileShouldBe( + testDir, + "component/Animal.scala", + "/ComponentAnimal.scala", + ) + } + } @@ TestAspect.exceptScala3, // for some reason, the temp dir is empty in Scala 3 + test("OpenAPI spec with inline schema response body of sum-type with reusable fields") { + + import zio.json.yaml.DecoderYamlOps + implicit val decoder: JsonDecoder[OpenAPI] = JsonCodec.jsonDecoder(OpenAPI.schema) + + val openAPIString = + Files + .readAllLines(Paths.get(getClass.getResource("/inline_schema_sumtype_with_reusable_fields.yaml").toURI)) + .asScala + .mkString("\n") + + openAPIString.fromYaml match { + case Left(error) => TestResult(TestArrow.make(_ => TestTrace.fail(ErrorMessage.text(error)))) + case Right(oapi) => + val code = EndpointGen.fromOpenAPI(oapi, Config(commonFieldsOnSuperType = true)) + + val tempDir = Files.createTempDirectory("codegen") + val testDir = tempDir.resolve("test") + + CodeGen.writeFiles(code, testDir, "test", Some(scalaFmtPath)) + + allFilesShouldBe( + testDir.toFile, + List( + "api/v1/zoo/Animal.scala", + "component/Animal.scala", + "component/AnimalSharedFields.scala", + "component/HttpError.scala", + ), + ) && fileShouldBe( + testDir, + "api/v1/zoo/Animal.scala", + "/EndpointForZoo.scala", + ) && fileShouldBe( + testDir, + "component/HttpError.scala", + "/ComponentHttpError.scala", + ) && fileShouldBe( + testDir, + "component/Animal.scala", + "/ComponentAnimalWithAbstractMembers.scala", + ) + } + } @@ TestAspect.exceptScala3, // for some reason, the temp dir is empty in Scala 3 + test("OpenAPI spec with inline schema response body of sum-type with multiple reusable fields") { + + import zio.json.yaml.DecoderYamlOps + implicit val decoder: JsonDecoder[OpenAPI] = JsonCodec.jsonDecoder(OpenAPI.schema) + + val openAPIString = + Files + .readAllLines( + Paths.get(getClass.getResource("/inline_schema_sumtype_with_multiple_reusable_fields.yaml").toURI), + ) + .asScala + .mkString("\n") + + openAPIString.fromYaml match { + case Left(error) => TestResult(TestArrow.make(_ => TestTrace.fail(ErrorMessage.text(error)))) + case Right(oapi) => + val code = EndpointGen.fromOpenAPI(oapi, Config(commonFieldsOnSuperType = true)) + + val tempDir = Files.createTempDirectory("codegen") + val testDir = tempDir.resolve("test") + + CodeGen.writeFiles(code, testDir, "test", Some(scalaFmtPath)) + + allFilesShouldBe( + testDir.toFile, + List( + "api/v1/zoo/Animal.scala", + "component/Animal.scala", + "component/HasAge.scala", + "component/HasWeight.scala", + ), + ) && fileShouldBe( + testDir, + "api/v1/zoo/Animal.scala", + "/EndpointForZooNoError.scala", + ) && fileShouldBe( + testDir, + "component/Animal.scala", + "/ComponentAnimalWithAbstractMembers.scala", + ) + } + } @@ TestAspect.exceptScala3, // for some reason, the temp dir is empty in Scala 3 + test("OpenAPI spec with inline schema response body of sum-type with multiple contradicting reusable fields") { + + import zio.json.yaml.DecoderYamlOps + implicit val decoder: JsonDecoder[OpenAPI] = JsonCodec.jsonDecoder(OpenAPI.schema) + + val openAPIString = + Files + .readAllLines( + Paths.get( + getClass.getResource("/inline_schema_sumtype_with_multiple_contradicting_reusable_fields.yaml").toURI, + ), + ) + .asScala + .mkString("\n") + + openAPIString.fromYaml match { + case Left(error) => TestResult(TestArrow.make(_ => TestTrace.fail(ErrorMessage.text(error)))) + case Right(oapi) => + assert { + Try(EndpointGen.fromOpenAPI(oapi)) + }(isFailure) + } + } @@ TestAspect.exceptScala3, // for some reason, the temp dir is empty in Scala 3 + test( + "OpenAPI spec with inline schema response body of sum-type with multiple contradicting reusable fields and super type members", + ) { + + import zio.json.yaml.DecoderYamlOps + implicit val decoder: JsonDecoder[OpenAPI] = JsonCodec.jsonDecoder(OpenAPI.schema) + + val openAPIString = + Files + .readAllLines( + Paths.get( + getClass.getResource("/inline_schema_sumtype_with_multiple_contradicting_reusable_fields.yaml").toURI, + ), + ) + .asScala + .mkString("\n") + + openAPIString.fromYaml match { + case Left(error) => TestResult(TestArrow.make(_ => TestTrace.fail(ErrorMessage.text(error)))) + case Right(oapi) => + assert { + Try(EndpointGen.fromOpenAPI(oapi, Config(commonFieldsOnSuperType = true))) + }(isFailure) + } + } @@ TestAspect.exceptScala3, // for some reason, the temp dir is empty in Scala 3 + test( + "OpenAPI spec with inline schema response body of sum-type with multiple non-contradicting reusable fields and super type members", + ) { + + import zio.json.yaml.DecoderYamlOps + implicit val decoder: JsonDecoder[OpenAPI] = JsonCodec.jsonDecoder(OpenAPI.schema) + + val openAPIString = + Files + .readAllLines( + Paths.get( + getClass + .getResource("/inline_schema_sumtype_with_multiple_non_contradicting_reusable_fields.yaml") + .toURI, + ), + ) + .asScala + .mkString("\n") + + openAPIString.fromYaml match { + case Left(error) => TestResult(TestArrow.make(_ => TestTrace.fail(ErrorMessage.text(error)))) + case Right(oapi) => + val t = Try(EndpointGen.fromOpenAPI(oapi, Config(commonFieldsOnSuperType = true))) + assert(t)(isSuccess) && { + val tempDir = Files.createTempDirectory("codegen") + val testDir = tempDir.resolve("test") + + CodeGen.writeFiles(t.get, testDir, "test", Some(scalaFmtPath)) + + allFilesShouldBe( + testDir.toFile, + List( + "api/v1/zoo/Animal.scala", + "component/Animal.scala", + "component/HasAgeAndWeight.scala", + "component/HasWeight.scala", + ), + ) && fileShouldBe( + testDir, + "api/v1/zoo/Animal.scala", + "/EndpointForZooNoError.scala", + ) && fileShouldBe( + testDir, + "component/Animal.scala", + "/ComponentAnimalWithAbstractMembers.scala", + ) + } + } + } @@ TestAspect.exceptScala3, // for some reason, the temp dir is empty in Scala 3 test("Endpoint with array field in input") { val endpoint = Endpoint(Method.POST / "api" / "v1" / "users").in[UserNameArray].out[User] val openAPI = OpenAPIGen.fromEndpoints("", "", endpoint)