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 cdce331a56..f1c815a267 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 @@ -8,6 +8,7 @@ import zio.schema.codec.BinaryCodec import zio.http.MediaType import zio.http.codec._ import zio.http.endpoint.Endpoint +import zio.http.endpoint.openapi.JsonSchema.{SchemaRef, SchemaSpec, SchemaStyle} import zio.http.endpoint.openapi.OpenAPIGen.{AtomizedMetaCodecs, MetaCodec} import zio.http.endpoint.openapi.{JsonSchema, OpenAPIGen} @@ -52,7 +53,7 @@ object HttpGen { inAtoms.content.collect { case MetaCodec(HttpCodec.Content(codec, _, _), _) if codec.choices.contains(MediaType.application.json) => val schema = codec.choices(MediaType.application.json).schema - val jsonSchema = JsonSchema.fromZSchema(schema) + val jsonSchema = JsonSchema.fromZSchema(schema, SchemaRef(SchemaSpec.OpenAPI, SchemaStyle.Inline)) jsonSchema }.headOption } 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 af2cb3b114..7508907231 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 @@ -18,6 +18,7 @@ import zio.http.endpoint.openapi.JsonSchema.MetaData @nowarn("msg=possible missing interpolator") private[openapi] case class SerializableJsonSchema( + @fieldName("$schema") schema: Option[String] = None, @fieldName("$ref") ref: Option[String] = None, @fieldName("type") schemaType: Option[TypeOrTypes] = None, format: Option[String] = None, @@ -49,6 +50,7 @@ private[openapi] case class SerializableJsonSchema( exclusiveMaximum: Option[Either[Boolean, Either[Double, Long]]] = None, uniqueItems: Option[Boolean] = None, minItems: Option[Int] = None, + @fieldName("$defs") defs: Option[Map[String, SerializableJsonSchema]] = None, ) { def asNullableType(nullable: Boolean): SerializableJsonSchema = { import SerializableJsonSchema.typeNull @@ -418,10 +420,89 @@ object JsonSchema { case SegmentCodec.Combined(_, _, _) => throw new IllegalArgumentException("Combined segment is not supported.") } + /** + * Represents a complete JSON Schema document, consisting of: + * + * @param root + * The top-level [[JsonSchema]] for the document. + * @param children + * A map of definition names to their corresponding [[JsonSchema]] + * instances. + */ + case class JsonSchemaRoot(root: JsonSchema, children: Map[java.lang.String, JsonSchema]) { + def toSerializableSchema: SerializableJsonSchema = { + root.toSerializableSchema.copy( + schema = Some("https://json-schema.org/draft/2020-12/schema"), + defs = Some(children.map { case (key, schema) => key -> schema.toSerializableSchema }), + ) + } + + def encodeJson(indent: Option[Int] = None): CharSequence = + JsonCodec + .jsonEncoder( + JsonCodec.Configuration( + explicitEmptyCollections = ExplicitConfig(encoding = false), + explicitNulls = ExplicitConfig(encoding = false), + ), + )(JsonSchemaRoot.schema) + .encodeJson(this, indent) + + /** + * Serializes the schema into compact JSON (no pretty-printing). + * + * @return + * A JSON string containing the serialized schema. + */ + def toJson: java.lang.String = encodeJson(None).toString + + /** + * Serializes the schema into human-readable, pretty-printed JSON. + * + * @return + * A formatted JSON string representing the schema. + */ + def toJsonPretty: java.lang.String = encodeJson(Some(0)).toString + } + + object JsonSchemaRoot { + implicit val schema: Schema[JsonSchemaRoot] = + SerializableJsonSchema.schema + .transform[JsonSchemaRoot](JsonSchemaRoot.fromSerializableSchema, _.toSerializableSchema) + + private[openapi] def fromSerializableSchema(schema: SerializableJsonSchema): JsonSchemaRoot = + JsonSchemaRoot( + JsonSchema.fromSerializableSchema(schema), + schema.defs.getOrElse(Map.empty).map { case (key, schema) => key -> JsonSchema.fromSerializableSchema(schema) }, + ) + } + + /** + * Builds a JSON Schema document from the given ZIO `Schema` + * + * @param schema + * The schema to convert into a JSON Schema document. + * @return + * A [[JsonSchemaRoot]] representing a valid JSON Schema document. + */ + def jsonSchema(schema: Schema[_]): JsonSchemaRoot = { + val s = fromZSchemaMultiple(schema, SchemaRef(SchemaSpec.JsonSchema, SchemaStyle.Compact)) + JsonSchemaRoot( + s.root, + s.children.map { case (key, schema) => key.replace(SchemaSpec.JsonSchema.path, "") -> schema }, + ) + } + + @deprecated("use fromZSchemaMultiple", "3.8") def fromZSchemaMulti( schema: Schema[_], refType: SchemaStyle = SchemaStyle.Inline, seen: Set[java.lang.String] = Set.empty, + ): JsonSchemas = fromZSchemaMultiple(schema, SchemaRef(SchemaSpec.OpenAPI, refType), seen) + + def fromZSchemaMultiple( + schema: Schema[_], + refType: SchemaRef, + seen: Set[java.lang.String] = Set.empty, ): JsonSchemas = { val ref = nominal(schema, refType) if (ref.exists(seen.contains)) { @@ -430,18 +511,18 @@ object JsonSchema { val seenWithCurrent = seen ++ ref schema match { case enum0: Schema.Enum[_] if enum0.cases.forall(_.schema.isInstanceOf[CaseClass0[_]]) => - JsonSchemas(fromZSchema(enum0, SchemaStyle.Inline), ref, Map.empty) + JsonSchemas(fromZSchema(enum0, refType.inline), ref, Map.empty) case enum0: Schema.Enum[_] => JsonSchemas( - fromZSchema(enum0, SchemaStyle.Inline), + fromZSchema(enum0, refType.inline), ref, enum0.cases .filterNot(_.annotations.exists(_.isInstanceOf[transientCase])) .flatMap { c => val key = nominal(c.schema, refType) - .orElse(nominal(c.schema, SchemaStyle.Compact)) - val nested = fromZSchemaMulti( + .orElse(nominal(c.schema, refType.inline)) + val nested = fromZSchemaMultiple( c.schema, refType, seenWithCurrent, @@ -454,7 +535,7 @@ object JsonSchema { val children = record.fields .filterNot(_.annotations.exists(_.isInstanceOf[transientField])) .flatMap { field => - val nested = fromZSchemaMulti( + val nested = fromZSchemaMultiple( field.annotations.foldLeft(field.schema)((schema, annotation) => schema.annotate(annotation)), refType, seenWithCurrent, @@ -462,7 +543,7 @@ object JsonSchema { nested.rootRef.fold(ifEmpty = nested.children)(k => nested.children + (k -> nested.root)) } .toMap - JsonSchemas(fromZSchema(record, SchemaStyle.Inline), ref, children) + JsonSchemas(fromZSchema(record, refType.inline), ref, children) case collection: Schema.Collection[_, _] => collection match { case Schema.Sequence(elementSchema, _, _, _, _) => @@ -484,32 +565,32 @@ object JsonSchema { arraySchemaMulti(refType, ref, elementSchema, seenWithCurrent) } case Schema.Transform(schema, _, _, _, _) => - fromZSchemaMulti(schema, refType, seen) + fromZSchemaMultiple(schema, refType, seen) case Schema.Primitive(_, _) => - JsonSchemas(fromZSchema(schema, SchemaStyle.Inline), ref, Map.empty) + JsonSchemas(fromZSchema(schema, refType.inline), ref, Map.empty) case Schema.Optional(schema, _) => - fromZSchemaMulti(schema, refType, seenWithCurrent) + fromZSchemaMultiple(schema, refType, seenWithCurrent) case Schema.Fail(_, _) => throw new IllegalArgumentException("Fail schema is not supported.") case Schema.Tuple2(left, right, _) => - val leftSchema = fromZSchemaMulti(left, refType, seenWithCurrent) - val rightSchema = fromZSchemaMulti(right, refType, seenWithCurrent) + val leftSchema = fromZSchemaMultiple(left, refType, seenWithCurrent) + val rightSchema = fromZSchemaMultiple(right, refType, seenWithCurrent) JsonSchemas( AllOfSchema(Chunk(leftSchema.root, rightSchema.root)), ref, leftSchema.children ++ rightSchema.children, ) case Schema.Either(left, right, _) => - val leftSchema = fromZSchemaMulti(left, refType, seenWithCurrent) - val rightSchema = fromZSchemaMulti(right, refType, seenWithCurrent) + val leftSchema = fromZSchemaMultiple(left, refType, seenWithCurrent) + val rightSchema = fromZSchemaMultiple(right, refType, seenWithCurrent) JsonSchemas( OneOfSchema(Chunk(leftSchema.root, rightSchema.root)), ref, leftSchema.children ++ rightSchema.children, ) case Schema.Fallback(left, right, fullDecode, _) => - val leftSchema = fromZSchemaMulti(left, refType, seenWithCurrent) - val rightSchema = fromZSchemaMulti(right, refType, seenWithCurrent) + val leftSchema = fromZSchemaMultiple(left, refType, seenWithCurrent) + val rightSchema = fromZSchemaMultiple(right, refType, seenWithCurrent) val candidates = if (fullDecode) Chunk( @@ -529,7 +610,7 @@ object JsonSchema { leftSchema.children ++ rightSchema.children, ) case Schema.Lazy(schema0) => - fromZSchemaMulti(schema0(), refType, seen) + fromZSchemaMultiple(schema0(), refType, seen) case Schema.Dynamic(_) => JsonSchemas(AnyJson, None, Map.empty) } @@ -537,13 +618,13 @@ object JsonSchema { } private def mapSchema[K, V]( - refType: SchemaStyle, + refType: SchemaRef, ref: Option[java.lang.String], seenWithCurrent: Set[java.lang.String], keySchema: Schema[K], valueSchema: Schema[V], ) = { - val nested = fromZSchemaMulti(valueSchema, refType, seenWithCurrent) + val nested = fromZSchemaMultiple(valueSchema, refType, seenWithCurrent) val mapObjectSchema = annotateMapSchemaWithKeysSchema(nested.root, keySchema) if (valueSchema.isInstanceOf[Schema.Primitive[_]]) { @@ -562,14 +643,14 @@ object JsonSchema { } private def arraySchemaMulti( - refType: SchemaStyle, + refType: SchemaRef, ref: Option[java.lang.String], elementSchema: Schema[_], seen: Set[java.lang.String], minItems: Option[Int] = None, uniqueItems: Boolean = false, ): JsonSchemas = { - val nested = fromZSchemaMulti(elementSchema, refType, seen) + val nested = fromZSchemaMultiple(elementSchema, refType, seen) if (elementSchema.isInstanceOf[Schema.Primitive[_]]) { JsonSchemas( JsonSchema.ArrayType(Some(nested.root), minItems, uniqueItems), @@ -589,7 +670,7 @@ object JsonSchema { keySchema match { case Schema.Primitive(StandardType.StringType, annotations) if annotations.isEmpty => None case nonSimple => - fromZSchema(nonSimple) match { + fromZSchema(nonSimple, SchemaRef(SchemaSpec.OpenAPI, SchemaStyle.Inline)) 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 @@ -612,39 +693,46 @@ object JsonSchema { private def jsonSchemaFromAnyMapSchema[K, V]( keySchema: Schema[K], valueSchema: Schema[V], - refType: SchemaStyle, + refType: SchemaRef, ): JsonSchema.Object = { val valuesSchema = fromZSchema(valueSchema, refType) annotateMapSchemaWithKeysSchema(valuesSchema, keySchema) } + @deprecated("use SchemaRef instead of SchemaStyle", "3.8") def fromZSchema(schema: Schema[_], refType: SchemaStyle = SchemaStyle.Inline): JsonSchema = + fromZSchema(schema, SchemaRef(SchemaSpec.OpenAPI, refType)) + + def fromZSchema(schema: Schema[_], refType: SchemaRef): JsonSchema = schema match { - case enum0: Schema.Enum[_] if refType != SchemaStyle.Inline && nominal(enum0).isDefined => + case enum0: Schema.Enum[_] + if refType.style != SchemaStyle.Inline && nominal(enum0, refType.reference).isDefined => JsonSchema.RefSchema(nominal(enum0, refType).get) - case enum0: Schema.Enum[_] if enum0.cases.forall(_.schema.isInstanceOf[CaseClass0[_]]) => + case enum0: Schema.Enum[_] if enum0.cases.forall(_.schema.isInstanceOf[CaseClass0[_]]) => JsonSchema.Enum( enum0.cases.map(c => EnumValue.Str(c.annotations.collectFirst { case caseName(name) => name }.getOrElse(c.id)), ), ) - case enum0: Schema.Enum[_] => + case enum0: Schema.Enum[_] => val noDiscriminator = enum0.annotations.exists(_.isInstanceOf[noDiscriminator]) val discriminatorName0 = enum0.annotations.collectFirst { case discriminatorName(name) => name } val nonTransientCases = enum0.cases.filterNot(_.annotations.exists(_.isInstanceOf[transientCase])) if (noDiscriminator) { JsonSchema - .OneOfSchema(nonTransientCases.map(c => fromZSchema(c.schema, SchemaStyle.Compact))) + .OneOfSchema(nonTransientCases.map(c => fromZSchema(c.schema, refType.compact))) } else if (discriminatorName0.isDefined) { JsonSchema - .OneOfSchema(nonTransientCases.map(c => fromZSchema(c.schema, SchemaStyle.Compact))) + .OneOfSchema(nonTransientCases.map(c => fromZSchema(c.schema, refType.compact))) .discriminator( OpenAPI.Discriminator( propertyName = discriminatorName0.get, mapping = nonTransientCases.map { c => val name = c.annotations.collectFirst { case caseName(name) => name }.getOrElse(c.id) - name -> nominal(c.schema, refType).orElse(nominal(c.schema, SchemaStyle.Compact)).get + name -> nominal(c.schema, refType) + .orElse(nominal(c.schema, refType.compact)) + .get }.toMap, ), ) @@ -652,12 +740,17 @@ object JsonSchema { JsonSchema .OneOfSchema(nonTransientCases.map { c => val name = c.annotations.collectFirst { case caseName(name) => name }.getOrElse(c.id) - Object(Map(name -> fromZSchema(c.schema, SchemaStyle.Compact)), Left(false), Chunk(name)) + Object( + Map(name -> fromZSchema(c.schema, refType.compact)), + Left(false), + Chunk(name), + ) }) } - case record: Schema.Record[_] if refType != SchemaStyle.Inline && nominal(record).isDefined => + case record: Schema.Record[_] + if refType.style != SchemaStyle.Inline && nominal(record, refType.reference).isDefined => JsonSchema.RefSchema(nominal(record, refType).get) - case record: Schema.Record[_] => + case record: Schema.Record[_] => val additionalProperties = if (record.annotations.exists(_.isInstanceOf[rejectExtraFields])) { Left(false) @@ -676,7 +769,7 @@ object JsonSchema { field.name -> fromZSchema( field.annotations.foldLeft(field.schema)((schema, annotation) => schema.annotate(annotation)), - SchemaStyle.Compact, + refType.compact, ) .deprecated(deprecated(field.schema)) .description(fieldDoc(field)) @@ -691,7 +784,7 @@ object JsonSchema { ) .deprecated(deprecated(record)) .description(descriptionFromAnnotations(record.annotations)) - case collection: Schema.Collection[_, _] => + case collection: Schema.Collection[_, _] => collection match { case Schema.Sequence(elementSchema, _, _, _, _) => JsonSchema.ArrayType(Some(fromZSchema(elementSchema, refType)), None, uniqueItems = false) @@ -708,9 +801,9 @@ object JsonSchema { case Schema.Set(elementSchema, _) => JsonSchema.ArrayType(Some(fromZSchema(elementSchema, refType)), None, uniqueItems = true) } - case Schema.Transform(schema, _, _, _, _) => + case Schema.Transform(schema, _, _, _, _) => fromZSchema(schema, refType) - case Schema.Primitive(standardType, annotations) => + case Schema.Primitive(standardType, annotations) => standardType match { case StandardType.UnitType => JsonSchema.Null case StandardType.StringType => @@ -814,16 +907,14 @@ object JsonSchema { case object Inline extends SchemaStyle /** - * Generates references to json schemas under #/components/schemas/{schema} - * and uses the full package path to help to generate unique schema names. + * Generates unique schema names using the full package path * @see * SchemaStyle.Compact for compact schema names. */ case object Reference extends SchemaStyle /** - * Generates references to json schemas under #/components/schemas/{schema} - * and uses the type name to help to generate schema names. + * Generates schema names using the type name * @see * SchemaStyle.Reference for full package path schema names to avoid name * collisions. @@ -831,6 +922,26 @@ object JsonSchema { case object Compact extends SchemaStyle } + sealed trait SchemaSpec extends Product with Serializable { private[openapi] def path: java.lang.String } + object SchemaSpec { + + /** + * Generates schema references for an Open API document + */ + case object OpenAPI extends SchemaSpec { override def path: java.lang.String = "#/components/schemas/" } + + /** + * Generates schema references for an JSON Schema document + */ + case object JsonSchema extends SchemaSpec { override def path: java.lang.String = "#/$defs/" } + } + + final case class SchemaRef(spec: SchemaSpec, style: SchemaStyle) { + def inline: SchemaRef = copy(style = SchemaStyle.Inline) + def reference: SchemaRef = copy(style = SchemaStyle.Reference) + def compact: SchemaRef = copy(style = SchemaStyle.Compact) + } + private def deprecated(schema: Schema[_]): Boolean = schema.annotations.exists(_.isInstanceOf[scala.deprecated]) @@ -848,7 +959,10 @@ object JsonSchema { .map(toJsonAst(schema.schema, _)) @tailrec - private def nominal(schema: Schema[_], referenceType: SchemaStyle = SchemaStyle.Reference): Option[java.lang.String] = + private def nominal( + schema: Schema[_], + referenceType: SchemaRef, + ): Option[java.lang.String] = schema match { case enumSchema: Schema.Enum[_] => refForTypeId(enumSchema.id, referenceType) case record: Schema.Record[_] => refForTypeId(record.id, referenceType) @@ -857,13 +971,13 @@ object JsonSchema { case _ => None } - private def refForTypeId(id: TypeId, referenceType: SchemaStyle): Option[java.lang.String] = + private def refForTypeId(id: TypeId, referenceType: SchemaRef): Option[java.lang.String] = id match { - case nominal: TypeId.Nominal if referenceType == SchemaStyle.Reference => - Some(s"#/components/schemas/${nominal.fullyQualified.replace(".", "_")}") - case nominal: TypeId.Nominal if referenceType == SchemaStyle.Compact => - Some(s"#/components/schemas/${nominal.typeName}") - case _ => + case nominal: TypeId.Nominal if referenceType.style == SchemaStyle.Reference => + Some(s"${referenceType.spec.path}${nominal.fullyQualified.replace(".", "_")}") + case nominal: TypeId.Nominal if referenceType.style == SchemaStyle.Compact => + Some(s"${referenceType.spec.path}${nominal.typeName}") + case _ => None } diff --git a/zio-http/shared/src/main/scala/zio/http/endpoint/openapi/OpenAPIGen.scala b/zio-http/shared/src/main/scala/zio/http/endpoint/openapi/OpenAPIGen.scala index e6906d3d6a..507e373927 100644 --- a/zio-http/shared/src/main/scala/zio/http/endpoint/openapi/OpenAPIGen.scala +++ b/zio-http/shared/src/main/scala/zio/http/endpoint/openapi/OpenAPIGen.scala @@ -18,7 +18,7 @@ import zio.http.codec.HttpCodec.Metadata import zio.http.codec.HttpCodecType.Content import zio.http.codec._ import zio.http.endpoint._ -import zio.http.endpoint.openapi.JsonSchema.SchemaStyle +import zio.http.endpoint.openapi.JsonSchema.{SchemaRef, SchemaSpec, SchemaStyle} import zio.http.endpoint.openapi.OpenAPI.SecurityScheme.SecurityRequirement import zio.http.endpoint.openapi.OpenAPI.{Key, Path, PathItem, ReferenceOr, SecurityScheme} import zio.http.internal.StringSchemaCodec @@ -338,6 +338,7 @@ object OpenAPIGen { wrapInObject: Boolean = false, omitDescription: Boolean = false, )(mediaType: MediaType): JsonSchema = { + val refType = SchemaRef(SchemaSpec.OpenAPI, referenceType) val descriptionFromMeta = if (omitDescription) None else description(metadata) codec match { case atom: HttpCodec.Atom[_, _] => @@ -347,7 +348,7 @@ object OpenAPIGen { findName(metadata).orElse(maybeName).getOrElse(throw new Exception("Multipart content without name")) JsonSchema.obj( name -> JsonSchema - .fromZSchema(codec.lookup(mediaType).map(_._2.schema).getOrElse(codec.defaultSchema), referenceType) + .fromZSchema(codec.lookup(mediaType).map(_._2.schema).getOrElse(codec.defaultSchema), refType) .description(descriptionFromMeta) .deprecated(deprecated(metadata)) .nullable(optional(metadata)), @@ -361,7 +362,7 @@ object OpenAPIGen { findName(metadata).orElse(maybeName).getOrElse(throw new Exception("Multipart content without name")) JsonSchema.obj( name -> JsonSchema - .fromZSchema(codec.lookup(mediaType).map(_._2.schema).getOrElse(codec.defaultSchema), referenceType) + .fromZSchema(codec.lookup(mediaType).map(_._2.schema).getOrElse(codec.defaultSchema), refType) .description(descriptionFromMeta) .deprecated(deprecated(metadata)) .nullable(optional(metadata)) @@ -379,7 +380,7 @@ object OpenAPIGen { JsonSchema .fromZSchema( codec.lookup(mediaType).map(_._2.schema).getOrElse(codec.defaultSchema), - referenceType, + refType, ), ), None, @@ -391,7 +392,7 @@ object OpenAPIGen { ) case HttpCodec.Content(codec, _, _) => JsonSchema - .fromZSchema(codec.lookup(mediaType).map(_._2.schema).getOrElse(codec.defaultSchema), referenceType) + .fromZSchema(codec.lookup(mediaType).map(_._2.schema).getOrElse(codec.defaultSchema), refType) .description(descriptionFromMeta) .deprecated(deprecated(metadata)) .nullable(optional(metadata)) @@ -400,7 +401,7 @@ object OpenAPIGen { .ArrayType( Some( JsonSchema - .fromZSchema(codec.lookup(mediaType).map(_._2.schema).getOrElse(codec.defaultSchema), referenceType), + .fromZSchema(codec.lookup(mediaType).map(_._2.schema).getOrElse(codec.defaultSchema), refType), ), None, uniqueItems = false, @@ -642,18 +643,19 @@ object OpenAPIGen { def gen( endpoint: Endpoint[_, _, _, _, _], - referenceType: SchemaStyle = SchemaStyle.Compact, + referenceStyle: SchemaStyle = SchemaStyle.Compact, genExamples: Boolean, ): OpenAPI = { - val inAtoms = AtomizedMetaCodecs.flatten(endpoint.input) + val referenceType = SchemaRef(SchemaSpec.OpenAPI, referenceStyle) + val inAtoms = AtomizedMetaCodecs.flatten(endpoint.input) val outs: Map[OpenAPI.StatusOrDefault, Map[MediaType, (JsonSchema, AtomizedMetaCodecs)]] = schemaByStatusAndMediaType( endpoint.output.alternatives.map(_._1) ++ endpoint.error.alternatives.map(_._1), - referenceType, + referenceStyle, omitContentDescription = true, ) // there is no status for inputs. So we just take the first one (default) - val ins = schemaByStatusAndMediaType(endpoint.input.alternatives.map(_._1), referenceType).values.headOption + val ins = schemaByStatusAndMediaType(endpoint.input.alternatives.map(_._1), referenceStyle).values.headOption def path: Map[Path, PathItem] = { val path = buildPath(endpoint.input) @@ -789,7 +791,11 @@ object OpenAPIGen { OpenAPI.Parameter.queryParameter( name = field.name, description = docs, - schema = Some(OpenAPI.ReferenceOr.Or(JsonSchema.fromZSchema(codec.schema))), + schema = Some( + OpenAPI.ReferenceOr.Or( + JsonSchema.fromZSchema(codec.schema, SchemaRef(SchemaSpec.OpenAPI, SchemaStyle.Inline)), + ), + ), deprecated = mc.deprecated, style = OpenAPI.Parameter.Style.Form, explode = false, @@ -905,24 +911,24 @@ object OpenAPIGen { ++ endpoint.output.alternatives.map(_._1).map(AtomizedMetaCodecs.flatten(_)).flatMap(_.content)).collect { case MetaCodec(HttpCodec.Content(codec, _, _), _) if jsonSchemaFromCodec(codec).isDefined && - nominal(jsonSchemaFromCodec(codec).get, referenceType).isDefined => - val schemas = JsonSchema.fromZSchemaMulti(jsonSchemaFromCodec(codec).get, referenceType) + nominal(jsonSchemaFromCodec(codec).get, referenceStyle).isDefined => + val schemas = JsonSchema.fromZSchemaMultiple(jsonSchemaFromCodec(codec).get, referenceType) schemas.children.map { case (key, schema) => - OpenAPI.Key.fromString(key.replace("#/components/schemas/", "")).get -> OpenAPI.ReferenceOr.Or(schema) - } + (OpenAPI.Key.fromString(nominal(jsonSchemaFromCodec(codec).get, referenceType).get).get -> + OpenAPI.Key.fromString(key.replace(referenceType.spec.path, "")).get -> OpenAPI.ReferenceOr.Or(schema) + } + (OpenAPI.Key.fromString(nominal(jsonSchemaFromCodec(codec).get, referenceStyle).get).get -> OpenAPI.ReferenceOr.Or(schemas.root.discriminator(genDiscriminator(jsonSchemaFromCodec(codec).get)))) case MetaCodec(HttpCodec.Content(setCodec, _, _), _) if jsonSchemaFromCodec(setCodec).isDefined && jsonSchemaFromCodec(setCodec).get.isInstanceOf[Schema.Set[_]] && nominal( jsonSchemaFromCodec(setCodec).get.asInstanceOf[Schema.Set[_]].elementSchema, - referenceType, + referenceStyle, ).isDefined => val schema = jsonSchemaFromCodec(setCodec).get.asInstanceOf[Schema.Set[_]].elementSchema - val schemas = JsonSchema.fromZSchemaMulti(schema, referenceType) + val schemas = JsonSchema.fromZSchemaMultiple(schema, referenceType) schemas.children.map { case (key, schema) => - OpenAPI.Key.fromString(key.replace("#/components/schemas/", "")).get -> OpenAPI.ReferenceOr.Or(schema) - } + (OpenAPI.Key.fromString(nominal(schema, referenceType).get).get -> + OpenAPI.Key.fromString(key.replace(referenceType.spec.path, "")).get -> OpenAPI.ReferenceOr.Or(schema) + } + (OpenAPI.Key.fromString(nominal(schema, referenceStyle).get).get -> OpenAPI.ReferenceOr.Or(schemas.root.discriminator(genDiscriminator(schema)))) case MetaCodec(HttpCodec.Content(seqCodec, _, _), _) @@ -930,13 +936,13 @@ object OpenAPIGen { .isInstanceOf[Schema.Sequence[_, _, _]] && nominal( jsonSchemaFromCodec(seqCodec).get.asInstanceOf[Schema.Sequence[_, _, _]].elementSchema, - referenceType, + referenceStyle, ).isDefined => val schema = jsonSchemaFromCodec(seqCodec).get.asInstanceOf[Schema.Sequence[_, _, _]].elementSchema - val schemas = JsonSchema.fromZSchemaMulti(schema, referenceType) + val schemas = JsonSchema.fromZSchemaMultiple(schema, referenceType) schemas.children.map { case (key, schema) => - OpenAPI.Key.fromString(key.replace("#/components/schemas/", "")).get -> OpenAPI.ReferenceOr.Or(schema) - } + (OpenAPI.Key.fromString(nominal(schema, referenceType).get).get -> + OpenAPI.Key.fromString(key.replace(referenceType.spec.path, "")).get -> OpenAPI.ReferenceOr.Or(schema) + } + (OpenAPI.Key.fromString(nominal(schema, referenceStyle).get).get -> OpenAPI.ReferenceOr.Or(schemas.root.discriminator(genDiscriminator(schema)))) case MetaCodec(HttpCodec.Content(mapCodec, _, _), _) @@ -944,24 +950,24 @@ object OpenAPIGen { .isInstanceOf[Schema.Map[_, _]] && nominal( jsonSchemaFromCodec(mapCodec).get.asInstanceOf[Schema.Map[_, _]].valueSchema, - referenceType, + referenceStyle, ).isDefined => val schema = jsonSchemaFromCodec(mapCodec).get.asInstanceOf[Schema.Map[_, _]].valueSchema - val schemas = JsonSchema.fromZSchemaMulti(schema, referenceType) + val schemas = JsonSchema.fromZSchemaMultiple(schema, referenceType) schemas.children.map { case (key, schema) => - OpenAPI.Key.fromString(key.replace("#/components/schemas/", "")).get -> OpenAPI.ReferenceOr.Or(schema) - } + (OpenAPI.Key.fromString(nominal(schema, referenceType).get).get -> + OpenAPI.Key.fromString(key.replace(referenceType.spec.path, "")).get -> OpenAPI.ReferenceOr.Or(schema) + } + (OpenAPI.Key.fromString(nominal(schema, referenceStyle).get).get -> OpenAPI.ReferenceOr.Or(schemas.root.discriminator(genDiscriminator(schema)))) case MetaCodec(HttpCodec.ContentStream(codec, _, _), _) if jsonSchemaFromCodec(codec).isDefined && nominal( jsonSchemaFromCodec(codec).get, - referenceType, + referenceStyle, ).isDefined => - val schemas = JsonSchema.fromZSchemaMulti(jsonSchemaFromCodec(codec).get, referenceType) + val schemas = JsonSchema.fromZSchemaMultiple(jsonSchemaFromCodec(codec).get, referenceType) schemas.children.map { case (key, schema) => - OpenAPI.Key.fromString(key.replace("#/components/schemas/", "")).get -> OpenAPI.ReferenceOr.Or(schema) - } + (OpenAPI.Key.fromString(nominal(jsonSchemaFromCodec(codec).get, referenceType).get).get -> + OpenAPI.Key.fromString(key.replace(referenceType.spec.path, "")).get -> OpenAPI.ReferenceOr.Or(schema) + } + (OpenAPI.Key.fromString(nominal(jsonSchemaFromCodec(codec).get, referenceStyle).get).get -> OpenAPI.ReferenceOr.Or(schemas.root.discriminator(genDiscriminator(jsonSchemaFromCodec(codec).get)))) }.flatten.toMap @@ -1154,7 +1160,7 @@ object OpenAPIGen { ) } - private def headersFrom(codec: AtomizedMetaCodecs) = { + private def headersFrom(codec: AtomizedMetaCodecs) = { codec.header.map { case mc @ MetaCodec(codec, _) => // todo use all headers codec.headerType.names.head -> OpenAPI.ReferenceOr.Or( @@ -1168,10 +1174,10 @@ object OpenAPIGen { ) }.toMap } - private def schemaReferencePath(nominal: TypeId.Nominal, referenceType: SchemaStyle): String = { - referenceType match { - case SchemaStyle.Compact => s"#/components/schemas/${nominal.typeName}" - case _ => s"#/components/schemas/${nominal.fullyQualified.replace(".", "_")}}" + private def schemaReferencePath(nominal: TypeId.Nominal, referenceType: SchemaRef): String = { + referenceType.style match { + case SchemaStyle.Compact => s"${referenceType.spec.path}${nominal.typeName}" + case _ => s"${referenceType.spec.path}${nominal.fullyQualified.replace(".", "_")}}" } } } 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 cf3271b317..a82ed8f858 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 @@ -7,9 +7,10 @@ import scala.collection.immutable.ListMap import zio.json.ast.Json import zio.test._ -import zio.schema.Schema +import zio.schema.annotation.discriminatorName +import zio.schema.{DeriveSchema, Schema} -import zio.http.endpoint.openapi.JsonSchema.SchemaStyle +import zio.http.endpoint.openapi.JsonSchema.{SchemaRef, SchemaSpec, SchemaStyle} import zio.http.endpoint.openapi.OpenAPI.ReferenceOr import zio.http.endpoint.openapi.OpenAPI.SecurityScheme._ @@ -21,6 +22,15 @@ object OpenAPISpec extends ZIOSpecDefault { def toJsonAst(api: OpenAPI): Json = toJsonAst(api.toJson) + @discriminatorName("type") sealed trait SealedTrait + object SealedTrait { + case class One(set: Set[String]) extends SealedTrait + case class Two(list: List[String]) extends SealedTrait + } + + case class SchemaTest(number: Int, string: String, child: Option[SealedTrait]) + implicit val schemaTestSchema: Schema[SchemaTest] = DeriveSchema.gen[SchemaTest] + val spec = suite("OpenAPISpec")( test("auth schema serialization") { import OpenAPI._ @@ -66,7 +76,8 @@ object OpenAPISpec extends ZIOSpecDefault { }, test("JsonSchema.fromZSchemaMulti correctly handles Map schema with List as Value") { val schema = Schema.map[String, List[String]] - val sch: JsonSchemas = JsonSchema.fromZSchemaMulti(schema, SchemaStyle.Reference) + val sch: JsonSchemas = + JsonSchema.fromZSchemaMultiple(schema, SchemaRef(SchemaSpec.OpenAPI, SchemaStyle.Reference)) val isSchemaProperlyGenerated = if (sch.root.isCollection) sch.root match { case JsonSchema.Object(_, additionalProperties, _) => @@ -82,7 +93,7 @@ object OpenAPISpec extends ZIOSpecDefault { }, test("JsonSchema.fromZSchema correctly handles Map with non-simple string keys") { val schema = Schema.map[UUID, String] - val js = JsonSchema.fromZSchema(schema) + val js = JsonSchema.fromZSchema(schema, SchemaRef(SchemaSpec.OpenAPI, SchemaStyle.Inline)) val oapi = OpenAPI.empty.copy( components = Some(OpenAPI.Components(schemas = ListMap(OpenAPI.Key.fromString("IdToName").get -> ReferenceOr.Or(js)))), @@ -117,5 +128,85 @@ object OpenAPISpec extends ZIOSpecDefault { |}""".stripMargin assertTrue(toJsonAst(json) == toJsonAst(expected)) }, + test("JsonSchema.jsonSchema correctly generate valid Json Schema with $defs and associated $ref") { + val jsonSchema = JsonSchema.jsonSchema(schemaTestSchema) + val json = jsonSchema.toJsonPretty + val expected = f"""{"$$schema" : "https://json-schema.org/draft/2020-12/schema",""" + + """ "type" : "object", + | "properties" : { + | "number" : { + | "type" : "integer", + | "format" : "int32" + | }, + | "string" : { + | "type" : "string" + | }, + | "child" : { + | "anyOf" : [ + | { + | "type" : "null" + | }, + | { + | "$ref" : "#/$defs/SealedTrait" + | } + | ] + | } + | }, + | "required" : [ + | "number", + | "string" + | ], + | "$defs" : { + | "One" : { + | "type" : "object", + | "properties" : { + | "set" : { + | "type" : "array", + | "items" : { + | "type" : "string" + | }, + | "uniqueItems" : true + | } + | }, + | "required" : [ + | "set" + | ] + | }, + | "Two" : { + | "type" : "object", + | "properties" : { + | "list" : { + | "type" : "array", + | "items" : { + | "type" : "string" + | } + | } + | }, + | "required" : [ + | "list" + | ] + | }, + | "SealedTrait" : { + | "oneOf" : [ + | { + | "$ref" : "#/$defs/One" + | }, + | { + | "$ref" : "#/$defs/Two" + | } + | ], + | "discriminator" : { + | "propertyName" : "type", + | "mapping" : { + | "One" : "#/$defs/One", + | "Two" : "#/$defs/Two" + | } + | } + | } + | } + |}""".stripMargin + + assertTrue(toJsonAst(json) == toJsonAst(expected)) + }, ) }