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 63937065b4..732490eaa7 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" @@ -34,6 +35,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)