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..7330086808 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,7 @@ 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 => None } } 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..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 @@ -260,6 +260,9 @@ object CodeGen { case Code.TypeRef(name) => Nil -> name + case Code.ScalaType.JsonAST => + List(Code.Import("zio.json.ast.Json")) -> "Json" + 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..119f0c6846 --- /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: Map[String, Json], +) +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..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 @@ -1002,6 +1002,30 @@ object CodeGenSpec extends ZIOSpecDefault { } } } @@ 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/v1/zoo/Animal.scala", + "component/Animal.scala", + ), + ) && fileShouldBe( + testDir, + "component/Animal.scala", + "/AnimalWithAny.scala", + ) + } + } + } @@ TestAspect.exceptScala3, test("Generate all responses") { val oapi = OpenAPI( 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 0dc9d674ce..13388be787 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 @@ -254,111 +254,138 @@ 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), + + val definedAttributesCount = schema.productIterator.count(_.asInstanceOf[Option[_]].isDefined) + + // if type: object with additionalProperties defined, + // but nothing else, we should assume a free form object + // 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 (definedAttributesCount == 0) JsonSchema.AnyJson + 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.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 =