diff --git a/build.sbt b/build.sbt index 956cf2106a..fd13e006d5 100644 --- a/build.sbt +++ b/build.sbt @@ -282,9 +282,9 @@ lazy val zioHttpExample = (project in file("zio-http-example")) .settings(libraryDependencies ++= Seq(`jwt-core`, `zio-schema-json`)) .settings( libraryDependencies ++= Seq( - "dev.zio" %% "zio-config" % ZioConfigVersion, - "dev.zio" %% "zio-config-typesafe" % ZioConfigVersion, - "dev.zio" %% "zio-config-magnolia" % ZioConfigVersion, + `zio-config`, + `zio-config-magnolia`, + `zio-config-typesafe`, "dev.zio" %% "zio-metrics-connectors" % "2.3.1", "dev.zio" %% "zio-metrics-connectors-prometheus" % "2.3.1", ), @@ -305,6 +305,7 @@ lazy val zioHttpGen = (project in file("zio-http-gen")) `zio`, `zio-test`, `zio-test-sbt`, + `zio-config`, scalafmt.cross(CrossVersion.for3Use2_13), scalametaParsers .cross(CrossVersion.for3Use2_13) @@ -404,9 +405,9 @@ lazy val docs = project libraryDependencies ++= Seq( `jwt-core`, "dev.zio" %% "zio-test" % ZioVersion, - "dev.zio" %% "zio-config" % ZioConfigVersion, - "dev.zio" %% "zio-config-magnolia" % ZioConfigVersion, - "dev.zio" %% "zio-config-typesafe" % ZioConfigVersion, + `zio-config`, + `zio-config-magnolia`, + `zio-config-typesafe`, ), publish / skip := true, mdocVariables ++= Map( diff --git a/project/Dependencies.scala b/project/Dependencies.scala index 516cda6ae7..a8c6e1c563 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -8,6 +8,7 @@ object Dependencies { val ZioVersion = "2.1.9" val ZioCliVersion = "0.5.0" val ZioJsonVersion = "0.7.1" + val ZioParserVersion = "0.1.10" val ZioSchemaVersion = "1.4.1" val SttpVersion = "3.3.18" val ZioConfigVersion = "4.0.2" @@ -36,6 +37,9 @@ object Dependencies { val zio = "dev.zio" %% "zio" % ZioVersion val `zio-cli` = "dev.zio" %% "zio-cli" % ZioCliVersion + val `zio-config` = "dev.zio" %% "zio-config" % ZioConfigVersion + val `zio-config-magnolia` = "dev.zio" %% "zio-config-magnolia" % ZioConfigVersion + val `zio-config-typesafe` = "dev.zio" %% "zio-config-typesafe" % ZioConfigVersion 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 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 index 3f90a49d56..11b8350902 100644 --- 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 @@ -1,38 +1,83 @@ package zio.http.gen.openapi +import zio.config.ConfigOps + +import zio.http.gen.openapi.Config.NormalizeFields + +// format: off +/** + * @param commonFieldsOnSuperType oneOf expressions in openapi result in sealed traits in generated scala code. + * if this flag is set to true, and all oneOf's "subtypes" are defined in terms of + * an allOf expression, and all share same object(s) included in the allOf expression, + * then the common fields from that shared object(s) will result in abstract fields + * defined on the sealed trait. + * + * @param generateSafeTypeAliases Referencing primitives, and giving them a name makes the openapi spec more readable. + * By default, the generated scala code will resolve the referenced name, + * and replace it with the primitive type. + * By setting this flag to true, the generator will create components with zio.prelude Newtype + * definitions wrapping over the aliased primitive type. + * + * Note: only aliased primitives are supported for now. + * + * TODO: in the future we can consider an enum instead of boolean for different aliased types. + * e.g: scala 3 opaque types, neotype, prelude's newtype, etc'… + * + * @param fieldNamesNormalization OpenAPI can declare fields that have unconventional "casing" in scala, + * like snake_case, or kebab-case. + * This configuration allows to normalize these fields. + * The original casing will be preserved via a @fieldName("") annotation. + */ +// format: on final case class Config( - commonFieldsOnSuperType: Boolean /* - * oneOf expressions in openapi result in sealed traits in generated scala code. - * if this flag is set to true, and all oneOf's "sub types" are defined in terms of - * an allOf expression, and all share same object(s) included in the allOf expression, - * then the common fields from that shared object(s) will result in abstract fields - * defined on the sealed trait. - */, - generateSafeTypeAliases: Boolean, /* - * Referencing primitives, and giving them a name makes the openapi spec more readable. - * By default, the generated scala code will resolve the referenced name, - * and replace it with the primitive type. - * By setting this flag to true, the generator will create components with zio.prelude Newtype - * definitions wrapping over the aliased primitive type. - * - * Note: only aliased primitives are supported for now. - * - * TODO: in the future we can consider an enum instead of boolean for different aliased types. - * e.g: scala 3 opaque types, neotype, prelude's newtype, etc'… - */ + commonFieldsOnSuperType: Boolean, + generateSafeTypeAliases: Boolean, + fieldNamesNormalization: NormalizeFields, ) object Config { + // format: off + /** + * @param enableAutomatic If enabled, the generator will attempt to normalize field names to camelCase, + * unless original field is defined in the specialReplacements map. + * + * @param manualOverrides When normalization is enabled, a heuristic parser will attempt to normalize field names. + * But this is not always possible, or may not yield the desired result. + * Consider field names that are defined in the JSON as `"1st"`, `"2nd"`, or `"3rd"`: + * You may want to override auto normalization in this case and provide a map like: {{{ + * Map( + * "1st" -> "first", + * "2nd" -> "second", + * "3rd" -> "third" + * ) + * }}} + */ + // format: on + final case class NormalizeFields( + enableAutomatic: Boolean, + manualOverrides: Map[String, String], + ) + object NormalizeFields { + lazy val config: zio.Config[NormalizeFields] = ( + zio.Config.boolean("enabled").withDefault(Config.default.fieldNamesNormalization.enableAutomatic) ++ + zio.Config + .table("special-replacements", zio.Config.string) + .withDefault(Config.default.fieldNamesNormalization.manualOverrides) + ).to[NormalizeFields] + } + val default: Config = Config( commonFieldsOnSuperType = false, generateSafeTypeAliases = false, + fieldNamesNormalization = NormalizeFields( + enableAutomatic = false, + manualOverrides = Map.empty, + ), ) - lazy val config: zio.Config[Config] = { - val c = - zio.Config.boolean("common-fields-on-super-type").withDefault(Config.default.commonFieldsOnSuperType) ++ - zio.Config.boolean("generate-safe-type-aliases").withDefault(Config.default.generateSafeTypeAliases) - - c.map { case (a, b) => Config(a, b) } - } + lazy val config: zio.Config[Config] = ( + zio.Config.boolean("common-fields-on-super-type").withDefault(Config.default.commonFieldsOnSuperType) ++ + zio.Config.boolean("generate-safe-type-aliases").withDefault(Config.default.generateSafeTypeAliases) ++ + NormalizeFields.config.nested("fields-normalization") + ).to[Config] } 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 930195cd10..ada3ed4e79 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 @@ -1,13 +1,14 @@ package zio.http.gen.openapi import scala.annotation.tailrec +import scala.reflect.ClassTag 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.{CodecType, Collection, PathSegmentCode, ScalaType, TypeRef} +import zio.http.gen.scala.Code._ import zio.http.gen.scala.{Code, CodeGen} object EndpointGen { @@ -372,7 +373,7 @@ final case class EndpointGen(config: Config) { } private def fieldName(op: OpenAPI.Operation, fallback: String) = - Code.Field(op.operationId.getOrElse(fallback)) + Code.Field(op.operationId.getOrElse(fallback), config.fieldNamesNormalization) private def endpoint( segments: List[Code.PathSegmentCode], @@ -900,7 +901,7 @@ final case class EndpointGen(config: Config) { ) case JsonSchema.AllOfSchema(schemas) => val genericFieldIndex = Iterator.from(0) - val unvalidatedFields = schemas.map(_.withoutAnnotations).flatMap { + val unvalidatedFields = schemas.toList.map(_.withoutAnnotations).flatMap { case schema @ JsonSchema.Object(_, _, _) => schemaToCode(schema, openAPI, name, annotations) .getOrElse( @@ -908,8 +909,7 @@ final case class EndpointGen(config: Config) { ) .caseClasses .headOption - .toList - .flatMap(_.fields) + .fold(List.empty[Code.Field])(_.fields) case schema @ JsonSchema.RefSchema(SchemaRef(name)) => schemaToCode(schema, openAPI, name, annotations) .getOrElse( @@ -917,11 +917,10 @@ final case class EndpointGen(config: Config) { ) .caseClasses .headOption - .toList - .flatMap(_.fields) + .fold(List.empty[Code.Field])(_.fields) case schema if schema.isPrimitive => val name = s"field${genericFieldIndex.next()}" - Chunk(schemaToField(schema, openAPI, name, annotations)).flatten + schemaToField(schema, openAPI, name, annotations).toList case other => throw new Exception(s"Unexpected subtype $other for allOf schema $schema") } @@ -935,7 +934,7 @@ final case class EndpointGen(config: Config) { caseClasses = List( Code.CaseClass( name, - fields.toList, + fields, companionObject = Some(Code.Object.schemaCompanion(name)), mixins = mixins, ), @@ -1089,7 +1088,7 @@ final case class EndpointGen(config: Config) { fields.head.copy(fieldType = reconciledFieldType) } - private def validateFields(fields: Seq[Code.Field]): List[Code.Field] = + private def validateFields(fields: List[Code.Field]): List[Code.Field] = fields .groupBy(_.name) .map { case (name, fields) => reconcileFieldTypes(name, fields) } @@ -1102,6 +1101,57 @@ final case class EndpointGen(config: Config) { schema.additionalProperties == Left(false) && schema.required == Chunk(schema.properties.head._1) + private def annotationImports: List[Import] = List( + Code.Import.Absolute("zio.schema.annotation.validate"), + Code.Import.Absolute("zio.schema.validation.Validation"), + ) + + def addStringValidations(minLength: Option[Int], maxLength: Option[Int]): List[Annotation] = { + (maxLength, minLength) match { + case (Some(max), Some(min)) => + Annotation( + s"@validate[String](Validation.maxLength($max) && Validation.minLength($min))", + annotationImports, + ) :: Nil + case (Some(max), None) => + Annotation(s"@validate[String](Validation.maxLength($max))", annotationImports) :: Nil + case (None, Some(min)) => + Annotation(s"@validate[String](Validation.minLength($min))", annotationImports) :: Nil + case (None, None) => + Nil + } + } + + def addNumericValidations[T: ClassTag](minOpt: Option[T], maxOpt: Option[T]): List[Annotation] = { + def typeName: String = implicitly[ClassTag[T]].toString + + (minOpt, maxOpt) match { + case (Some(min), Some(max)) => + Annotation( + s"@validate[${typeName}](Validation.greaterThan($min) && Validation.lessThan($max))", + annotationImports, + ) :: Nil + case (Some(min), None) => + Annotation(s"@validate[${typeName}](Validation.greaterThan($min))", annotationImports) :: Nil + case (None, Some(max)) => + Annotation(s"@validate[${typeName}](Validation.lessThan($max))", annotationImports) :: Nil + case (None, None) => + Nil + } + } + + private def safeCastLongToInt(l: Long): Int = { + val i = l.intValue() + require(l == i, s"Long[$l] does not fit in an Int: failed to cast") + i + } + + private def safeCastDoubleToFloat(d: Double): Float = { + val f = d.floatValue() + require(d == f, s"Double[$d] does not fit in a Float: failed to cast") + f + } + def schemaToField( schema: JsonSchema, openAPI: OpenAPI, @@ -1112,7 +1162,7 @@ final case class EndpointGen(config: Config) { case JsonSchema.AnnotatedSchema(s, _) => schemaToField(s.withoutAnnotations, openAPI, name, schema.annotations) case JsonSchema.RefSchema(SchemaRef(ref)) => - Some(Code.Field(name, Code.TypeRef(ref.capitalize))) + Some(Code.Field(name, Code.TypeRef(ref.capitalize), config.fieldNamesNormalization)) case JsonSchema.RefSchema(ref) => throw new Exception(s" Not found: $ref. Only references to internal schemas are supported.") case JsonSchema.Integer( @@ -1131,18 +1181,13 @@ final case class EndpointGen(config: Config) { if (exclusiveMaximum.isDefined && exclusiveMaximum.get == Left(true)) maximum else if (exclusiveMaximum.isDefined && exclusiveMaximum.get.isRight) exclusiveMaximum.get.toOption else maximum.map(_ + 1) - val annotations = (exclusiveMin, exclusiveMax) match { - case (Some(min), Some(max)) => - s"@zio.schema.annotation.validate[Int](zio.schema.validation.Validation.greaterThan($min) && zio.schema.validation.Validation.lessThan($max))" :: Nil - case (Some(min), None) => - s"@zio.schema.annotation.validate[Int](zio.schema.validation.Validation.greaterThan($min))" :: Nil - case (None, Some(max)) => - s"@zio.schema.annotation.validate[Int](zio.schema.validation.Validation.lessThan($max))" :: Nil - case (None, None) => - Nil - } - Some(Code.Field(name, Code.Primitive.ScalaInt, annotations.map(Code.Annotation.apply))) + val annotations = addNumericValidations[Int]( + exclusiveMin.collect { case l if l >= Int.MinValue => safeCastLongToInt(l) }, + exclusiveMax.collect { case l if l <= Int.MaxValue => safeCastLongToInt(l) }, + ) + + Some(Code.Field(name, Code.Primitive.ScalaInt, annotations, config.fieldNamesNormalization)) case JsonSchema.Integer( JsonSchema.IntegerFormat.Int64, minimum, @@ -1159,17 +1204,9 @@ final case class EndpointGen(config: Config) { if (exclusiveMaximum.isDefined && exclusiveMaximum.get == Left(true)) maximum else if (exclusiveMaximum.isDefined && exclusiveMaximum.get.isRight) exclusiveMaximum.get.toOption else maximum.map(_ + 1) - val annotations = (exclusiveMin, exclusiveMax) match { - case (Some(min), Some(max)) => - s"@zio.schema.annotation.validate[Long](zio.schema.validation.Validation.greaterThan($min) && zio.schema.validation.Validation.lessThan($max))" :: Nil - case (Some(min), None) => - s"@zio.schema.annotation.validate[Long](zio.schema.validation.Validation.greaterThan($min))" :: Nil - case (None, Some(max)) => - s"@zio.schema.annotation.validate[Long](zio.schema.validation.Validation.lessThan($max))" :: Nil - case (None, None) => - Nil - } - Some(Code.Field(name, Code.Primitive.ScalaLong, annotations.map(Code.Annotation.apply))) + + val annotations = addNumericValidations[Long](exclusiveMin, exclusiveMax) + Some(Code.Field(name, Code.Primitive.ScalaLong, annotations, config.fieldNamesNormalization)) case JsonSchema.Integer( JsonSchema.IntegerFormat.Timestamp, minimum, @@ -1186,45 +1223,17 @@ final case class EndpointGen(config: Config) { if (exclusiveMaximum.isDefined && exclusiveMaximum.get == Left(true)) maximum else if (exclusiveMaximum.isDefined && exclusiveMaximum.get.isRight) exclusiveMaximum.get.toOption else maximum.map(_ + 1) - val annotations = (exclusiveMin, exclusiveMax) match { - case (Some(min), Some(max)) => - s"@zio.schema.annotation.validate[Long](zio.schema.validation.Validation.greaterThan($min) && zio.schema.validation.Validation.lessThan($max))" :: Nil - case (Some(min), None) => - s"@zio.schema.annotation.validate[Long](zio.schema.validation.Validation.greaterThan($min))" :: Nil - case (None, Some(max)) => - s"@zio.schema.annotation.validate[Long](zio.schema.validation.Validation.lessThan($max))" :: Nil - case (None, None) => - Nil - } - Some(Code.Field(name, Code.Primitive.ScalaLong, annotations.map(Code.Annotation.apply))) + val annotations = addNumericValidations[Long](exclusiveMin, exclusiveMax) + Some(Code.Field(name, Code.Primitive.ScalaLong, annotations, config.fieldNamesNormalization)) case JsonSchema.String(Some(JsonSchema.StringFormat.UUID), _, maxLength, minLength) => - val annotations = - (maxLength, minLength) match { - case (Some(max), Some(min)) => - s"@zio.schema.annotation.validate[String](zio.schema.validation.Validation.maxLength($max) && zio.schema.validation.Validation.minLength($min))" :: Nil - case (Some(max), None) => - s"@zio.schema.annotation.validate[String](zio.schema.validation.Validation.maxLength($max))" :: Nil - case (None, Some(min)) => - s"@zio.schema.annotation.validate[String](zio.schema.validation.Validation.minLength($min))" :: Nil - case (None, None) => - Nil - } - Some(Code.Field(name, Code.Primitive.ScalaUUID, annotations.map(Code.Annotation.apply))) + val annotations = addStringValidations(minLength, maxLength) + Some(Code.Field(name, Code.Primitive.ScalaUUID, annotations, config.fieldNamesNormalization)) case JsonSchema.String(_, _, maxLength, minLength) => - val annotations = (maxLength, minLength) match { - case (Some(max), Some(min)) => - s"@zio.schema.annotation.validate[String](zio.schema.validation.Validation.maxLength($max) && zio.schema.validation.Validation.minLength($min))" :: Nil - case (Some(max), None) => - s"@zio.schema.annotation.validate[String](zio.schema.validation.Validation.maxLength($max))" :: Nil - case (None, Some(min)) => - s"@zio.schema.annotation.validate[String](zio.schema.validation.Validation.minLength($min))" :: Nil - case (None, None) => - Nil - } - Some(Code.Field(name, Code.Primitive.ScalaString, annotations.map(Code.Annotation.apply))) + val annotations = addStringValidations(minLength, maxLength) + Some(Code.Field(name, Code.Primitive.ScalaString, annotations, config.fieldNamesNormalization)) case JsonSchema.Boolean => - Some(Code.Field(name, Code.Primitive.ScalaBoolean)) + Some(Code.Field(name, Code.Primitive.ScalaBoolean, config.fieldNamesNormalization)) case JsonSchema.OneOfSchema(schemas) => val tpe = schemas @@ -1232,7 +1241,7 @@ final case class EndpointGen(config: Config) { .flatMap(schemaToField(_, openAPI, "unused", annotations)) .map(_.fieldType) .reduceLeft(Code.ScalaType.Or.apply) - Some(Code.Field(name, tpe)) + Some(Code.Field(name, tpe, config.fieldNamesNormalization)) case JsonSchema.AllOfSchema(_) => throw new Exception("Inline allOf schemas are not supported for fields") case JsonSchema.AnyOfSchema(schemas) => @@ -1242,7 +1251,7 @@ final case class EndpointGen(config: Config) { .flatMap(schemaToField(_, openAPI, "unused", annotations)) .map(_.fieldType) .reduceLeft(Code.ScalaType.Or.apply) - Some(Code.Field(name, tpe)) + Some(Code.Field(name, tpe, config.fieldNamesNormalization)) case JsonSchema.Number(JsonSchema.NumberFormat.Double, minimum, exclusiveMinimum, maximum, exclusiveMaximum, _) => val exclusiveMin = if (exclusiveMinimum.isDefined && exclusiveMinimum.get == Left(true)) minimum @@ -1253,17 +1262,8 @@ final case class EndpointGen(config: Config) { else if (exclusiveMaximum.isDefined && exclusiveMaximum.get.isRight) exclusiveMaximum.get.toOption else maximum.map(_ + 1) - val annotations = (exclusiveMin, exclusiveMax) match { - case (Some(min), Some(max)) => - s"@zio.schema.annotation.validate[Double](zio.schema.validation.Validation.greaterThan($min) && zio.schema.validation.Validation.lessThan($max))" :: Nil - case (Some(min), None) => - s"@zio.schema.annotation.validate[Double](zio.schema.validation.Validation.greaterThan($min))" :: Nil - case (None, Some(max)) => - s"@zio.schema.annotation.validate[Double](zio.schema.validation.Validation.lessThan($max))" :: Nil - case (None, None) => - Nil - } - Some(Code.Field(name, Code.Primitive.ScalaDouble, annotations.map(Code.Annotation.apply))) + val annotations = addNumericValidations[Double](exclusiveMin, exclusiveMax) + Some(Code.Field(name, Code.Primitive.ScalaDouble, annotations, config.fieldNamesNormalization)) case JsonSchema.Number(JsonSchema.NumberFormat.Float, minimum, exclusiveMinimum, maximum, exclusiveMaximum, _) => val exclusiveMin = if (exclusiveMinimum.isDefined && exclusiveMinimum.get == Left(true)) minimum @@ -1274,17 +1274,11 @@ final case class EndpointGen(config: Config) { else if (exclusiveMaximum.isDefined && exclusiveMaximum.get.isRight) exclusiveMaximum.get.toOption else maximum.map(_ + 1) - val annotations = (exclusiveMin, exclusiveMax) match { - case (Some(min), Some(max)) => - s"@zio.schema.annotation.validate[Float](zio.schema.validation.Validation.greaterThan($min) && zio.schema.validation.Validation.lessThan($max))" :: Nil - case (Some(min), None) => - s"@zio.schema.annotation.validate[Float](zio.schema.validation.Validation.greaterThan($min))" :: Nil - case (None, Some(max)) => - s"@zio.schema.annotation.validate[Float](zio.schema.validation.Validation.lessThan($max))" :: Nil - case (None, None) => - Nil - } - Some(Code.Field(name, Code.Primitive.ScalaFloat, annotations.map(Code.Annotation.apply))) + val annotations = addNumericValidations[Float]( + exclusiveMin.collect { case l if l >= Float.MinValue => safeCastDoubleToFloat(l) }, + exclusiveMax.collect { case l if l <= Float.MaxValue => safeCastDoubleToFloat(l) }, + ) + Some(Code.Field(name, Code.Primitive.ScalaFloat, annotations, config.fieldNamesNormalization)) case JsonSchema.ArrayType(items, minItems, uniqueItems) => val nonEmpty = minItems.exists(_ > 1) val tpe = items @@ -1295,7 +1289,7 @@ final case class EndpointGen(config: Config) { if (uniqueItems) Code.Primitive.ScalaString.set(nonEmpty) else Code.Primitive.ScalaString.seq(nonEmpty) }, ) - tpe.map(Code.Field(name, _)) + tpe.map(Code.Field(name, _, config.fieldNamesNormalization)) case JsonSchema.Object(properties, additionalProperties, _) if properties.nonEmpty && additionalProperties.isRight => // Can't be an object and a map at the same time @@ -1333,16 +1327,17 @@ final case class EndpointGen(config: Config) { ) }, ), + config.fieldNamesNormalization, ), ) case JsonSchema.Object(_, _, _) => - Some(Code.Field(name, Code.TypeRef(name.capitalize))) + Some(Code.Field(name, Code.TypeRef(name.capitalize), config.fieldNamesNormalization)) case JsonSchema.Enum(_) => - Some(Code.Field(name, Code.TypeRef(name.capitalize))) + Some(Code.Field(name, Code.TypeRef(name.capitalize), config.fieldNamesNormalization)) case JsonSchema.Null => - Some(Code.Field(name, Code.ScalaType.Unit)) + Some(Code.Field(name, Code.ScalaType.Unit, config.fieldNamesNormalization)) case JsonSchema.AnyJson => - Some(Code.Field(name, Code.ScalaType.JsonAST)) + Some(Code.Field(name, Code.ScalaType.JsonAST, config.fieldNamesNormalization)) } } 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 63e708fa82..1777c4b44c 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 @@ -3,10 +3,10 @@ package zio.http.gen.scala import scala.meta.Term import scala.meta.prettyprinters.XtensionSyntax +import zio.http.gen.openapi +import zio.http.gen.openapi.Config.NormalizeFields import zio.http.{Method, Status} -import com.sun.tools.javac.code.TypeMetadata.Annotations - sealed trait Code extends Product with Serializable object Code { @@ -134,7 +134,7 @@ object Code { abstractMembers: List[Field] = Nil, ) extends ScalaType - final case class Annotation(value: String) + final case class Annotation(value: String, imports: List[Code.Import]) extends Code sealed abstract case class Field private (name: String, fieldType: ScalaType, annotations: List[Annotation]) extends Code { @@ -145,18 +145,61 @@ object Code { object Field { - def apply(name: String): Field = apply(name, ScalaType.Inferred) - def apply(name: String, fieldType: ScalaType): Field = { - val validScalaTermName = Term.Name(name).syntax - new Field(validScalaTermName, fieldType, Nil) {} - } - def apply(name: String, fieldType: ScalaType, annotation: Annotation): Field = { - val validScalaTermName = Term.Name(name).syntax - new Field(validScalaTermName, fieldType, List(annotation)) {} + def apply(name: String): Field = + apply(name, ScalaType.Inferred) + + def apply(name: String, conf: NormalizeFields): Field = + apply(name, ScalaType.Inferred, conf) + + def apply(name: String, fieldType: ScalaType): Field = + apply(name, fieldType, openapi.Config.default.fieldNamesNormalization) + + def apply(name: String, fieldType: ScalaType, conf: NormalizeFields): Field = + apply(name, fieldType, Nil, conf) + + def apply(name: String, fieldType: ScalaType, annotation: Annotation, conf: NormalizeFields): Field = + apply(name, fieldType, List(annotation), conf) + + def apply(name: String, fieldType: ScalaType, annotations: List[Annotation], conf: NormalizeFields): Field = { + + def mkValidScalaTermName(term: String) = Term.Name(term).syntax + + val (validScalaTermName, originalFieldNameAnnotation) = conf.manualOverrides + .get(name) + .orElse(if (conf.enableAutomatic) normalize(name) else None) + .fold(mkValidScalaTermName(name) -> Option.empty[Annotation]) { maybeValidScala => + val valid = mkValidScalaTermName(maybeValidScala) + // if modified name is an invalid scala term, + // then no reason to use backticks wrapped non-original name. + // In this case we return the original name, + // after possibly wrapping with backticks. + if (valid != maybeValidScala) mkValidScalaTermName(name) -> Option.empty[Annotation] + else { + val annotationString = "@fieldName(\"" + name + "\")" + val annotationImport = List(Import("zio.schema.annotation.fieldName")) + maybeValidScala -> Some(Annotation(annotationString, annotationImport)) + } + } + + val allAnnotations = originalFieldNameAnnotation.fold(annotations)(annotations.::) + new Field(validScalaTermName, fieldType, allAnnotations.sortBy(_.value)) {} } - def apply(name: String, fieldType: ScalaType, annotations: List[Annotation]): Field = { - val validScalaTermName = Term.Name(name).syntax - new Field(validScalaTermName, fieldType, annotations) {} + + private val regex = "(?<=[a-z0-9])(?=[A-Z0-9])|(?<=[A-Z0-9])(?=[A-Z0-9][a-z0-9])|[^a-zA-Z0-9]+" + + def normalize(name: String): Option[String] = { + + name + .split(regex) + .toList match { + case Nil => None + case head :: tail => + val normalized = (head.toLowerCase :: tail.map(_.capitalize)).mkString + // no need to normalize if the name is already normalized + // returning None here will signal there's no need for annotation. + if (normalized == name) None + else Some(normalized) + } } } 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 37b5566b17..1f4830e0db 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 @@ -47,11 +47,24 @@ object CodeGen { val (objImports, objContent) = objects.map(render(basePackage)).unzip val (ccImports, ccContent) = caseClasses.map(render(basePackage)).unzip val (enumImports, enumContent) = enums.map(render(basePackage)).unzip - val allImports = (imports ++ objImports.flatten ++ ccImports.flatten ++ enumImports.flatten).distinct - val content = + + val allImports = (imports ++ objImports.flatten ++ ccImports.flatten ++ enumImports.flatten).distinct + val renderedSortedImports = { + val javaImports = List.newBuilder[String] + val scalaImports = List.newBuilder[String] + val otherImports = List.newBuilder[String] + allImports.foreach { imprt => + val rendered = render(basePackage)(imprt)._2 + if (rendered.startsWith("import java.")) javaImports += rendered + else if (rendered.startsWith("import scala.")) scalaImports += rendered + else otherImports += rendered + } + otherImports.result().sorted ::: javaImports.result().sorted ::: scalaImports.result().sorted + } + val content = s"package $basePackage${if (path.exists(_.nonEmpty)) path.mkString(if (basePackage.isEmpty) "" else ".", ".", "") - else ""}\n\n" + - s"${allImports.map(render(basePackage)(_)._2).mkString("\n")}\n\n" + + else ""}" + + renderedSortedImports.mkString("\n\n", "\n", "\n\n") + objContent.mkString("\n") + ccContent.mkString("\n") + enumContent.mkString("\n") @@ -168,7 +181,7 @@ object CodeGen { traitBodyBuilder ++= ": " traitBodyBuilder ++= tpe - imports ::: importsAcc + annotations.foldRight(imports ::: importsAcc)(_.imports ::: _).distinct } } val body = @@ -210,10 +223,12 @@ object CodeGen { } case Code.Field(name, fieldType, annotations) => - val (imports, tpe) = render(basePackage)(fieldType) - val annotationsStr = annotations.map(_.value).mkString("\n") - val content = if (tpe.isEmpty) s"val $name" else s"val $name: $tpe" - imports -> (annotationsStr + content) + val (imports, tpe) = render(basePackage)(fieldType) + val (annotationValues, annotationImports) = annotations.unzip(ann => ann.value -> ann.imports) + val allImports = annotationImports.foldRight(imports)(_ ::: _).distinct + val content = if (tpe.isEmpty) s"val $name" else s"val $name: $tpe" + val multipleAnnotationsAboveContent = if (annotationValues.size > 1) "\n" + content else content + allImports -> annotationValues.mkString("", "\n", multipleAnnotationsAboveContent) case Code.Primitive.ScalaBoolean => Nil -> "Boolean" case Code.Primitive.ScalaByte => Nil -> "Byte" diff --git a/zio-http-gen/src/test/resources/ComponentAnimal.scala b/zio-http-gen/src/test/resources/ComponentAnimal.scala index 4ebaa49bd6..2e18aaca57 100644 --- a/zio-http-gen/src/test/resources/ComponentAnimal.scala +++ b/zio-http-gen/src/test/resources/ComponentAnimal.scala @@ -2,6 +2,8 @@ package test.component import zio.schema._ import zio.schema.annotation._ +import zio.schema.annotation.validate +import zio.schema.validation.Validation @noDiscriminator sealed trait Animal @@ -9,17 +11,17 @@ object Animal { implicit val codec: Schema[Animal] = DeriveSchema.gen[Animal] case class Alligator( - @zio.schema.annotation.validate[Int](zio.schema.validation.Validation.greaterThan(-1)) age: Int, - @zio.schema.annotation.validate[Float](zio.schema.validation.Validation.greaterThan(-1.0)) weight: Float, - @zio.schema.annotation.validate[Int](zio.schema.validation.Validation.greaterThan(-1)) num_teeth: Int, + @validate[Int](Validation.greaterThan(-1)) age: Int, + @validate[Float](Validation.greaterThan(-1.0)) weight: Float, + @validate[Int](Validation.greaterThan(-1)) num_teeth: Int, ) extends Animal object Alligator { implicit val codec: Schema[Alligator] = DeriveSchema.gen[Alligator] } case class Zebra( - @zio.schema.annotation.validate[Int](zio.schema.validation.Validation.greaterThan(-1)) age: Int, - @zio.schema.annotation.validate[Float](zio.schema.validation.Validation.greaterThan(-1.0)) weight: Float, - @zio.schema.annotation.validate[Int](zio.schema.validation.Validation.greaterThan(-1)) num_stripes: Int, + @validate[Int](Validation.greaterThan(-1)) age: Int, + @validate[Float](Validation.greaterThan(-1.0)) weight: Float, + @validate[Int](Validation.greaterThan(-1)) 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 index d2f5f241bd..af2e9ca283 100644 --- a/zio-http-gen/src/test/resources/ComponentAnimalWithAbstractMembers.scala +++ b/zio-http-gen/src/test/resources/ComponentAnimalWithAbstractMembers.scala @@ -2,6 +2,8 @@ package test.component import zio.schema._ import zio.schema.annotation._ +import zio.schema.annotation.validate +import zio.schema.validation.Validation @noDiscriminator sealed trait Animal { @@ -12,17 +14,17 @@ object Animal { implicit val codec: Schema[Animal] = DeriveSchema.gen[Animal] case class Alligator( - @zio.schema.annotation.validate[Int](zio.schema.validation.Validation.greaterThan(-1)) age: Int, - @zio.schema.annotation.validate[Float](zio.schema.validation.Validation.greaterThan(-1.0)) weight: Float, - @zio.schema.annotation.validate[Int](zio.schema.validation.Validation.greaterThan(-1)) num_teeth: Int, + @validate[Int](Validation.greaterThan(-1)) age: Int, + @validate[Float](Validation.greaterThan(-1.0)) weight: Float, + @validate[Int](Validation.greaterThan(-1)) num_teeth: Int, ) extends Animal object Alligator { implicit val codec: Schema[Alligator] = DeriveSchema.gen[Alligator] } case class Zebra( - @zio.schema.annotation.validate[Int](zio.schema.validation.Validation.greaterThan(-1)) age: Int, - @zio.schema.annotation.validate[Float](zio.schema.validation.Validation.greaterThan(-1.0)) weight: Float, - @zio.schema.annotation.validate[Int](zio.schema.validation.Validation.greaterThan(-1)) num_stripes: Int, + @validate[Int](Validation.greaterThan(-1)) age: Int, + @validate[Float](Validation.greaterThan(-1.0)) weight: Float, + @validate[Int](Validation.greaterThan(-1)) num_stripes: Int, ) extends Animal object Zebra { implicit val codec: Schema[Zebra] = DeriveSchema.gen[Zebra] diff --git a/zio-http-gen/src/test/resources/ComponentAnimalWithAliases.scala b/zio-http-gen/src/test/resources/ComponentAnimalWithAliases.scala index 883c911736..430ffe79d5 100644 --- a/zio-http-gen/src/test/resources/ComponentAnimalWithAliases.scala +++ b/zio-http-gen/src/test/resources/ComponentAnimalWithAliases.scala @@ -1,7 +1,7 @@ package test.component -import zio.schema._ import zio.Chunk +import zio.schema._ case class Animal( species: Species.Type, diff --git a/zio-http-gen/src/test/resources/ComponentAnimalWithFieldsReferencingSubs.scala b/zio-http-gen/src/test/resources/ComponentAnimalWithFieldsReferencingSubs.scala index 5f6d6649f1..62f2402d0b 100644 --- a/zio-http-gen/src/test/resources/ComponentAnimalWithFieldsReferencingSubs.scala +++ b/zio-http-gen/src/test/resources/ComponentAnimalWithFieldsReferencingSubs.scala @@ -1,8 +1,10 @@ package test.component +import zio.Chunk import zio.schema._ import zio.schema.annotation._ -import zio.Chunk +import zio.schema.annotation.validate +import zio.schema.validation.Validation @noDiscriminator sealed trait Animal { @@ -13,17 +15,17 @@ object Animal { implicit val codec: Schema[Animal] = DeriveSchema.gen[Animal] case class Alligator( - @zio.schema.annotation.validate[Int](zio.schema.validation.Validation.greaterThan(-1)) age: Int, - @zio.schema.annotation.validate[Float](zio.schema.validation.Validation.greaterThan(-1.0)) weight: Float, - @zio.schema.annotation.validate[Int](zio.schema.validation.Validation.greaterThan(-1)) num_teeth: Int, + @validate[Int](Validation.greaterThan(-1)) age: Int, + @validate[Float](Validation.greaterThan(-1.0)) weight: Float, + @validate[Int](Validation.greaterThan(-1)) num_teeth: Int, ) extends Animal object Alligator { implicit val codec: Schema[Alligator] = DeriveSchema.gen[Alligator] } case class Zebra( - @zio.schema.annotation.validate[Int](zio.schema.validation.Validation.greaterThan(-1)) age: Int, - @zio.schema.annotation.validate[Float](zio.schema.validation.Validation.greaterThan(-1.0)) weight: Float, - @zio.schema.annotation.validate[Int](zio.schema.validation.Validation.greaterThan(-1)) num_stripes: Int, + @validate[Int](Validation.greaterThan(-1)) age: Int, + @validate[Float](Validation.greaterThan(-1.0)) weight: Float, + @validate[Int](Validation.greaterThan(-1)) num_stripes: Int, dazzle: Chunk[Zebra], ) extends Animal object Zebra { diff --git a/zio-http-gen/src/test/resources/ComponentAnimalWithoutAliases.scala b/zio-http-gen/src/test/resources/ComponentAnimalWithoutAliases.scala index 99a16983cf..c84d6f12e4 100644 --- a/zio-http-gen/src/test/resources/ComponentAnimalWithoutAliases.scala +++ b/zio-http-gen/src/test/resources/ComponentAnimalWithoutAliases.scala @@ -1,7 +1,7 @@ package test.component -import zio.schema._ import zio.Chunk +import zio.schema._ case class Animal( species: String, diff --git a/zio-http-gen/src/test/resources/ComponentOrder.scala b/zio-http-gen/src/test/resources/ComponentOrder.scala index 5c9a99f422..58d5fb5bb7 100644 --- a/zio-http-gen/src/test/resources/ComponentOrder.scala +++ b/zio-http-gen/src/test/resources/ComponentOrder.scala @@ -1,13 +1,15 @@ package test.component import zio.schema._ +import zio.schema.annotation.validate +import zio.schema.validation.Validation import java.util.UUID case class Order( id: UUID, product: String, - @zio.schema.annotation.validate[Int](zio.schema.validation.Validation.greaterThan(0)) quantity: Int, - @zio.schema.annotation.validate[Double](zio.schema.validation.Validation.greaterThan(-1.0)) price: Double, + @validate[Int](Validation.greaterThan(0)) quantity: Int, + @validate[Double](Validation.greaterThan(-1.0)) price: Double, ) object Order { implicit val codec: Schema[Order] = DeriveSchema.gen[Order] diff --git a/zio-http-gen/src/test/resources/ComponentOrderWithAliases.scala b/zio-http-gen/src/test/resources/ComponentOrderWithAliases.scala index 7a601f7605..0326c11431 100644 --- a/zio-http-gen/src/test/resources/ComponentOrderWithAliases.scala +++ b/zio-http-gen/src/test/resources/ComponentOrderWithAliases.scala @@ -1,12 +1,14 @@ package test.component import zio.schema._ +import zio.schema.annotation.validate +import zio.schema.validation.Validation case class Order( id: OrderId.Type, product: String, - @zio.schema.annotation.validate[Int](zio.schema.validation.Validation.greaterThan(0)) quantity: Int, - @zio.schema.annotation.validate[Double](zio.schema.validation.Validation.greaterThan(-1.0)) price: Double, + @validate[Int](Validation.greaterThan(0)) quantity: Int, + @validate[Double](Validation.greaterThan(-1.0)) price: Double, ) object Order { implicit val codec: Schema[Order] = DeriveSchema.gen[Order] diff --git a/zio-http-gen/src/test/resources/ComponentOrderWithNormalizedFieldNames.scala b/zio-http-gen/src/test/resources/ComponentOrderWithNormalizedFieldNames.scala new file mode 100644 index 0000000000..425c97ff2c --- /dev/null +++ b/zio-http-gen/src/test/resources/ComponentOrderWithNormalizedFieldNames.scala @@ -0,0 +1,19 @@ +package test.component + +import zio.schema._ +import zio.schema.annotation.fieldName +import zio.schema.annotation.validate +import zio.schema.validation.Validation +import java.util.UUID + +case class Order( + @fieldName("ORDER-ID") orderID: UUID, + @fieldName("price_in dollars") + @validate[Double](Validation.greaterThan(-1.0)) + priceInDollars: Double, + @fieldName("1st item") firstItem: String, + @fieldName("2nd item") secondItem: Option[String], +) +object Order { + implicit val codec: Schema[Order] = DeriveSchema.gen[Order] +} \ No newline at end of file diff --git a/zio-http-gen/src/test/resources/GeneratedUserNameArray.scala b/zio-http-gen/src/test/resources/GeneratedUserNameArray.scala index 7f79054c8f..78e8811859 100644 --- a/zio-http-gen/src/test/resources/GeneratedUserNameArray.scala +++ b/zio-http-gen/src/test/resources/GeneratedUserNameArray.scala @@ -1,7 +1,7 @@ package test.component -import zio.schema._ import zio.Chunk +import zio.schema._ case class UserNameArray( id: Int, diff --git a/zio-http-gen/src/test/resources/ValidatedData.scala b/zio-http-gen/src/test/resources/ValidatedData.scala index 8f73248c9d..99a34b2588 100644 --- a/zio-http-gen/src/test/resources/ValidatedData.scala +++ b/zio-http-gen/src/test/resources/ValidatedData.scala @@ -1,12 +1,12 @@ package test.component import zio.schema._ +import zio.schema.annotation.validate +import zio.schema.validation.Validation case class ValidatedData( - @zio.schema.annotation.validate[String](zio.schema.validation.Validation.minLength(10)) name: String, - @zio.schema.annotation.validate[Int]( - zio.schema.validation.Validation.greaterThan(0) && zio.schema.validation.Validation.lessThan(100), - ) age: Int, + @validate[String](Validation.minLength(10)) name: String, + @validate[Int](Validation.greaterThan(0) && Validation.lessThan(100)) age: Int, ) object ValidatedData { implicit val codec: Schema[ValidatedData] = DeriveSchema.gen[ValidatedData] diff --git a/zio-http-gen/src/test/resources/inline_schema_weird_field_names.yaml b/zio-http-gen/src/test/resources/inline_schema_weird_field_names.yaml new file mode 100644 index 0000000000..cbceaffe45 --- /dev/null +++ b/zio-http-gen/src/test/resources/inline_schema_weird_field_names.yaml @@ -0,0 +1,49 @@ +info: + title: Shop Service + version: 0.0.1 +servers: + - url: http://127.0.0.1:5000/ +tags: + - name: Order_API +paths: + /api/v1/shop/order/{id}: + get: + operationId: get_order_by_id + parameters: + - in: path + name: id + schema: + $ref: '#/components/schemas/OrderId' + required: true + tags: + - Order_API + description: Get order by id + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/Order' + description: OK +openapi: 3.0.3 +components: + schemas: + Order: + type: object + required: + - ORDER-ID + - price_in dollars + - 1st item + properties: + ORDER-ID: + $ref: '#/components/schemas/OrderId' + price_in dollars: + type: number + minimum: 0 + 1st item: + type: string + 2nd item: + type: string + OrderId: + type: string + format: uuid 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 326a936a1a..300cd287f8 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 @@ -9,8 +9,8 @@ import scala.meta.parsers._ import scala.util.{Failure, Success, Try} import zio.Scope -import zio.json.{JsonDecoder, JsonEncoder} -import zio.test.Assertion.{equalTo, hasSameElements, isFailure, isSuccess, succeeds} +import zio.json.JsonDecoder +import zio.test.Assertion.{hasSameElements, isFailure, isSuccess} import zio.test.TestAspect.{blocking, flaky} import zio.test._ @@ -24,6 +24,7 @@ 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.Config.NormalizeFields import zio.http.gen.openapi.{Config, EndpointGen} @nowarn("msg=missing interpolator") @@ -306,7 +307,7 @@ object CodeGenSpec extends ZIOSpecDefault { val openAPIString = stringFromResource("/inline_schema_sumtype_with_reusable_fields.yaml") openApiFromYamlString(openAPIString) { oapi => - codeGenFromOpenAPI(oapi, Config(commonFieldsOnSuperType = true, generateSafeTypeAliases = false)) { testDir => + codeGenFromOpenAPI(oapi, Config.default.copy(commonFieldsOnSuperType = true)) { testDir => allFilesShouldBe( testDir.toFile, List( @@ -335,7 +336,7 @@ object CodeGenSpec extends ZIOSpecDefault { val openAPIString = stringFromResource("/inline_schema_sumtype_with_multiple_reusable_fields.yaml") openApiFromYamlString(openAPIString) { oapi => - codeGenFromOpenAPI(oapi, Config(commonFieldsOnSuperType = true, generateSafeTypeAliases = false)) { testDir => + codeGenFromOpenAPI(oapi, Config.default.copy(commonFieldsOnSuperType = true)) { testDir => allFilesShouldBe( testDir.toFile, List( @@ -374,7 +375,7 @@ object CodeGenSpec extends ZIOSpecDefault { openApiFromYamlString(openAPIString) { oapi => assert { - Try(EndpointGen.fromOpenAPI(oapi, Config(commonFieldsOnSuperType = true, generateSafeTypeAliases = false))) + Try(EndpointGen.fromOpenAPI(oapi, Config.default.copy(commonFieldsOnSuperType = true))) }(isFailure) } } @@ TestAspect.exceptScala3, // for some reason, the temp dir is empty in Scala 3 @@ -385,7 +386,7 @@ object CodeGenSpec extends ZIOSpecDefault { stringFromResource("/inline_schema_sumtype_with_multiple_non_contradicting_reusable_fields.yaml") openApiFromYamlString(openAPIString) { oapi => - codeGenFromOpenAPI(oapi, Config(commonFieldsOnSuperType = true, generateSafeTypeAliases = false)) { testDir => + codeGenFromOpenAPI(oapi, Config.default.copy(commonFieldsOnSuperType = true)) { testDir => allFilesShouldBe( testDir.toFile, List( @@ -410,7 +411,7 @@ object CodeGenSpec extends ZIOSpecDefault { val openAPIString = stringFromResource("/inline_schema_sumtype_with_subtype_referenced_directly.yaml") openApiFromYamlString(openAPIString) { oapi => - codeGenFromOpenAPI(oapi, Config(commonFieldsOnSuperType = true, generateSafeTypeAliases = false)) { testDir => + codeGenFromOpenAPI(oapi, Config.default.copy(commonFieldsOnSuperType = true)) { testDir => allFilesShouldBe( testDir.toFile, List( @@ -439,7 +440,10 @@ object CodeGenSpec extends ZIOSpecDefault { val openAPIString = stringFromResource("/inline_schema_alias_primitives.yaml") openApiFromYamlString(openAPIString) { oapi => - codeGenFromOpenAPI(oapi, Config(commonFieldsOnSuperType = true, generateSafeTypeAliases = true)) { testDir => + codeGenFromOpenAPI( + oapi, + Config.default.copy(commonFieldsOnSuperType = true, generateSafeTypeAliases = true), + ) { testDir => allFilesShouldBe( testDir.toFile, List( @@ -487,7 +491,7 @@ object CodeGenSpec extends ZIOSpecDefault { val openAPIString = stringFromResource("/inline_schema_alias_primitives.yaml") openApiFromYamlString(openAPIString) { oapi => - codeGenFromOpenAPI(oapi, Config(commonFieldsOnSuperType = true, generateSafeTypeAliases = false)) { testDir => + codeGenFromOpenAPI(oapi, Config.default.copy(commonFieldsOnSuperType = true)) { testDir => allFilesShouldBe( testDir.toFile, List( @@ -810,7 +814,7 @@ object CodeGenSpec extends ZIOSpecDefault { val openAPIString = stringFromResource("/inline_schema_constrained_keys_map.yaml") openApiFromYamlString(openAPIString) { oapi => - codeGenFromOpenAPI(oapi, Config(commonFieldsOnSuperType = false, generateSafeTypeAliases = true)) { testDir => + codeGenFromOpenAPI(oapi, Config.default.copy(generateSafeTypeAliases = true)) { testDir => allFilesShouldBe( testDir.toFile, List( @@ -864,5 +868,35 @@ object CodeGenSpec extends ZIOSpecDefault { } } }, + test("Endpoint with normalized field names") { + val openAPIString = stringFromResource("/inline_schema_weird_field_names.yaml") + + openApiFromYamlString(openAPIString) { oapi => + codeGenFromOpenAPI( + oapi, + Config.default.copy( + fieldNamesNormalization = NormalizeFields( + enableAutomatic = true, + manualOverrides = Map( + "1st item" -> "firstItem", + "2nd item" -> "secondItem", + ), + ), + ), + ) { testDir => + allFilesShouldBe( + testDir.toFile, + List( + "api/v1/shop/order/Id.scala", + "component/Order.scala", + ), + ) && fileShouldBe( + testDir, + "component/Order.scala", + "/ComponentOrderWithNormalizedFieldNames.scala", + ) + } + } + } @@ TestAspect.exceptScala3, ) @@ java11OrNewer @@ flaky @@ blocking // Downloading scalafmt on CI is flaky } diff --git a/zio-http-gen/src/test/scala/zio/http/gen/scala/FieldNormalizationSpec.scala b/zio-http-gen/src/test/scala/zio/http/gen/scala/FieldNormalizationSpec.scala new file mode 100644 index 0000000000..b4022ceba5 --- /dev/null +++ b/zio-http-gen/src/test/scala/zio/http/gen/scala/FieldNormalizationSpec.scala @@ -0,0 +1,45 @@ +package zio.http.gen.scala + +import zio.Scope +import zio.test.Assertion.{equalTo, isNone, isSome} +import zio.test._ + +object FieldNormalizationSpec extends ZIOSpecDefault { + + override def spec: Spec[TestEnvironment with Scope, Any] = + suite("FieldNormalizationSpec")( + test("Simple lowercase (None signals no change)") { + assert(Code.Field.normalize("foo"))(isNone) + }, + test("Simple UPPERCASE") { + assert(Code.Field.normalize("FOO"))(isSome(equalTo("foo"))) + }, + test("preserve camelCase (None signals no change)") { + assert(Code.Field.normalize("fooBar"))(isNone) + }, + test("preserve camelCase with digits (None signals no change)") { + assert(Code.Field.normalize("fooBar42"))(isNone) + }, + test("preserve camelCase with digits #2 (None signals no change)") { + assert(Code.Field.normalize("foo42Bar"))(isNone) + }, + test("lowercase capitalized camelCase") { + assert(Code.Field.normalize("FooBar"))(isSome(equalTo("fooBar"))) + }, + test("preserve non-leading UPPERCASE (None signals no change)") { + assert(Code.Field.normalize("fooBAR"))(isNone) + }, + test("mixed camelSnake_case") { + assert(Code.Field.normalize("camelSnake_case"))(isSome(equalTo("camelSnakeCase"))) + }, + test("mixed snake_caseUPPERLower") { + assert(Code.Field.normalize("ARN_APIGateway"))(isSome(equalTo("arnAPIGateway"))) + }, + test("challenge with complex CamelCase") { + assert(Code.Field.normalize("UseWD40ToLossenBut3MToFasten"))(isSome(equalTo("useWD40ToLossenBut3MToFasten"))) + }, + test("with whitespaces") { + assert(Code.Field.normalize("white\tspace - as\nsep"))(isSome(equalTo("whiteSpaceAsSep"))) + }, + ) +}