From 1d092b99012f7debb7f50e73283559c2f2b068dc Mon Sep 17 00:00:00 2001 From: Gilad Hoch Date: Thu, 5 Sep 2024 08:19:46 +0300 Subject: [PATCH] [gen] map common abstract fields in trait (oneOf enum) to have (un)aliasing translation of types (#3089) --- .../zio/http/gen/openapi/EndpointGen.scala | 53 +++++++----- .../test/resources/ComponentAliasWeight.scala | 8 ++ ...nentAnimalWithAbstractAliasedMembers.scala | 30 +++++++ ...ntAnimalWithAbstractUnAliasedMembers.scala | 30 +++++++ ..._sumtype_with_reusable_aliased_fields.yaml | 81 +++++++++++++++++++ .../zio/http/gen/scala/CodeGenSpec.scala | 74 +++++++++++++++++ 6 files changed, 255 insertions(+), 21 deletions(-) create mode 100644 zio-http-gen/src/test/resources/ComponentAliasWeight.scala create mode 100644 zio-http-gen/src/test/resources/ComponentAnimalWithAbstractAliasedMembers.scala create mode 100644 zio-http-gen/src/test/resources/ComponentAnimalWithAbstractUnAliasedMembers.scala create mode 100644 zio-http-gen/src/test/resources/inline_schema_sumtype_with_reusable_aliased_fields.yaml 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 ada3ed4e79..6c9209b6b0 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 @@ -191,7 +191,11 @@ final case class EndpointGen(config: Config) { cf.copy( objects = cf.objects.map(mapType(_.name, subtypeToTraits, aliasedPrimitives)), caseClasses = cf.caseClasses.map(mapType(_.name, subtypeToTraits, aliasedPrimitives)), - enums = cf.enums.map(mapType(_.name, subtypeToTraits, aliasedPrimitives)), + enums = cf.enums.map(enm => + mapType[Code.Enum](_.name, subtypeToTraits, aliasedPrimitives)(enm).copy( + abstractMembers = enm.abstractMembers.map(mapField(subtypeToTraits, aliasedPrimitives, enm.name)), + ), + ), ) } } @@ -227,29 +231,36 @@ final case class EndpointGen(config: Config) { codeStructureToAlter: T, ): T = mapCaseClasses { cc => - cc.copy(fields = cc.fields.foldRight(List.empty[Code.Field]) { case (f @ Code.Field(_, scalaType, _), tail) => - f.copy(fieldType = mapTypeRef(scalaType) { case originalType @ Code.TypeRef(tName) => - // We use the subtypeToTraits map to check if the type is a concrete subtype of a sealed trait. - // As of the time of writing this code, there should be only a single trait. - // In case future code generalizes to allow multiple mixins, this code should be updated. - // - // If no mixins are found, we try to check maybe we deal with an aliased primitive, - // which in this case we should use the provided alias (with ".Type" appended). - // - // If no alias, and no mixins, we return the original type. - subtypeToTraits - .get(tName) - .fold(aliasedPrimitives.getOrElse(tName, originalType)) { set => - // If the type parameter has exactly 1 super type trait, - // and that trait's name is different from our enclosing object's name, - // then we should alter the type to include the object's name. - if (set.size != 1 || set.head == getEncapsulatingName(codeStructureToAlter)) originalType - else Code.TypeRef(set.head + "." + tName) - } - }) :: tail + cc.copy(fields = cc.fields.foldRight(List.empty[Code.Field]) { case (field, tail) => + mapField(subtypeToTraits, aliasedPrimitives, getEncapsulatingName(codeStructureToAlter))(field) :: tail }) }(codeStructureToAlter) + def mapField( + subtypeToTraits: Map[String, Set[String]], + aliasedPrimitives: Map[String, ScalaType], + encapsulatingName: => String, + ): Code.Field => Code.Field = (f: Code.Field) => + f.copy(fieldType = mapTypeRef(f.fieldType) { case originalType @ Code.TypeRef(tName) => + // We use the subtypeToTraits map to check if the type is a concrete subtype of a sealed trait. + // As of the time of writing this code, there should be only a single trait. + // In case future code generalizes to allow multiple mixins, this code should be updated. + // + // If no mixins are found, we try to check maybe we deal with an aliased primitive, + // which in this case we should use the provided alias (with ".Type" appended). + // + // If no alias, and no mixins, we return the original type. + subtypeToTraits + .get(tName) + .fold(aliasedPrimitives.getOrElse(tName, originalType)) { set => + // If the type parameter has exactly 1 super type trait, + // and that trait's name is different from our enclosing object's name, + // then we should alter the type to include the object's name. + if (set.size != 1 || set.head == encapsulatingName) originalType + else Code.TypeRef(set.head + "." + tName) + } + }) + /** * Given the type parameter of a field, we may want to alter it, e.g. by * prepending the enclosing trait/object's name. This function will diff --git a/zio-http-gen/src/test/resources/ComponentAliasWeight.scala b/zio-http-gen/src/test/resources/ComponentAliasWeight.scala new file mode 100644 index 0000000000..7f0ebea38e --- /dev/null +++ b/zio-http-gen/src/test/resources/ComponentAliasWeight.scala @@ -0,0 +1,8 @@ +package test.component + +import zio.prelude.Newtype +import zio.schema.Schema + +object Weight extends Newtype[Float] { + implicit val schema: Schema[Weight.Type] = Schema.primitive[Float].transform(wrap, unwrap) +} diff --git a/zio-http-gen/src/test/resources/ComponentAnimalWithAbstractAliasedMembers.scala b/zio-http-gen/src/test/resources/ComponentAnimalWithAbstractAliasedMembers.scala new file mode 100644 index 0000000000..6406fabfbc --- /dev/null +++ b/zio-http-gen/src/test/resources/ComponentAnimalWithAbstractAliasedMembers.scala @@ -0,0 +1,30 @@ +package test.component + +import zio.schema._ +import zio.schema.annotation._ + +@noDiscriminator +sealed trait Animal { + def age: Age.Type + def weight: Weight.Type +} +object Animal { + + implicit val codec: Schema[Animal] = DeriveSchema.gen[Animal] + case class Alligator( + age: Age.Type, + weight: Weight.Type, + num_teeth: Int, + ) extends Animal + object Alligator { + implicit val codec: Schema[Alligator] = DeriveSchema.gen[Alligator] + } + case class Zebra( + age: Age.Type, + weight: Weight.Type, + num_stripes: Int, + ) extends Animal + object Zebra { + implicit val codec: Schema[Zebra] = DeriveSchema.gen[Zebra] + } +} diff --git a/zio-http-gen/src/test/resources/ComponentAnimalWithAbstractUnAliasedMembers.scala b/zio-http-gen/src/test/resources/ComponentAnimalWithAbstractUnAliasedMembers.scala new file mode 100644 index 0000000000..950a98e199 --- /dev/null +++ b/zio-http-gen/src/test/resources/ComponentAnimalWithAbstractUnAliasedMembers.scala @@ -0,0 +1,30 @@ +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/inline_schema_sumtype_with_reusable_aliased_fields.yaml b/zio-http-gen/src/test/resources/inline_schema_sumtype_with_reusable_aliased_fields.yaml new file mode 100644 index 0000000000..0c97dfc1a5 --- /dev/null +++ b/zio-http-gen/src/test/resources/inline_schema_sumtype_with_reusable_aliased_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: + Age: + type: integer + format: int32 + Weight: + type: number + format: float + Animal: + oneOf: + - $ref: '#/components/schemas/Alligator' + - $ref: '#/components/schemas/Zebra' + AnimalSharedFields: + type: object + required: + - age + - weight + properties: + age: + $ref: '#/components/schemas/Age' + weight: + $ref: '#/components/schemas/Weight' + Alligator: + allOf: + - $ref: '#/components/schemas/AnimalSharedFields' + - type: object + required: + - num_teeth + properties: + num_teeth: + type: integer + format: int32 + Zebra: + allOf: + - $ref: '#/components/schemas/AnimalSharedFields' + - type: object + required: + - num_stripes + properties: + num_stripes: + type: integer + format: int32 + HttpError: + type: object + properties: + messages: + type: string 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 300cd287f8..6fc4910a98 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 @@ -332,6 +332,80 @@ object CodeGenSpec extends ZIOSpecDefault { } } } @@ 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 aliased fields") { + val openAPIString = stringFromResource("/inline_schema_sumtype_with_reusable_aliased_fields.yaml") + + openApiFromYamlString(openAPIString) { oapi => + codeGenFromOpenAPI( + oapi, + Config.default.copy(commonFieldsOnSuperType = true, generateSafeTypeAliases = true), + ) { testDir => + allFilesShouldBe( + testDir.toFile, + List( + "api/v1/zoo/Animal.scala", + "component/Animal.scala", + "component/AnimalSharedFields.scala", + "component/Age.scala", + "component/Weight.scala", + "component/HttpError.scala", + ), + ) && fileShouldBe( + testDir, + "api/v1/zoo/Animal.scala", + "/EndpointForZoo.scala", + ) && fileShouldBe( + testDir, + "component/Age.scala", + "/ComponentAliasAge.scala", + ) && fileShouldBe( + testDir, + "component/Weight.scala", + "/ComponentAliasWeight.scala", + ) && fileShouldBe( + testDir, + "component/Animal.scala", + "/ComponentAnimalWithAbstractAliasedMembers.scala", + ) && fileShouldBe( + testDir, + "component/HttpError.scala", + "/ComponentHttpError.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 un-aliased fields") { + val openAPIString = stringFromResource("/inline_schema_sumtype_with_reusable_aliased_fields.yaml") + + openApiFromYamlString(openAPIString) { oapi => + codeGenFromOpenAPI( + oapi, + Config.default.copy(commonFieldsOnSuperType = true, generateSafeTypeAliases = false), + ) { testDir => + 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/Animal.scala", + "/ComponentAnimalWithAbstractUnAliasedMembers.scala", + ) && fileShouldBe( + testDir, + "component/HttpError.scala", + "/ComponentHttpError.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") { val openAPIString = stringFromResource("/inline_schema_sumtype_with_multiple_reusable_fields.yaml")