From c871b966d4a92e884ec50de44bbaf27e31758aae Mon Sep 17 00:00:00 2001 From: hochgi Date: Tue, 12 Nov 2024 18:36:49 +0200 Subject: [PATCH 1/3] partial support for Json.Obj started --- .../zio/http/gen/openapi/EndpointGen.scala | 8 +- .../main/scala/zio/http/gen/scala/Code.scala | 1 + .../scala/zio/http/gen/scala/CodeGen.scala | 6 + .../src/test/resources/AnimalWithAny.scala | 14 ++ .../inline_schema_any_and_any_object.yaml | 49 ++++ .../zio/http/gen/scala/CodeGenSpec.scala | 32 ++- .../zio/http/endpoint/http/HttpFile.scala | 1 + .../zio/http/endpoint/http/HttpGen.scala | 1 + .../http/endpoint/openapi/JsonSchema.scala | 211 ++++++++++-------- 9 files changed, 222 insertions(+), 101 deletions(-) create mode 100644 zio-http-gen/src/test/resources/AnimalWithAny.scala create mode 100644 zio-http-gen/src/test/resources/inline_schema_any_and_any_object.yaml diff --git a/zio-http-gen/src/main/scala/zio/http/gen/openapi/EndpointGen.scala b/zio-http-gen/src/main/scala/zio/http/gen/openapi/EndpointGen.scala index f92bdb144d..185e7980f7 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 @@ -1026,6 +1026,7 @@ final case class EndpointGen(config: Config) { properties.map { case (name, schema) => name -> schema.withoutAnnotations }.collect { case (name, schema) if !schema.isInstanceOf[JsonSchema.RefSchema] + && !(schema == JsonSchema.AnyJson) && !schema.isPrimitive && !schema.isCollection => schemaToCode(schema, openAPI, name.capitalize, Chunk.empty) @@ -1077,7 +1078,10 @@ final case class EndpointGen(config: Config) { ), ) case JsonSchema.Null => throw new Exception("Null query parameters are not supported") - case JsonSchema.AnyJson => throw new Exception("AnyJson query parameters are not supported") + case JsonSchema.AnyJson => { + // throw new Exception("AnyJson query parameters are not supported") + None + } } } @@ -1349,6 +1353,8 @@ final case class EndpointGen(config: Config) { Some(Code.Field(name, Code.ScalaType.Unit, config.fieldNamesNormalization)) case JsonSchema.AnyJson => Some(Code.Field(name, Code.ScalaType.JsonAST, config.fieldNamesNormalization)) + case JsonSchema.AnyJsonObj => + Some(Code.Field(name, Code.ScalaType.JsonObj, 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 1777c4b44c..35eeefcc37 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 @@ -21,6 +21,7 @@ object Code { case object Inferred extends ScalaType case object Unit extends ScalaType case object JsonAST extends ScalaType + case object JsonObj extends ScalaType final case class Or(left: ScalaType, right: ScalaType) extends ScalaType } 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 4a797b83f7..930f598dd9 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 @@ -260,6 +260,12 @@ object CodeGen { case Code.TypeRef(name) => Nil -> name + case Code.ScalaType.JsonAST => + List(Code.Import("zio.json.ast.Json")) -> "Json" + + case Code.ScalaType.JsonObj => + List(Code.Import("zio.json.ast.Json")) -> "Json.Obj" + case scalaType => throw new Exception(s"Unknown ScalaType: $scalaType") } diff --git a/zio-http-gen/src/test/resources/AnimalWithAny.scala b/zio-http-gen/src/test/resources/AnimalWithAny.scala new file mode 100644 index 0000000000..88a14e1468 --- /dev/null +++ b/zio-http-gen/src/test/resources/AnimalWithAny.scala @@ -0,0 +1,14 @@ +package test.component + +import zio.json.ast.Json +import zio.schema._ +import zio.schema.annotation.fieldName + +case class Animal( + name: String, + eats: Json, + @fieldName("extra_attributes") extraAttributes: Json.Obj, +) +object Animal { + implicit val codec: Schema[Animal] = DeriveSchema.gen[Animal] +} diff --git a/zio-http-gen/src/test/resources/inline_schema_any_and_any_object.yaml b/zio-http-gen/src/test/resources/inline_schema_any_and_any_object.yaml new file mode 100644 index 0000000000..fbe3b9c2fb --- /dev/null +++ b/zio-http-gen/src/test/resources/inline_schema_any_and_any_object.yaml @@ -0,0 +1,49 @@ +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: + type: object + required: + - name + - eats + - extra_attributes + properties: + name: + type: string + eats: {} + extra_attributes: + type: object + additionalProperties: true \ No newline at end of file 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 16ba55d5d4..9fbf44be35 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 @@ -123,7 +123,7 @@ object CodeGenSpec extends ZIOSpecDefault { private val scalaFmtPath = java.nio.file.Paths.get(getClass.getResource("/scalafmt.conf").toURI) override def spec: Spec[TestEnvironment with Scope, Any] = - suite("CodeGenSpec")( + suite("CodeGenSpec")( /* test("Simple endpoint without data structures") { val endpoint = Endpoint(Method.GET / "api" / "v1" / "users") val openAPI = OpenAPIGen.fromEndpoints(endpoint) @@ -1001,7 +1001,31 @@ object CodeGenSpec extends ZIOSpecDefault { ) } } - } @@ TestAspect.exceptScala3, + } @@ TestAspect.exceptScala3, */ + test("Schema with any and any object") { + val openAPIString = stringFromResource("/inline_schema_any_and_any_object.yaml") + + openApiFromYamlString(openAPIString) { oapi => + codeGenFromOpenAPI( + oapi, + Config.default.copy( + fieldNamesNormalization = Config.default.fieldNamesNormalization.copy(enableAutomatic = true) + ), + ) { testDir => + allFilesShouldBe( + testDir.toFile, + List( + "api/Get_Animal.scala", + "component/Animal.scala", + ), + ) && fileShouldBe( + testDir, + "component/Animal.scala", + "/AnimalWithAny.scala", + ) + } + } + } @@ TestAspect.exceptScala3, /* test("Generate all responses") { val oapi = OpenAPI( @@ -1181,6 +1205,6 @@ object CodeGenSpec extends ZIOSpecDefault { assert(EndpointGen.fromOpenAPI(oapi, Config.default).files) { Assertion.forall(importsZioSchema || fileContainsNoSchema) } - }, - ) @@ java11OrNewer @@ flaky @@ blocking // Downloading scalafmt on CI is flaky + }, */ + ) @@ java11OrNewer /*@@ flaky*/ @@ blocking // Downloading scalafmt on CI is flaky } diff --git a/zio-http/shared/src/main/scala/zio/http/endpoint/http/HttpFile.scala b/zio-http/shared/src/main/scala/zio/http/endpoint/http/HttpFile.scala index 9537e71e60..2ec49a4794 100644 --- a/zio-http/shared/src/main/scala/zio/http/endpoint/http/HttpFile.scala +++ b/zio-http/shared/src/main/scala/zio/http/endpoint/http/HttpFile.scala @@ -51,6 +51,7 @@ final case class HttpEndpoint( case JsonSchema.Enum(_) => s""""${getName(name)}": {{${getName(name)}}}""" case JsonSchema.Null => "" case JsonSchema.AnyJson => "" + case JsonSchema.AnyJsonObj => "" } private def getName(name: Option[String]) = { name.getOrElse(throw new IllegalArgumentException("name is required")) } diff --git a/zio-http/shared/src/main/scala/zio/http/endpoint/http/HttpGen.scala b/zio-http/shared/src/main/scala/zio/http/endpoint/http/HttpGen.scala index eae8f5aba1..19de08beb9 100644 --- a/zio-http/shared/src/main/scala/zio/http/endpoint/http/HttpGen.scala +++ b/zio-http/shared/src/main/scala/zio/http/endpoint/http/HttpGen.scala @@ -114,6 +114,7 @@ object HttpGen { case JsonSchema.Enum(values) => Seq(HttpVariable(getName(name), None, Some(s"enum: ${values.mkString(",")}"))) case JsonSchema.Null => Seq.empty case JsonSchema.AnyJson => Seq.empty + case JsonSchema.AnyJsonObj => Seq.empty } bodySchema0 match { diff --git a/zio-http/shared/src/main/scala/zio/http/endpoint/openapi/JsonSchema.scala b/zio-http/shared/src/main/scala/zio/http/endpoint/openapi/JsonSchema.scala index d2063c2bc8..2794b38f56 100644 --- a/zio-http/shared/src/main/scala/zio/http/endpoint/openapi/JsonSchema.scala +++ b/zio-http/shared/src/main/scala/zio/http/endpoint/openapi/JsonSchema.scala @@ -1,18 +1,16 @@ package zio.http.endpoint.openapi import scala.annotation.{nowarn, tailrec} - import zio._ import zio.json.ast.Json - import zio.schema.Schema.CaseClass0 import zio.schema._ import zio.schema.annotation._ import zio.schema.codec._ import zio.schema.codec.json._ import zio.schema.validation._ - import zio.http.codec._ +import zio.http.endpoint.openapi.BoolOrSchema.BooleanWrapper import zio.http.endpoint.openapi.JsonSchema.MetaData @nowarn("msg=possible missing interpolator") @@ -254,111 +252,124 @@ object JsonSchema { .get private[openapi] def fromSerializableSchema(schema: SerializableJsonSchema): JsonSchema = { - val additionalProperties = schema.additionalProperties match { - case Some(BoolOrSchema.BooleanWrapper(bool)) => Left(bool) - case Some(BoolOrSchema.SchemaWrapper(schema)) => - val valuesSchema = fromSerializableSchema(schema) - Right( - schema.optionalKeySchema.fold(valuesSchema)(keySchema => - valuesSchema.annotate( - MetaData.KeySchema( - fromSerializableSchema(keySchema), + + // if type: object with additionalProperties defined, + // but nothing else, we should assume a free form object + def anyObject: Boolean = schema.additionalProperties.collect { + case BoolOrSchema.BooleanWrapper(true) => + schema.schemaType.contains(TypeOrTypes.Type("object")) && + schema.productIterator.count(_.asInstanceOf[Option[_]].isDefined) == 2 + }.exists(identity) + + if (schema.productIterator.forall(_.asInstanceOf[Option[_]].isEmpty)) JsonSchema.AnyJson + else if (anyObject) JsonSchema.AnyJsonObj + else { + val additionalProperties = schema.additionalProperties match { + case Some(BoolOrSchema.BooleanWrapper(bool)) => Left(bool) + case Some(BoolOrSchema.SchemaWrapper(schema)) => + val valuesSchema = fromSerializableSchema(schema) + Right( + schema.optionalKeySchema.fold(valuesSchema)(keySchema => + valuesSchema.annotate( + MetaData.KeySchema( + fromSerializableSchema(keySchema), + ), ), ), - ), - ) - case None => Left(true) - } + ) + case None => Left(true) + } - var jsonSchema: JsonSchema = schema match { - case schema if schema.ref.isDefined => - RefSchema(schema.ref.get) - case schema if schema.schemaType.contains(TypeOrTypes.Type("number")) => - JsonSchema.Number( - NumberFormat.fromString(schema.format.getOrElse("double")), - schema.minimum.map(_.fold(identity, _.toDouble)), - schema.exclusiveMinimum.map(_.map(_.fold(identity, _.toDouble))), - schema.maximum.map(_.fold(identity, _.toDouble)), - schema.exclusiveMaximum.map(_.map(_.fold(identity, _.toDouble))), - ) - case schema if schema.schemaType.contains(TypeOrTypes.Type("integer")) => - JsonSchema.Integer( - IntegerFormat.fromString(schema.format.getOrElse("int64")), - schema.minimum.map(_.fold(_.toLong, identity)), - schema.exclusiveMinimum.map(_.map(_.fold(_.toLong, identity))), - schema.maximum.map(_.fold(_.toLong, identity)), - schema.exclusiveMaximum.map(_.map(_.fold(_.toLong, identity))), - ) - case schema if schema.schemaType.contains(TypeOrTypes.Type("string")) && schema.enumValues.isEmpty => - JsonSchema.String( - schema.format.map(StringFormat.fromString), - schema.pattern.map(Pattern.apply), - schema.minLength, - schema.maxLength, - ) - case schema if schema.schemaType.contains(TypeOrTypes.Type("boolean")) => - JsonSchema.Boolean - case schema if schema.schemaType.contains(TypeOrTypes.Type("array")) => - JsonSchema.ArrayType( - schema.items.map(fromSerializableSchema), - schema.minItems, - schema.uniqueItems.contains(true), - ) - case schema if schema.enumValues.isDefined => - JsonSchema.Enum(schema.enumValues.get.map(EnumValue.fromJson)) - case schema if schema.oneOf.isDefined => - OneOfSchema(schema.oneOf.get.map(fromSerializableSchema)) - case schema if schema.allOf.isDefined => - AllOfSchema(schema.allOf.get.map(fromSerializableSchema)) - case schema if schema.anyOf.isDefined => - AnyOfSchema(schema.anyOf.get.map(fromSerializableSchema)) - case schema if schema.schemaType.contains(TypeOrTypes.Type("null")) => - JsonSchema.Null - case schema if schema.schemaType.contains(TypeOrTypes.Type("object")) || schema.schemaType.isEmpty => - JsonSchema.Object( - schema.properties - .map(_.map { case (name, schema) => name -> fromSerializableSchema(schema) }) - .getOrElse(Map.empty), - additionalProperties, - schema.required.getOrElse(Chunk.empty), - ) - case _ => - throw new IllegalArgumentException(s"Can't convert $schema") - } + var jsonSchema: JsonSchema = schema match { + case schema if schema.ref.isDefined => + RefSchema(schema.ref.get) + case schema if schema.schemaType.contains(TypeOrTypes.Type("number")) => + JsonSchema.Number( + NumberFormat.fromString(schema.format.getOrElse("double")), + schema.minimum.map(_.fold(identity, _.toDouble)), + schema.exclusiveMinimum.map(_.map(_.fold(identity, _.toDouble))), + schema.maximum.map(_.fold(identity, _.toDouble)), + schema.exclusiveMaximum.map(_.map(_.fold(identity, _.toDouble))), + ) + case schema if schema.schemaType.contains(TypeOrTypes.Type("integer")) => + JsonSchema.Integer( + IntegerFormat.fromString(schema.format.getOrElse("int64")), + schema.minimum.map(_.fold(_.toLong, identity)), + schema.exclusiveMinimum.map(_.map(_.fold(_.toLong, identity))), + schema.maximum.map(_.fold(_.toLong, identity)), + schema.exclusiveMaximum.map(_.map(_.fold(_.toLong, identity))), + ) + case schema if schema.schemaType.contains(TypeOrTypes.Type("string")) && schema.enumValues.isEmpty => + JsonSchema.String( + schema.format.map(StringFormat.fromString), + schema.pattern.map(Pattern.apply), + schema.minLength, + schema.maxLength, + ) + case schema if schema.schemaType.contains(TypeOrTypes.Type("boolean")) => + JsonSchema.Boolean + case schema if schema.schemaType.contains(TypeOrTypes.Type("array")) => + JsonSchema.ArrayType( + schema.items.map(fromSerializableSchema), + schema.minItems, + schema.uniqueItems.contains(true), + ) + case schema if schema.enumValues.isDefined => + JsonSchema.Enum(schema.enumValues.get.map(EnumValue.fromJson)) + case schema if schema.oneOf.isDefined => + OneOfSchema(schema.oneOf.get.map(fromSerializableSchema)) + case schema if schema.allOf.isDefined => + AllOfSchema(schema.allOf.get.map(fromSerializableSchema)) + case schema if schema.anyOf.isDefined => + AnyOfSchema(schema.anyOf.get.map(fromSerializableSchema)) + case schema if schema.schemaType.contains(TypeOrTypes.Type("null")) => + JsonSchema.Null + case schema if schema.schemaType.contains(TypeOrTypes.Type("object")) || schema.schemaType.isEmpty => + JsonSchema.Object( + schema.properties + .map(_.map { case (name, schema) => name -> fromSerializableSchema(schema) }) + .getOrElse(Map.empty), + additionalProperties, + schema.required.getOrElse(Chunk.empty), + ) + case _ => + throw new IllegalArgumentException(s"Can't convert $schema") + } - val examples = Chunk.fromIterable(schema.example) ++ schema.examples.getOrElse(Chunk.empty) - if (examples.nonEmpty) jsonSchema = jsonSchema.examples(examples) + val examples = Chunk.fromIterable(schema.example) ++ schema.examples.getOrElse(Chunk.empty) + if (examples.nonEmpty) jsonSchema = jsonSchema.examples(examples) - schema.description match { - case Some(value) => jsonSchema = jsonSchema.description(value) - case None => () - } + schema.description match { + case Some(value) => jsonSchema = jsonSchema.description(value) + case None => () + } - schema.nullable match { - case Some(value) => jsonSchema = jsonSchema.nullable(value) - case None => () - } + schema.nullable match { + case Some(value) => jsonSchema = jsonSchema.nullable(value) + case None => () + } - schema.discriminator match { - case Some(value) => jsonSchema = jsonSchema.discriminator(value) - case None => () - } + schema.discriminator match { + case Some(value) => jsonSchema = jsonSchema.discriminator(value) + case None => () + } - schema.contentEncoding.flatMap(ContentEncoding.fromString) match { - case Some(value) => jsonSchema = jsonSchema.contentEncoding(value) - case None => () - } + schema.contentEncoding.flatMap(ContentEncoding.fromString) match { + case Some(value) => jsonSchema = jsonSchema.contentEncoding(value) + case None => () + } - schema.contentMediaType match { - case Some(value) => jsonSchema = jsonSchema.contentMediaType(value) - case None => () - } + schema.contentMediaType match { + case Some(value) => jsonSchema = jsonSchema.contentMediaType(value) + case None => () + } - jsonSchema = jsonSchema.default(schema.default) + jsonSchema = jsonSchema.default(schema.default) - jsonSchema = jsonSchema.deprecated(schema.deprecated.getOrElse(false)) + jsonSchema = jsonSchema.deprecated(schema.deprecated.getOrElse(false)) - jsonSchema + jsonSchema + } } def fromTextCodec(codec: TextCodec[_]): JsonSchema = @@ -1524,4 +1535,12 @@ object JsonSchema { SerializableJsonSchema() } + case object AnyJsonObj extends JsonSchema { + override protected[openapi] def toSerializableSchema: SerializableJsonSchema = + SerializableJsonSchema( + schemaType = Some(TypeOrTypes.Type("object")), + additionalProperties = Some(BooleanWrapper(true)), + ) + } + } From f34b87aceee816f781cf07f05f478b53193ea5f7 Mon Sep 17 00:00:00 2001 From: hochgi Date: Tue, 12 Nov 2024 18:53:00 +0200 Subject: [PATCH 2/3] [gen] adding support for "Any" and "AnyObject" as Json in codegen --- .../zio/http/gen/openapi/EndpointGen.scala | 2 - .../main/scala/zio/http/gen/scala/Code.scala | 1 - .../scala/zio/http/gen/scala/CodeGen.scala | 3 - .../src/test/resources/AnimalWithAny.scala | 2 +- .../zio/http/gen/scala/CodeGenSpec.scala | 14 ++--- .../zio/http/endpoint/http/HttpFile.scala | 1 - .../zio/http/endpoint/http/HttpGen.scala | 1 - .../http/endpoint/openapi/JsonSchema.scala | 57 ++++++++----------- 8 files changed, 33 insertions(+), 48 deletions(-) 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 185e7980f7..822bfe37c0 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 @@ -1353,8 +1353,6 @@ final case class EndpointGen(config: Config) { Some(Code.Field(name, Code.ScalaType.Unit, config.fieldNamesNormalization)) case JsonSchema.AnyJson => Some(Code.Field(name, Code.ScalaType.JsonAST, config.fieldNamesNormalization)) - case JsonSchema.AnyJsonObj => - Some(Code.Field(name, Code.ScalaType.JsonObj, 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 35eeefcc37..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 @@ -21,7 +21,6 @@ object Code { case object Inferred extends ScalaType case object Unit extends ScalaType case object JsonAST extends ScalaType - case object JsonObj extends ScalaType final case class Or(left: ScalaType, right: ScalaType) extends ScalaType } 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 930f598dd9..9c543b2c51 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 @@ -263,9 +263,6 @@ object CodeGen { case Code.ScalaType.JsonAST => List(Code.Import("zio.json.ast.Json")) -> "Json" - case Code.ScalaType.JsonObj => - List(Code.Import("zio.json.ast.Json")) -> "Json.Obj" - case scalaType => throw new Exception(s"Unknown ScalaType: $scalaType") } diff --git a/zio-http-gen/src/test/resources/AnimalWithAny.scala b/zio-http-gen/src/test/resources/AnimalWithAny.scala index 88a14e1468..119f0c6846 100644 --- a/zio-http-gen/src/test/resources/AnimalWithAny.scala +++ b/zio-http-gen/src/test/resources/AnimalWithAny.scala @@ -7,7 +7,7 @@ import zio.schema.annotation.fieldName case class Animal( name: String, eats: Json, - @fieldName("extra_attributes") extraAttributes: Json.Obj, + @fieldName("extra_attributes") extraAttributes: Map[String, Json], ) object Animal { implicit val codec: Schema[Animal] = DeriveSchema.gen[Animal] 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 9fbf44be35..bc9e0c263e 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 @@ -123,7 +123,7 @@ object CodeGenSpec extends ZIOSpecDefault { private val scalaFmtPath = java.nio.file.Paths.get(getClass.getResource("/scalafmt.conf").toURI) override def spec: Spec[TestEnvironment with Scope, Any] = - suite("CodeGenSpec")( /* + suite("CodeGenSpec")( test("Simple endpoint without data structures") { val endpoint = Endpoint(Method.GET / "api" / "v1" / "users") val openAPI = OpenAPIGen.fromEndpoints(endpoint) @@ -1001,7 +1001,7 @@ object CodeGenSpec extends ZIOSpecDefault { ) } } - } @@ TestAspect.exceptScala3, */ + } @@ TestAspect.exceptScala3, test("Schema with any and any object") { val openAPIString = stringFromResource("/inline_schema_any_and_any_object.yaml") @@ -1009,13 +1009,13 @@ object CodeGenSpec extends ZIOSpecDefault { codeGenFromOpenAPI( oapi, Config.default.copy( - fieldNamesNormalization = Config.default.fieldNamesNormalization.copy(enableAutomatic = true) + fieldNamesNormalization = Config.default.fieldNamesNormalization.copy(enableAutomatic = true), ), ) { testDir => allFilesShouldBe( testDir.toFile, List( - "api/Get_Animal.scala", + "api/v1/zoo/Animal.scala", "component/Animal.scala", ), ) && fileShouldBe( @@ -1025,7 +1025,7 @@ object CodeGenSpec extends ZIOSpecDefault { ) } } - } @@ TestAspect.exceptScala3, /* + } @@ TestAspect.exceptScala3, test("Generate all responses") { val oapi = OpenAPI( @@ -1205,6 +1205,6 @@ object CodeGenSpec extends ZIOSpecDefault { assert(EndpointGen.fromOpenAPI(oapi, Config.default).files) { Assertion.forall(importsZioSchema || fileContainsNoSchema) } - }, */ - ) @@ java11OrNewer /*@@ flaky*/ @@ blocking // Downloading scalafmt on CI is flaky + }, + ) @@ java11OrNewer @@ flaky @@ blocking // Downloading scalafmt on CI is flaky } diff --git a/zio-http/shared/src/main/scala/zio/http/endpoint/http/HttpFile.scala b/zio-http/shared/src/main/scala/zio/http/endpoint/http/HttpFile.scala index 2ec49a4794..9537e71e60 100644 --- a/zio-http/shared/src/main/scala/zio/http/endpoint/http/HttpFile.scala +++ b/zio-http/shared/src/main/scala/zio/http/endpoint/http/HttpFile.scala @@ -51,7 +51,6 @@ final case class HttpEndpoint( case JsonSchema.Enum(_) => s""""${getName(name)}": {{${getName(name)}}}""" case JsonSchema.Null => "" case JsonSchema.AnyJson => "" - case JsonSchema.AnyJsonObj => "" } private def getName(name: Option[String]) = { name.getOrElse(throw new IllegalArgumentException("name is required")) } diff --git a/zio-http/shared/src/main/scala/zio/http/endpoint/http/HttpGen.scala b/zio-http/shared/src/main/scala/zio/http/endpoint/http/HttpGen.scala index 19de08beb9..eae8f5aba1 100644 --- a/zio-http/shared/src/main/scala/zio/http/endpoint/http/HttpGen.scala +++ b/zio-http/shared/src/main/scala/zio/http/endpoint/http/HttpGen.scala @@ -114,7 +114,6 @@ object HttpGen { case JsonSchema.Enum(values) => Seq(HttpVariable(getName(name), None, Some(s"enum: ${values.mkString(",")}"))) case JsonSchema.Null => Seq.empty case JsonSchema.AnyJson => Seq.empty - case JsonSchema.AnyJsonObj => Seq.empty } bodySchema0 match { diff --git a/zio-http/shared/src/main/scala/zio/http/endpoint/openapi/JsonSchema.scala b/zio-http/shared/src/main/scala/zio/http/endpoint/openapi/JsonSchema.scala index 2794b38f56..0524bbf4df 100644 --- a/zio-http/shared/src/main/scala/zio/http/endpoint/openapi/JsonSchema.scala +++ b/zio-http/shared/src/main/scala/zio/http/endpoint/openapi/JsonSchema.scala @@ -1,16 +1,18 @@ package zio.http.endpoint.openapi import scala.annotation.{nowarn, tailrec} + import zio._ import zio.json.ast.Json + import zio.schema.Schema.CaseClass0 import zio.schema._ import zio.schema.annotation._ import zio.schema.codec._ import zio.schema.codec.json._ import zio.schema.validation._ + import zio.http.codec._ -import zio.http.endpoint.openapi.BoolOrSchema.BooleanWrapper import zio.http.endpoint.openapi.JsonSchema.MetaData @nowarn("msg=possible missing interpolator") @@ -255,17 +257,16 @@ object JsonSchema { // if type: object with additionalProperties defined, // but nothing else, we should assume a free form object - def anyObject: Boolean = schema.additionalProperties.collect { - case BoolOrSchema.BooleanWrapper(true) => - schema.schemaType.contains(TypeOrTypes.Type("object")) && - schema.productIterator.count(_.asInstanceOf[Option[_]].isDefined) == 2 + def anyObject: Boolean = schema.additionalProperties.collect { case BoolOrSchema.BooleanWrapper(true) => + schema.schemaType.contains(TypeOrTypes.Type("object")) && + schema.productIterator.count(_.asInstanceOf[Option[_]].isDefined) == 2 }.exists(identity) if (schema.productIterator.forall(_.asInstanceOf[Option[_]].isEmpty)) JsonSchema.AnyJson - else if (anyObject) JsonSchema.AnyJsonObj + else if (anyObject) JsonSchema.Object(Map.empty, Right(JsonSchema.AnyJson), Chunk.empty) else { val additionalProperties = schema.additionalProperties match { - case Some(BoolOrSchema.BooleanWrapper(bool)) => Left(bool) + case Some(BoolOrSchema.BooleanWrapper(bool)) => Left(bool) case Some(BoolOrSchema.SchemaWrapper(schema)) => val valuesSchema = fromSerializableSchema(schema) Right( @@ -277,13 +278,13 @@ object JsonSchema { ), ), ) - case None => Left(true) + case None => Left(true) } var jsonSchema: JsonSchema = schema match { - case schema if schema.ref.isDefined => + case schema if schema.ref.isDefined => RefSchema(schema.ref.get) - case schema if schema.schemaType.contains(TypeOrTypes.Type("number")) => + case schema if schema.schemaType.contains(TypeOrTypes.Type("number")) => JsonSchema.Number( NumberFormat.fromString(schema.format.getOrElse("double")), schema.minimum.map(_.fold(identity, _.toDouble)), @@ -291,7 +292,7 @@ object JsonSchema { schema.maximum.map(_.fold(identity, _.toDouble)), schema.exclusiveMaximum.map(_.map(_.fold(identity, _.toDouble))), ) - case schema if schema.schemaType.contains(TypeOrTypes.Type("integer")) => + case schema if schema.schemaType.contains(TypeOrTypes.Type("integer")) => JsonSchema.Integer( IntegerFormat.fromString(schema.format.getOrElse("int64")), schema.minimum.map(_.fold(_.toLong, identity)), @@ -306,23 +307,23 @@ object JsonSchema { schema.minLength, schema.maxLength, ) - case schema if schema.schemaType.contains(TypeOrTypes.Type("boolean")) => + case schema if schema.schemaType.contains(TypeOrTypes.Type("boolean")) => JsonSchema.Boolean - case schema if schema.schemaType.contains(TypeOrTypes.Type("array")) => + case schema if schema.schemaType.contains(TypeOrTypes.Type("array")) => JsonSchema.ArrayType( schema.items.map(fromSerializableSchema), schema.minItems, schema.uniqueItems.contains(true), ) - case schema if schema.enumValues.isDefined => + case schema if schema.enumValues.isDefined => JsonSchema.Enum(schema.enumValues.get.map(EnumValue.fromJson)) - case schema if schema.oneOf.isDefined => + case schema if schema.oneOf.isDefined => OneOfSchema(schema.oneOf.get.map(fromSerializableSchema)) - case schema if schema.allOf.isDefined => + case schema if schema.allOf.isDefined => AllOfSchema(schema.allOf.get.map(fromSerializableSchema)) - case schema if schema.anyOf.isDefined => + case schema if schema.anyOf.isDefined => AnyOfSchema(schema.anyOf.get.map(fromSerializableSchema)) - case schema if schema.schemaType.contains(TypeOrTypes.Type("null")) => + case schema if schema.schemaType.contains(TypeOrTypes.Type("null")) => JsonSchema.Null case schema if schema.schemaType.contains(TypeOrTypes.Type("object")) || schema.schemaType.isEmpty => JsonSchema.Object( @@ -332,7 +333,7 @@ object JsonSchema { additionalProperties, schema.required.getOrElse(Chunk.empty), ) - case _ => + case _ => throw new IllegalArgumentException(s"Can't convert $schema") } @@ -341,27 +342,27 @@ object JsonSchema { schema.description match { case Some(value) => jsonSchema = jsonSchema.description(value) - case None => () + case None => () } schema.nullable match { case Some(value) => jsonSchema = jsonSchema.nullable(value) - case None => () + case None => () } schema.discriminator match { case Some(value) => jsonSchema = jsonSchema.discriminator(value) - case None => () + case None => () } schema.contentEncoding.flatMap(ContentEncoding.fromString) match { case Some(value) => jsonSchema = jsonSchema.contentEncoding(value) - case None => () + case None => () } schema.contentMediaType match { case Some(value) => jsonSchema = jsonSchema.contentMediaType(value) - case None => () + case None => () } jsonSchema = jsonSchema.default(schema.default) @@ -1535,12 +1536,4 @@ object JsonSchema { SerializableJsonSchema() } - case object AnyJsonObj extends JsonSchema { - override protected[openapi] def toSerializableSchema: SerializableJsonSchema = - SerializableJsonSchema( - schemaType = Some(TypeOrTypes.Type("object")), - additionalProperties = Some(BooleanWrapper(true)), - ) - } - } From d4ef6d4498729ab9e2f209fb014f257a825f1ba0 Mon Sep 17 00:00:00 2001 From: hochgi Date: Wed, 13 Nov 2024 09:45:18 +0200 Subject: [PATCH 3/3] better condition logic now properly handles correctly these cases: extra_attributes: type: object additionalProperties: true extra_attributes: type: object additionalProperties: {} extra_attributes: additionalProperties: true but fails for this case, which might not be an issue (or an issue with zio-schema): extra_attributes: additionalProperties: {} --- .../http/endpoint/openapi/JsonSchema.scala | 25 +++++++++++++++---- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/zio-http/shared/src/main/scala/zio/http/endpoint/openapi/JsonSchema.scala b/zio-http/shared/src/main/scala/zio/http/endpoint/openapi/JsonSchema.scala index 0524bbf4df..9aa4a158e0 100644 --- a/zio-http/shared/src/main/scala/zio/http/endpoint/openapi/JsonSchema.scala +++ b/zio-http/shared/src/main/scala/zio/http/endpoint/openapi/JsonSchema.scala @@ -255,14 +255,29 @@ object JsonSchema { private[openapi] def fromSerializableSchema(schema: SerializableJsonSchema): JsonSchema = { + val definedAttributesCount = schema.productIterator.count(_.asInstanceOf[Option[_]].isDefined) + // if type: object with additionalProperties defined, // but nothing else, we should assume a free form object - def anyObject: Boolean = schema.additionalProperties.collect { case BoolOrSchema.BooleanWrapper(true) => - schema.schemaType.contains(TypeOrTypes.Type("object")) && - schema.productIterator.count(_.asInstanceOf[Option[_]].isDefined) == 2 - }.exists(identity) + // if type is not defined, but additionalProperties is, + // and nothing else, object is assumed again. + // if both type: object and additionalProperties are defined, + // and nothing else, object is assumed. + def anyObject: Boolean = { + val isObject = schema.schemaType.contains(TypeOrTypes.Type("object")) + val hasAttrs = schema.additionalProperties.collect { case BoolOrSchema.BooleanWrapper(b) => + b + }.exists(identity) + + // if definedAttributesCount == 0, this also yields true, + // but we check for it before calling this function, + // thus no need to check it here. + val isAnyObj = List(isObject, hasAttrs).count(identity) == definedAttributesCount + + isAnyObj + } - if (schema.productIterator.forall(_.asInstanceOf[Option[_]].isEmpty)) JsonSchema.AnyJson + if (definedAttributesCount == 0) JsonSchema.AnyJson else if (anyObject) JsonSchema.Object(Map.empty, Right(JsonSchema.AnyJson), Chunk.empty) else { val additionalProperties = schema.additionalProperties match {