From ed4ce0f8a3bf60a26047fdd8f607aac2bbe30002 Mon Sep 17 00:00:00 2001 From: Gilad Hoch Date: Sat, 31 Aug 2024 00:50:52 +0300 Subject: [PATCH] [gen] dictionaries with referenced keys extension (#3043) --- .../zio/http/gen/openapi/EndpointGen.scala | 31 ++- .../main/scala/zio/http/gen/scala/Code.scala | 10 +- .../scala/zio/http/gen/scala/CodeGen.scala | 9 +- .../resources/ComponentAliasOrderId.scala | 9 + .../test/resources/ComponentAliasUserId.scala | 9 + .../src/test/resources/ComponentOrder.scala | 14 ++ .../resources/ComponentOrderWithAliases.scala | 13 + .../resources/ComponentUserOrderHistory.scala | 12 + ...ComponentUserOrderHistoryWithAliases.scala | 11 + ...e_schema_constrained_inlined_keys_map.yaml | 67 +++++ .../inline_schema_constrained_keys_map.yaml | 69 +++++ ...chema_constrained_keys_map_wrong_type.yaml | 69 +++++ .../zio/http/gen/scala/CodeGenSpec.scala | 95 ++++++- .../http/endpoint/openapi/JsonSchema.scala | 236 +++++++++++++++--- .../http/endpoint/openapi/OpenAPISpec.scala | 43 +++- 15 files changed, 643 insertions(+), 54 deletions(-) create mode 100644 zio-http-gen/src/test/resources/ComponentAliasOrderId.scala create mode 100644 zio-http-gen/src/test/resources/ComponentAliasUserId.scala create mode 100644 zio-http-gen/src/test/resources/ComponentOrder.scala create mode 100644 zio-http-gen/src/test/resources/ComponentOrderWithAliases.scala create mode 100644 zio-http-gen/src/test/resources/ComponentUserOrderHistory.scala create mode 100644 zio-http-gen/src/test/resources/ComponentUserOrderHistoryWithAliases.scala create mode 100644 zio-http-gen/src/test/resources/inline_schema_constrained_inlined_keys_map.yaml create mode 100644 zio-http-gen/src/test/resources/inline_schema_constrained_keys_map.yaml create mode 100644 zio-http-gen/src/test/resources/inline_schema_constrained_keys_map_wrong_type.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 8b760d7254..930195cd10 100644 --- a/zio-http-gen/src/main/scala/zio/http/gen/openapi/EndpointGen.scala +++ b/zio-http-gen/src/main/scala/zio/http/gen/openapi/EndpointGen.scala @@ -7,7 +7,7 @@ import zio.Chunk import zio.http.Method import zio.http.endpoint.openapi.OpenAPI.ReferenceOr import zio.http.endpoint.openapi.{JsonSchema, OpenAPI} -import zio.http.gen.scala.Code.{CodecType, Collection, PathSegmentCode, ScalaType} +import zio.http.gen.scala.Code.{CodecType, Collection, PathSegmentCode, ScalaType, TypeRef} import zio.http.gen.scala.{Code, CodeGen} object EndpointGen { @@ -268,7 +268,7 @@ final case class EndpointGen(config: Config) { case tref: Code.TypeRef => f(tref) case Collection.Seq(inner, nonEmpty) => Collection.Seq(mapTypeRef(inner)(f), nonEmpty) case Collection.Set(inner, nonEmpty) => Collection.Set(mapTypeRef(inner)(f), nonEmpty) - case Collection.Map(inner) => Collection.Map(mapTypeRef(inner)(f)) + case Collection.Map(inner, keysType) => Collection.Map(mapTypeRef(inner)(f), keysType) case Collection.Opt(inner) => Collection.Opt(mapTypeRef(inner)(f)) case _ => sType } @@ -1302,11 +1302,36 @@ final case class EndpointGen(config: Config) { throw new Exception("Object with properties and additionalProperties is not supported") case JsonSchema.Object(properties, additionalProperties, _) if properties.isEmpty && additionalProperties.isRight => + val (vSchema, kSchemaOpt) = { + val vs = additionalProperties.toOption.get + val (ks, annotations) = JsonSchema.Object.extractKeySchemaFromAnnotations(vs) + vs.withoutAnnotations.annotate(annotations) -> ks + } + Some( Code.Field( name, Code.Collection.Map( - schemaToField(additionalProperties.toOption.get, openAPI, name, annotations).get.fieldType, + schemaToField(vSchema, openAPI, name, annotations).get.fieldType, + kSchemaOpt.collect { + case ss: JsonSchema.String => + schemaToField(ss, openAPI, name, annotations).get.fieldType + case JsonSchema.RefSchema(ref) => + val baref = ref.replaceFirst("^#/components/schemas/", "") + resolveSchemaRef(openAPI, baref) match { + case ks: JsonSchema.String => + if (config.generateSafeTypeAliases) TypeRef(baref + ".Type") + else schemaToField(ks, openAPI, name, annotations).get.fieldType + case nonStringSchema => + throw new IllegalArgumentException( + s"x-string-key-schema must reference a string schema, but got: ${nonStringSchema.toJson}", + ) + } + case nonStringSchema => + throw new IllegalArgumentException( + s"x-string-key-schema must be a string schema, but got: ${nonStringSchema.toJson}", + ) + }, ), ), ) 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 b365689ba8..63e708fa82 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 @@ -13,7 +13,7 @@ object Code { sealed trait ScalaType extends Code { self => def seq(nonEmpty: Boolean): Collection.Seq = Collection.Seq(self, nonEmpty) def set(nonEmpty: Boolean): Collection.Set = Collection.Set(self, nonEmpty) - def map: Collection.Map = Collection.Map(self) + def map: Collection.Map = Collection.Map(self, None) def opt: Collection.Opt = Collection.Opt(self) } @@ -165,10 +165,10 @@ object Code { } object Collection { - final case class Seq(elementType: ScalaType, nonEmpty: Boolean) extends Collection - final case class Set(elementType: ScalaType, nonEmpty: Boolean) extends Collection - final case class Map(elementType: ScalaType) extends Collection - final case class Opt(elementType: ScalaType) extends Collection + final case class Seq(elementType: ScalaType, nonEmpty: Boolean) extends Collection + final case class Set(elementType: ScalaType, nonEmpty: Boolean) extends Collection + final case class Map(elementType: ScalaType, keysType: Option[ScalaType]) extends Collection + final case class Opt(elementType: ScalaType) extends Collection } sealed trait Primitive 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 f64259936f..37b5566b17 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 @@ -198,9 +198,12 @@ object CodeGen { val (imports, tpe) = render(basePackage)(elementType) if (nonEmpty) (Code.Import("zio.prelude.NonEmptySet") :: imports) -> s"NonEmptySet[$tpe]" else imports -> s"Set[$tpe]" - case Code.Collection.Map(elementType) => - val (imports, tpe) = render(basePackage)(elementType) - imports -> s"Map[String, $tpe]" + case Code.Collection.Map(elementType, keysType) => + val (vImports, vType) = render(basePackage)(elementType) + keysType.fold(vImports -> s"Map[String, $vType]") { keyType => + val (kImports, kType) = render(basePackage)(keyType) + (kImports ::: vImports).distinct -> s"Map[$kType, $vType]" + } case Code.Collection.Opt(elementType) => val (imports, tpe) = render(basePackage)(elementType) imports -> s"Option[$tpe]" diff --git a/zio-http-gen/src/test/resources/ComponentAliasOrderId.scala b/zio-http-gen/src/test/resources/ComponentAliasOrderId.scala new file mode 100644 index 0000000000..ce32b14198 --- /dev/null +++ b/zio-http-gen/src/test/resources/ComponentAliasOrderId.scala @@ -0,0 +1,9 @@ +package test.component + +import zio.prelude.Newtype +import zio.schema.Schema +import java.util.UUID + +object OrderId extends Newtype[UUID] { + implicit val schema: Schema[OrderId.Type] = Schema.primitive[UUID].transform(wrap, unwrap) +} \ No newline at end of file diff --git a/zio-http-gen/src/test/resources/ComponentAliasUserId.scala b/zio-http-gen/src/test/resources/ComponentAliasUserId.scala new file mode 100644 index 0000000000..4b7a7f1327 --- /dev/null +++ b/zio-http-gen/src/test/resources/ComponentAliasUserId.scala @@ -0,0 +1,9 @@ +package test.component + +import zio.prelude.Newtype +import zio.schema.Schema +import java.util.UUID + +object UserId extends Newtype[UUID] { + implicit val schema: Schema[UserId.Type] = Schema.primitive[UUID].transform(wrap, unwrap) +} \ No newline at end of file diff --git a/zio-http-gen/src/test/resources/ComponentOrder.scala b/zio-http-gen/src/test/resources/ComponentOrder.scala new file mode 100644 index 0000000000..5c9a99f422 --- /dev/null +++ b/zio-http-gen/src/test/resources/ComponentOrder.scala @@ -0,0 +1,14 @@ +package test.component + +import zio.schema._ +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, +) +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 new file mode 100644 index 0000000000..7a601f7605 --- /dev/null +++ b/zio-http-gen/src/test/resources/ComponentOrderWithAliases.scala @@ -0,0 +1,13 @@ +package test.component + +import zio.schema._ + +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, +) +object Order { + implicit val codec: Schema[Order] = DeriveSchema.gen[Order] +} diff --git a/zio-http-gen/src/test/resources/ComponentUserOrderHistory.scala b/zio-http-gen/src/test/resources/ComponentUserOrderHistory.scala new file mode 100644 index 0000000000..a447d0e463 --- /dev/null +++ b/zio-http-gen/src/test/resources/ComponentUserOrderHistory.scala @@ -0,0 +1,12 @@ +package test.component + +import zio.schema._ +import java.util.UUID + +case class UserOrderHistory( + user_id: UUID, + history: Map[UUID, Order], +) +object UserOrderHistory { + implicit val codec: Schema[UserOrderHistory] = DeriveSchema.gen[UserOrderHistory] +} \ No newline at end of file diff --git a/zio-http-gen/src/test/resources/ComponentUserOrderHistoryWithAliases.scala b/zio-http-gen/src/test/resources/ComponentUserOrderHistoryWithAliases.scala new file mode 100644 index 0000000000..95dc1a3e9f --- /dev/null +++ b/zio-http-gen/src/test/resources/ComponentUserOrderHistoryWithAliases.scala @@ -0,0 +1,11 @@ +package test.component + +import zio.schema._ + +case class UserOrderHistory( + user_id: UserId.Type, + history: Map[OrderId.Type, Order], +) +object UserOrderHistory { + implicit val codec: Schema[UserOrderHistory] = DeriveSchema.gen[UserOrderHistory] +} \ No newline at end of file diff --git a/zio-http-gen/src/test/resources/inline_schema_constrained_inlined_keys_map.yaml b/zio-http-gen/src/test/resources/inline_schema_constrained_inlined_keys_map.yaml new file mode 100644 index 0000000000..cbab7aa02a --- /dev/null +++ b/zio-http-gen/src/test/resources/inline_schema_constrained_inlined_keys_map.yaml @@ -0,0 +1,67 @@ +info: + title: Shop Service + version: 0.0.1 +servers: + - url: http://127.0.0.1:5000/ +tags: + - name: Order_API +paths: + /api/v1/shop/history/{id}: + get: + operationId: get_user_history + parameters: + - in: path + name: id + schema: + type: string + format: uuid + required: true + tags: + - Order_API + description: Get user order history by user id + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/UserOrderHistory' + description: OK +openapi: 3.0.3 +components: + schemas: + UserOrderHistory: + type: object + required: + - user_id + - history + properties: + user_id: + type: string + format: uuid + history: + type: object + additionalProperties: + $ref: '#/components/schemas/Order' + x-string-key-schema: + type: string + format: uuid + Order: + type: object + required: + - id + - product + - quantity + - price + properties: + id: + type: string + format: uuid + product: + type: string + quantity: + type: integer + format: int32 + minimum: 1 + price: + type: number + minimum: 0 diff --git a/zio-http-gen/src/test/resources/inline_schema_constrained_keys_map.yaml b/zio-http-gen/src/test/resources/inline_schema_constrained_keys_map.yaml new file mode 100644 index 0000000000..3da7817284 --- /dev/null +++ b/zio-http-gen/src/test/resources/inline_schema_constrained_keys_map.yaml @@ -0,0 +1,69 @@ +info: + title: Shop Service + version: 0.0.1 +servers: + - url: http://127.0.0.1:5000/ +tags: + - name: Order_API +paths: + /api/v1/shop/history/{id}: + get: + operationId: get_user_history + parameters: + - in: path + name: id + schema: + $ref: '#/components/schemas/UserId' + required: true + tags: + - Order_API + description: Get user order history by user id + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/UserOrderHistory' + description: OK +openapi: 3.0.3 +components: + schemas: + UserOrderHistory: + type: object + required: + - user_id + - history + properties: + user_id: + $ref: '#/components/schemas/UserId' + history: + type: object + additionalProperties: + $ref: '#/components/schemas/Order' + x-string-key-schema: + $ref: '#/components/schemas/OrderId' + Order: + type: object + required: + - id + - product + - quantity + - price + properties: + id: + $ref: '#/components/schemas/OrderId' + product: + type: string + quantity: + type: integer + format: int32 + minimum: 1 + price: + type: number + minimum: 0 + OrderId: + type: string + format: uuid + UserId: + type: string + format: uuid diff --git a/zio-http-gen/src/test/resources/inline_schema_constrained_keys_map_wrong_type.yaml b/zio-http-gen/src/test/resources/inline_schema_constrained_keys_map_wrong_type.yaml new file mode 100644 index 0000000000..961ca4322e --- /dev/null +++ b/zio-http-gen/src/test/resources/inline_schema_constrained_keys_map_wrong_type.yaml @@ -0,0 +1,69 @@ +info: + title: Shop Service + version: 0.0.1 +servers: + - url: http://127.0.0.1:5000/ +tags: + - name: Order_API +paths: + /api/v1/shop/history/{id}: + get: + operationId: get_user_history + parameters: + - in: path + name: id + schema: + $ref: '#/components/schemas/UserId' + required: true + tags: + - Order_API + description: Get user order history by user id + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/UserOrderHistory' + description: OK +openapi: 3.0.3 +components: + schemas: + UserOrderHistory: + type: object + required: + - user_id + - history + properties: + user_id: + $ref: '#/components/schemas/UserId' + history: + type: object + additionalProperties: + $ref: '#/components/schemas/Order' + x-string-key-schema: + $ref: '#/components/schemas/OrderId' + Order: + type: object + required: + - id + - product + - quantity + - price + properties: + id: + $ref: '#/components/schemas/OrderId' + product: + type: string + quantity: + type: integer + format: int32 + minimum: 1 + price: + type: number + minimum: 0 + OrderId: + type: integer + format: int32 + UserId: + 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 b146658079..326a936a1a 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 @@ -10,7 +10,7 @@ import scala.util.{Failure, Success, Try} import zio.Scope import zio.json.{JsonDecoder, JsonEncoder} -import zio.test.Assertion.{hasSameElements, isFailure, isSuccess} +import zio.test.Assertion.{equalTo, hasSameElements, isFailure, isSuccess, succeeds} import zio.test.TestAspect.{blocking, flaky} import zio.test._ @@ -758,6 +758,99 @@ object CodeGenSpec extends ZIOSpecDefault { } } }, + test("Additional referenced properties") { + val openAPIString = stringFromResource("/inline_schema_constrained_keys_map.yaml") + + openApiFromYamlString(openAPIString) { oapi => + codeGenFromOpenAPI(oapi) { testDir => + allFilesShouldBe( + testDir.toFile, + List( + "api/v1/shop/history/Id.scala", + "component/Order.scala", + "component/UserOrderHistory.scala", + ), + ) && fileShouldBe( + testDir, + "component/Order.scala", + "/ComponentOrder.scala", + ) && fileShouldBe( + testDir, + "component/UserOrderHistory.scala", + "/ComponentUserOrderHistory.scala", + ) + } + } + } @@ TestAspect.exceptScala3, + test("Additional inlined properties") { + val openAPIString = stringFromResource("/inline_schema_constrained_inlined_keys_map.yaml") + + openApiFromYamlString(openAPIString) { oapi => + codeGenFromOpenAPI(oapi) { testDir => + allFilesShouldBe( + testDir.toFile, + List( + "api/v1/shop/history/Id.scala", + "component/Order.scala", + "component/UserOrderHistory.scala", + ), + ) && fileShouldBe( + testDir, + "component/Order.scala", + "/ComponentOrder.scala", + ) && fileShouldBe( + testDir, + "component/UserOrderHistory.scala", + "/ComponentUserOrderHistory.scala", + ) + } + } + } @@ TestAspect.exceptScala3, + test("Additional aliased referenced properties") { + val openAPIString = stringFromResource("/inline_schema_constrained_keys_map.yaml") + + openApiFromYamlString(openAPIString) { oapi => + codeGenFromOpenAPI(oapi, Config(commonFieldsOnSuperType = false, generateSafeTypeAliases = true)) { testDir => + allFilesShouldBe( + testDir.toFile, + List( + "api/v1/shop/history/Id.scala", + "component/Order.scala", + "component/UserOrderHistory.scala", + "component/OrderId.scala", + "component/UserId.scala", + ), + ) && fileShouldBe( + testDir, + "component/Order.scala", + "/ComponentOrderWithAliases.scala", + ) && fileShouldBe( + testDir, + "component/UserOrderHistory.scala", + "/ComponentUserOrderHistoryWithAliases.scala", + ) && fileShouldBe( + testDir, + "component/OrderId.scala", + "/ComponentAliasOrderId.scala", + ) && fileShouldBe( + testDir, + "component/UserId.scala", + "/ComponentAliasUserId.scala", + ) + } + } + } @@ TestAspect.exceptScala3, + test("Additional referenced properties with non-string key type") { + val openAPIString = stringFromResource("/inline_schema_constrained_keys_map_wrong_type.yaml") + + openApiFromYamlString(openAPIString) { oapi => + assertTrue( + Try( + codeGenFromOpenAPI(oapi)(_ => TestResult(TestArrow.succeed(true))), + ).failed.get.getMessage == "x-string-key-schema must reference a string schema, but got: {\"type\":\"integer\",\"format\":\"int32\"}", + ) + } + }, test("Endpoint with data validation") { val endpoint = Endpoint(Method.POST / "api" / "v1" / "users").in[ValidatedData] val openAPIJson = OpenAPIGen.fromEndpoints(endpoint).toJson 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 40203a8419..3dd7ce54ca 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,21 @@ package zio.http.endpoint.openapi import scala.annotation.{nowarn, tailrec} +import scala.util.chaining.scalaUtilChainingOps import zio._ import zio.json.ast.Json import zio.schema.Schema.CaseClass0 +import zio.schema.StandardType.Tags import zio.schema._ import zio.schema.annotation._ import zio.schema.codec._ import zio.schema.codec.json._ import zio.schema.validation._ -import zio.http.codec.{SegmentCodec, TextCodec} +import zio.http.codec.{PathCodec, SegmentCodec, TextCodec} +import zio.http.endpoint.openapi.BoolOrSchema.SchemaWrapper import zio.http.endpoint.openapi.JsonSchema.MetaData @nowarn("msg=possible missing interpolator") @@ -26,6 +29,7 @@ private[openapi] case class SerializableJsonSchema( @fieldName("enum") enumValues: Option[Chunk[Json]] = None, properties: Option[Map[String, SerializableJsonSchema]] = None, additionalProperties: Option[BoolOrSchema] = None, + @fieldName("x-string-key-schema") optionalKeySchema: Option[SerializableJsonSchema] = None, required: Option[Chunk[String]] = None, items: Option[SerializableJsonSchema] = None, nullable: Option[Boolean] = None, @@ -101,7 +105,7 @@ private[openapi] object BoolOrSchema { object SchemaWrapper { implicit val schema: Schema[SchemaWrapper] = - Schema[SerializableJsonSchema].transform(SchemaWrapper(_), _.schema) + Schema[SerializableJsonSchema].transform(SchemaWrapper.apply, _.schema) } final case class BooleanWrapper(value: Boolean) extends BoolOrSchema @@ -254,9 +258,18 @@ object JsonSchema { private def fromSerializableSchema(schema: SerializableJsonSchema): JsonSchema = { val additionalProperties = schema.additionalProperties match { - case Some(BoolOrSchema.BooleanWrapper(false)) => Left(false) - case Some(BoolOrSchema.BooleanWrapper(true)) => Left(true) - case Some(BoolOrSchema.SchemaWrapper(schema)) => Right(fromSerializableSchema(schema)) + 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) } @@ -432,10 +445,10 @@ object JsonSchema { minItems = Some(1), uniqueItems = identity == "NonEmptySet", ) - case Schema.Map(_, valueSchema, _) => - mapSchema(refType, ref, seenWithCurrent, valueSchema) - case Schema.NonEmptyMap(_, valueSchema, _) => - mapSchema(refType, ref, seenWithCurrent, valueSchema) + case Schema.Map(keySchema, valueSchema, _) => + mapSchema(refType, ref, seenWithCurrent, keySchema, valueSchema) + case Schema.NonEmptyMap(keySchema, valueSchema, _) => + mapSchema(refType, ref, seenWithCurrent, keySchema, valueSchema) case Schema.Set(elementSchema, _) => arraySchemaMulti(refType, ref, elementSchema, seenWithCurrent) } @@ -492,30 +505,25 @@ object JsonSchema { } } - private def mapSchema[V]( + private def mapSchema[K, V]( refType: SchemaStyle, ref: Option[java.lang.String], seenWithCurrent: Set[java.lang.String], + keySchema: Schema[K], valueSchema: Schema[V], ) = { - val nested = fromZSchemaMulti(valueSchema, refType, seenWithCurrent) + val nested = fromZSchemaMulti(valueSchema, refType, seenWithCurrent) + val mapObjectSchema = annotateMapSchemaWithKeysSchema(nested.root, keySchema) + if (valueSchema.isInstanceOf[Schema.Primitive[_]]) { JsonSchemas( - JsonSchema.Object( - Map.empty, - Right(nested.root), - Chunk.empty, - ), + mapObjectSchema, ref, nested.children, ) } else { JsonSchemas( - JsonSchema.Object( - Map.empty, - Right(nested.root), - Chunk.empty, - ), + mapObjectSchema, ref, nested.children ++ nested.rootRef.map(_ -> nested.root), ) @@ -546,6 +554,39 @@ object JsonSchema { } } + private def annotationForKeySchema[K](keySchema: Schema[K]): Option[MetaData.KeySchema] = + keySchema match { + case Schema.Primitive(StandardType.StringType, annotations) if annotations.isEmpty => None + case nonSimple => + fromZSchema(nonSimple) match { + case JsonSchema.String(None, None, None, None) => None // no need for extension + case s: JsonSchema.String => Some(MetaData.KeySchema(s)) + case _ => None // only string keys are allowed + } + } + + private def annotateMapSchemaWithKeysSchema[K](valueSchema: JsonSchema, keySchema: Schema[K]) = { + val keySchemaOpt: Option[MetaData.KeySchema] = annotationForKeySchema(keySchema) + val resultSchema = keySchemaOpt match { + case Some(keySchemaAnnotation) => valueSchema.annotate(keySchemaAnnotation) + case None => valueSchema + } + JsonSchema.Object( + Map.empty, + Right(resultSchema), + Chunk.empty, + ) + } + + private def jsonSchemaFromAnyMapSchema[K, V]( + keySchema: Schema[K], + valueSchema: Schema[V], + refType: SchemaStyle, + ): JsonSchema.Object = { + val valuesSchema = fromZSchema(valueSchema, refType) + annotateMapSchemaWithKeysSchema(valuesSchema, keySchema) + } + def fromZSchema(schema: Schema[_], refType: SchemaStyle = SchemaStyle.Inline): JsonSchema = schema match { case enum0: Schema.Enum[_] if refType != SchemaStyle.Inline && nominal(enum0).isDefined => @@ -629,18 +670,10 @@ object JsonSchema { None, uniqueItems = identity == "NonEmptySet", ) - case Schema.Map(_, valueSchema, _) => - JsonSchema.Object( - Map.empty, - Right(fromZSchema(valueSchema, refType)), - Chunk.empty, - ) - case Schema.NonEmptyMap(_, valueSchema, _) => - JsonSchema.Object( - Map.empty, - Right(fromZSchema(valueSchema, refType)), - Chunk.empty, - ) + case Schema.Map(keySchema, valueSchema, _) => + jsonSchemaFromAnyMapSchema(keySchema, valueSchema, refType) + case Schema.NonEmptyMap(keySchema, valueSchema, _) => + jsonSchemaFromAnyMapSchema(keySchema, valueSchema, refType) case Schema.Set(elementSchema, _) => JsonSchema.ArrayType(Some(fromZSchema(elementSchema, refType)), None, uniqueItems = true) } @@ -816,12 +849,17 @@ object JsonSchema { schema.toSerializableSchema.copy(deprecated = Some(true)) case MetaData.Default(default) => schema.toSerializableSchema.copy(default = Some(default)) + case _: MetaData.KeySchema => + // This is used only for additionalProperties schema, + // where this annotation captures the schema for key values only. + throw new IllegalStateException("KeySchema annotation should be stripped from schema prior to serialization") } } } sealed trait MetaData extends Product with Serializable object MetaData { + final case class KeySchema(schema: JsonSchema) extends MetaData final case class Examples(chunk: Chunk[Json]) extends MetaData final case class Default(default: Json) extends MetaData final case class Discriminator(discriminator: OpenAPI.Discriminator) extends MetaData @@ -1263,23 +1301,132 @@ object JsonSchema { def required(required: Chunk[java.lang.String]): Object = this.copy(required = required) + private def reconcileIfBothDefined[T](left: Option[T], right: Option[T])(combine: (T, T) => Option[T]): Option[T] = + for { + l <- left + r <- right + c <- combine(l, r) + } yield c + + private def reconcileOrEither[T](left: Option[T], right: Option[T])(combine: (T, T) => Option[T]): Option[T] = + (left, right) match { + case (Some(l), Some(r)) => combine(l, r) + case (lOption, rOption) => lOption.orElse(rOption) + } + + private def someWhenEq[T](l: T, r: T): Option[T] = + if (l == r) Some(l) else None + + private def combinePatterns(lPattern: Pattern, rPattern: Pattern): Some[Pattern] = + if (lPattern == rPattern) Some(lPattern) + else { + // validate either pattern match. + // + // If we to enforce AND semantics rather than OR semantics, + // we can be easily achieve this with lookahead assertions: {{{ + // Pattern("(?=" + lPattern + ")(?=" + rPattern + ")") + // }}} + Some(Pattern("(" + lPattern + ")|(" + rPattern + ")")) + } + + private def wrap[T](f: (T, T) => T): (T, T) => Option[T] = (l, r) => Some(f(l, r)) + + /** + * When combining additionalProperties (AKA dictionaries) schemas, usually + * we should deal only with the values schemas. + * + * Since a support for "x-string-key-schema" extension was added, we also + * have schemas for the dictionary keys. This is not supported natively in + * OpenAPI, which allows only string keys without schema. + * + * By allowing this extension, we are still restricted to only use string + * keys so we adhere to OpenAPI's rules, but we can have a more fine-grained + * semantics. + * + * For instance, having keys as UUID instead of plain strings, referencing + * aliased Newtype strings, or have other OpenAPI string validations like + * pattern, or length. + * + * In here, we attempt to reconcile 2 key schemas. They must be Strings, (or + * else we'll throw). And we attempt to make them match the best we can: + * - only keep format if match on both + * - adjust the pattern to enforce both patterns if both exist + * - max length is restricted to be the minimum + * - min length is restricted to be the maximum + * - if after reconciliation min > max, we discard both requirements + * + * @param left + * @param right + * @return + */ + private def combineKeySchemasForAdditionalProperties( + left: Option[JsonSchema], + right: Option[JsonSchema], + ): Option[JsonSchema] = { + + // TODO: what happens in case of annotated key schemas? + // Is it possible? + // How should we combine if so? + // Meanwhile, we just discard an key schema annotations. + // key schemas are a special extension without support in other libraries, so it's fine. + val lKeyNoAnnotations = left.map(_.withoutAnnotations) + val rKeyNoAnnotations = right.map(_.withoutAnnotations) + + reconcileIfBothDefined(lKeyNoAnnotations, rKeyNoAnnotations) { + case (JsonSchema.String(lfmt, lptn, lmxl, lmnl), JsonSchema.String(rfmt, rptn, rmxl, rmnl)) => + Some( + JsonSchema.String( + format = reconcileIfBothDefined(lfmt, rfmt)(someWhenEq), + pattern = reconcileIfBothDefined(lptn, rptn)(combinePatterns), + maxLength = reconcileOrEither(lmxl, rmxl)(wrap(math.max)), + minLength = reconcileOrEither(lmnl, rmnl)(wrap(math.min)), + ), + ) + case (l, r) => throw new IllegalArgumentException(s"dictionary keys must be of string schemas! got: $l, $r") + } + } + private def combineAdditionalProperties( left: Either[Boolean, JsonSchema], right: Either[Boolean, JsonSchema], ): Either[Boolean, JsonSchema] = (left, right) match { - case (Left(false), _) => Left(false) - case (_, Left(_)) => left - case (Left(true), _) => right - case (Right(left), Right(right)) => - Right(AllOfSchema(Chunk(left, right))) + case (Left(false), _) => Left(false) + case (_, Left(_)) => left + case (Left(true), _) => right + case (Right(lSchema), Right(rSchema)) => + val (leftKey, lAnnotations) = Object.extractKeySchemaFromAnnotations(lSchema) + val (rightKey, rAnnotations) = Object.extractKeySchemaFromAnnotations(rSchema) + + val keySchema = combineKeySchemasForAdditionalProperties(leftKey, rightKey) + + val leftVal = lSchema.withoutAnnotations.annotate(lAnnotations) + val rightVal = rSchema.withoutAnnotations.annotate(rAnnotations) + + // TODO: should we flatten AllOfSchemas here? + val combined = AllOfSchema(Chunk(leftVal, rightVal)) + val annotatedCombined = keySchema.fold[JsonSchema](combined)(ks => combined.annotate(MetaData.KeySchema(ks))) + + Right(annotatedCombined) } override protected[openapi] def toSerializableSchema: SerializableJsonSchema = { val additionalProperties = this.additionalProperties match { - case Left(true) => None - case Left(false) => Some(BoolOrSchema.BooleanWrapper(false)) - case Right(schema) => Some(BoolOrSchema.SchemaWrapper(schema.toSerializableSchema)) + case Left(true) => None + case Left(false) => Some(BoolOrSchema.BooleanWrapper(false)) + case Right(js) => + val (keySchemaOpt, nonKeySchemaAnnotations) = Object.extractKeySchemaFromAnnotations(js) + val filteredKeySchemaOpt = keySchemaOpt.filterNot { + case JsonSchema.String(None, None, None, None) => true // no need to annotate a plain string key + case _ => false + } + val valueSerializedSchema = + js.withoutAnnotations + .annotate(nonKeySchemaAnnotations) + .toSerializableSchema + .copy(optionalKeySchema = filteredKeySchemaOpt.map(_.toSerializableSchema)) + + Some(BoolOrSchema.SchemaWrapper(valueSerializedSchema)) } val nullableFields = properties.collect { case (name, schema) if schema.isNullable => name }.toSet @@ -1301,6 +1448,13 @@ object JsonSchema { object Object { val empty: JsonSchema.Object = JsonSchema.Object(Map.empty, Left(true), Chunk.empty) + + private[http] def extractKeySchemaFromAnnotations(js: JsonSchema): (Option[JsonSchema], Chunk[MetaData]) = + js.annotations.foldLeft(Option.empty[JsonSchema] -> Chunk.empty[MetaData]) { + case ((kSchemaOpt, otherAnnotations), MetaData.KeySchema(s)) => kSchemaOpt.orElse(Some(s)) -> otherAnnotations + case ((kSchemaOpt, otherAnnotations), noKeySchemaAnnotation) => + kSchemaOpt -> (otherAnnotations :+ noKeySchemaAnnotation) + } } final case class Enum(values: Chunk[EnumValue]) extends JsonSchema { diff --git a/zio-http/shared/src/test/scala/zio/http/endpoint/openapi/OpenAPISpec.scala b/zio-http/shared/src/test/scala/zio/http/endpoint/openapi/OpenAPISpec.scala index 5a81e27838..b8ab5af2a8 100644 --- a/zio-http/shared/src/test/scala/zio/http/endpoint/openapi/OpenAPISpec.scala +++ b/zio-http/shared/src/test/scala/zio/http/endpoint/openapi/OpenAPISpec.scala @@ -1,5 +1,7 @@ package zio.http.endpoint.openapi +import java.util.UUID + import scala.collection.immutable.ListMap import zio.json.ast.Json @@ -8,6 +10,7 @@ import zio.test._ import zio.schema.Schema import zio.http.endpoint.openapi.JsonSchema.SchemaStyle +import zio.http.endpoint.openapi.OpenAPI.ReferenceOr import zio.http.endpoint.openapi.OpenAPI.SecurityScheme._ object OpenAPISpec extends ZIOSpecDefault { @@ -68,7 +71,8 @@ object OpenAPISpec extends ZIOSpecDefault { val isSchemaProperlyGenerated = if (sch.root.isCollection) sch.root match { case JsonSchema.Object(_, additionalProperties, _) => additionalProperties match { - case Right(JsonSchema.ArrayType(items, _, _)) => items.exists(_.isInstanceOf[JsonSchema.String]) + case Right(JsonSchema.ArrayType(items, _, _)) => + items.exists(_.isInstanceOf[JsonSchema.String]) case _ => false } case _ => false @@ -76,5 +80,42 @@ object OpenAPISpec extends ZIOSpecDefault { else false assertTrue(isSchemaProperlyGenerated) }, + test("JsonSchema.fromZSchema correctly handles Map with non-simple string keys") { + val schema = Schema.map[UUID, String] + val js = JsonSchema.fromZSchema(schema) + val oapi = OpenAPI.empty.copy( + components = + Some(OpenAPI.Components(schemas = ListMap(OpenAPI.Key.fromString("IdToName").get -> ReferenceOr.Or(js)))), + ) + val json = oapi.toJsonPretty + val expected = """{ + | "openapi" : "3.1.0", + | "info" : { + | "title" : "", + | "version" : "" + | }, + | "components" : { + | "schemas" : { + | "IdToName" : + | { + | "type" : + | "object", + | "properties" : {}, + | "additionalProperties" : + | { + | "type" : + | "string", + | "x-string-key-schema" : { + | "type" : + | "string", + | "format" : "uuid" + | } + | } + | } + | } + | } + |}""".stripMargin + assertTrue(toJsonAst(json) == toJsonAst(expected)) + }, ) }