diff --git a/core/src/main/scala-2/sttp/tapir/internal/OneOfMacro.scala b/core/src/main/scala-2/sttp/tapir/internal/OneOfMacro.scala index 2b9585883a..dad3a0c1c1 100644 --- a/core/src/main/scala-2/sttp/tapir/internal/OneOfMacro.scala +++ b/core/src/main/scala-2/sttp/tapir/internal/OneOfMacro.scala @@ -51,7 +51,7 @@ private[tapir] object OneOfMacro { val discriminatorName = _root_.sttp.tapir.FieldName($name, $conf.toEncodedName($name)) // cannot use .collect because of a bug in ScalaJS (Trying to access the this of another class ... during phase: jscode) val discriminatorMapping = mappingAsMap.toList.flatMap { - case (k, _root_.sttp.tapir.Schema(_, _root_.scala.Some(fname), _, _, _, _, _, _, _, _, _)) => _root_.scala.collection.immutable.List($asString.apply(k) -> _root_.sttp.tapir.SchemaType.SRef(fname)) + case (k, _root_.sttp.tapir.Schema(_, _root_.scala.Some(fname), _, _, _, _, _, _, _, _, _, _)) => _root_.scala.collection.immutable.List($asString.apply(k) -> _root_.sttp.tapir.SchemaType.SRef(fname)) case _ => _root_.scala.Nil } .toMap diff --git a/core/src/main/scala-3/sttp/tapir/macros/SchemaMacros.scala b/core/src/main/scala-3/sttp/tapir/macros/SchemaMacros.scala index 5fbf497154..d36ff0be35 100644 --- a/core/src/main/scala-3/sttp/tapir/macros/SchemaMacros.scala +++ b/core/src/main/scala-3/sttp/tapir/macros/SchemaMacros.scala @@ -230,7 +230,7 @@ private[tapir] object SchemaCompanionMacros { val mappingAsMap = mappingAsList.toMap val discriminatorName = _root_.sttp.tapir.FieldName(${ Expr(functionName) }, $conf.toEncodedName(${ Expr(functionName) })) - val discriminatorMapping = mappingAsMap.collect { case (k, sf @ Schema(_, Some(fname), _, _, _, _, _, _, _, _, _)) => + val discriminatorMapping = mappingAsMap.collect { case (k, sf @ Schema(_, Some(fname), _, _, _, _, _, _, _, _, _, _)) => $asString.apply(k) -> SRef(fname) } diff --git a/core/src/main/scala/sttp/tapir/Schema.scala b/core/src/main/scala/sttp/tapir/Schema.scala index 496b00d255..c1ab5ba506 100644 --- a/core/src/main/scala/sttp/tapir/Schema.scala +++ b/core/src/main/scala/sttp/tapir/Schema.scala @@ -39,15 +39,94 @@ case class Schema[T]( deprecated: Boolean = false, hidden: Boolean = false, validator: Validator[T] = Validator.pass[T], - attributes: AttributeMap = AttributeMap.Empty + attributes: AttributeMap = AttributeMap.Empty, + // The const value together with the value encoded to a raw format, which will then be directly rendered in the + // documentation. This is needed as codecs for nested types aren't available. Similar to Validator.EncodeToRaw + const: Option[(T, Option[Any])] = None ) extends SchemaMacros[T] { - def map[TT](f: T => Option[TT])(g: TT => T): Schema[TT] = copy( + // required for binary compatibility + def this( + schemaType: SchemaType[T], + name: Option[SName], + isOptional: Boolean, + description: Option[String], + default: Option[(T, Option[Any])], + format: Option[String], + encodedExample: Option[Any], + deprecated: Boolean, + hidden: Boolean, + validator: Validator[T], + attributes: AttributeMap + ) = + this(schemaType, name, isOptional, description, default, format, encodedExample, deprecated, hidden, validator, attributes, None) + +// def copy( +// schemaType: SchemaType[T] = this.schemaType, +// name: Option[SName] = this.name, +// isOptional: Boolean = this.isOptional, +// description: Option[String] = this.description, +// default: Option[(T, Option[Any])] = this.default, +// format: Option[String] = this.format, +// encodedExample: Option[Any] = this.encodedExample, +// deprecated: Boolean = this.deprecated, +// hidden: Boolean = this.hidden, +// validator: Validator[T] = this.validator, +// attributes: AttributeMap = this.attributes, +// const: Option[(T, Option[Any])] = this.const +// ): Schema[T] = new Schema( +// schemaType, +// name, +// isOptional, +// description, +// default, +// format, +// encodedExample, +// deprecated, +// hidden, +// validator, +// attributes, +// const +// ) + +// // required for binary compatibility +// def copy( +// schemaType: SchemaType[T], +// name: Option[SName], +// isOptional: Boolean, +// description: Option[String], +// default: Option[(T, Option[Any])], +// format: Option[String], +// encodedExample: Option[Any], +// deprecated: Boolean, +// hidden: Boolean, +// validator: Validator[T], +// attributes: AttributeMap +// ): Schema[T] = +// new Schema( +// schemaType, +// name, +// isOptional, +// description, +// default, +// format, +// encodedExample, +// deprecated, +// hidden, +// validator, +// attributes, +// this.const +// ) + + def map[TT](f: T => Option[TT])(g: TT => T): Schema[TT] = Schema( schemaType = schemaType.contramap(g), default = default.flatMap { case (t, raw) => f(t).map(tt => (tt, raw)) }, - validator = validator.contramap(g) + validator = validator.contramap(g), + const = const.flatMap { case (t, raw) => + f(t).map(tt => (tt, raw)) + } ) /** Adapt this schema to type `TT`. Only the meta-data is retained, except for default values and the validator (however, product @@ -55,10 +134,11 @@ case class Schema[T]( * - traversing collection elements, product fields, or coproduct subtypes * - validating an instance of type `TT` the top-level type is lost. */ - def as[TT]: Schema[TT] = copy( + def as[TT]: Schema[TT] = Schema( schemaType = schemaType.as[TT], default = None, - validator = Validator.pass + validator = Validator.pass, + const = None ) /** Returns an optional version of this schema, with `isOptional` set to true. */ @@ -257,6 +337,22 @@ case class Schema[T]( } object Schema extends LowPrioritySchema with SchemaCompanionMacros { +// // required for binary compatibility +// def apply[T]( +// schemaType: SchemaType[T], +// name: Option[SName], +// isOptional: Boolean, +// description: Option[String], +// default: Option[(T, Option[Any])], +// format: Option[String], +// encodedExample: Option[Any], +// deprecated: Boolean, +// hidden: Boolean, +// validator: Validator[T], +// attributes: AttributeMap +// ): Schema[T] = +// new Schema(schemaType, name, isOptional, description, default, format, encodedExample, deprecated, hidden, validator, attributes, None) + val ModifyCollectionElements = "each" /** Creates a schema for type `T`, where the low-level representation is a `String`. */ @@ -309,7 +405,7 @@ object Schema extends LowPrioritySchema with SchemaCompanionMacros { implicit def schemaForEither[A, B](implicit sa: Schema[A], sb: Schema[B]): Schema[Either[A, B]] = { Schema[Either[A, B]]( - SchemaType.SCoproduct(List(sa, sb), None) { + SchemaType.SCoproduct[Either[A, B]](List(sa, sb), None) { case Left(v) => Some(SchemaWithValue(sa, v)) case Right(v) => Some(SchemaWithValue(sb, v)) }, diff --git a/core/src/main/scala/sttp/tapir/SchemaType.scala b/core/src/main/scala/sttp/tapir/SchemaType.scala index 06b395ce9e..a43ca1c1df 100644 --- a/core/src/main/scala/sttp/tapir/SchemaType.scala +++ b/core/src/main/scala/sttp/tapir/SchemaType.scala @@ -131,7 +131,7 @@ object SchemaType { ): SCoproduct[T] = { SCoproduct( subtypes.map { - case s @ Schema(st: SchemaType.SProduct[Any @unchecked], _, _, _, _, _, _, _, _, _, _) + case s @ Schema(st: SchemaType.SProduct[Any @unchecked], _, _, _, _, _, _, _, _, _, _, _) if st.fields.forall(_.name != discriminatorName) => s.copy(schemaType = st.copy(fields = st.fields :+ SProductField[Any, D](discriminatorName, discriminatorSchema, _ => None))) case s => s diff --git a/docs/apispec-docs/src/main/scala/sttp/tapir/docs/apispec/schema/TSchemaToASchema.scala b/docs/apispec-docs/src/main/scala/sttp/tapir/docs/apispec/schema/TSchemaToASchema.scala index a8e1c004f2..82429d6eaa 100644 --- a/docs/apispec-docs/src/main/scala/sttp/tapir/docs/apispec/schema/TSchemaToASchema.scala +++ b/docs/apispec-docs/src/main/scala/sttp/tapir/docs/apispec/schema/TSchemaToASchema.scala @@ -4,7 +4,7 @@ import sttp.apispec.{Schema => ASchema, _} import sttp.tapir.Schema.{SName, Title, UniqueItems} import sttp.tapir.Validator.EncodeToRaw import sttp.tapir.docs.apispec.DocsExtensionAttribute.RichSchema -import sttp.tapir.docs.apispec.schema.TSchemaToASchema.{tDefaultToADefault, tExampleToAExample} +import sttp.tapir.docs.apispec.schema.TSchemaToASchema.{tConstToAConst, tDefaultToADefault, tExampleToAExample} import sttp.tapir.docs.apispec.{DocsExtensions, exampleValue} import sttp.tapir.internal._ import sttp.tapir.{Codec, Validator, Schema => TSchema, SchemaType => TSchemaType} @@ -43,7 +43,7 @@ private[docs] class TSchemaToASchema( properties = extractProperties(fields) ) case TSchemaType.SArray(el) => ASchema(SchemaType.Array).copy(items = Some(apply(el, allowReference = true))) - case opt @ TSchemaType.SOption(nested @ TSchema(_, Some(name), _, _, _, _, _, _, _, _, _)) => + case opt @ TSchemaType.SOption(nested @ TSchema(_, Some(name), _, _, _, _, _, _, _, _, _, _)) => // #3288: in case there are multiple different customisations of the nested schema, we need to propagate the // metadata to properly customise the reference. These are also propagated in ToKeyedSchemas when computing // the initial list of schemas. @@ -121,6 +121,7 @@ private[docs] class TSchemaToASchema( oschema.copy( description = tschema.description.orElse(oschema.description), default = tDefaultToADefault(tschema).orElse(oschema.default), + const = tConstToAConst(tschema).orElse(oschema.const), examples = tExampleToAExample(tschema).map(List(_)).orElse(oschema.examples), format = tschema.format.orElse(oschema.format), deprecated = (if (tschema.deprecated) Some(true) else None).orElse(oschema.deprecated), @@ -193,5 +194,10 @@ object TSchemaToASchema { def tDefaultToADefault(schema: TSchema[_]): Option[ExampleValue] = schema.default.flatMap { case (_, raw) => raw.flatMap(r => exampleValue(schema, r)) } + + def tConstToAConst(schema: TSchema[_]): Option[ExampleValue] = schema.const.flatMap { case (_, raw) => + raw.flatMap(r => exampleValue(schema, r)) + } + def tExampleToAExample(schema: TSchema[_]): Option[ExampleValue] = schema.encodedExample.flatMap(exampleValue(schema, _)) } diff --git a/docs/apispec-docs/src/main/scala/sttp/tapir/docs/apispec/schema/ToKeyedSchemas.scala b/docs/apispec-docs/src/main/scala/sttp/tapir/docs/apispec/schema/ToKeyedSchemas.scala index f809fa188f..c37fcb72f9 100644 --- a/docs/apispec-docs/src/main/scala/sttp/tapir/docs/apispec/schema/ToKeyedSchemas.scala +++ b/docs/apispec-docs/src/main/scala/sttp/tapir/docs/apispec/schema/ToKeyedSchemas.scala @@ -9,14 +9,14 @@ private[docs] object ToKeyedSchemas { def apply(schema: TSchema[_]): List[KeyedSchema] = { val thisSchema = SchemaKey(schema).map(_ -> schema).toList val nestedSchemas = schema match { - case TSchema(TSchemaType.SArray(o), _, _, _, _, _, _, _, _, _, _) => apply(o) - case t @ TSchema(o: TSchemaType.SOption[_, _], _, _, _, _, _, _, _, _, _, _) => + case TSchema(TSchemaType.SArray(o), _, _, _, _, _, _, _, _, _, _, _) => apply(o) + case t @ TSchema(o: TSchemaType.SOption[_, _], _, _, _, _, _, _, _, _, _, _, _) => // #1168: if there's an optional field which is an object, with metadata defined (such as description), this // needs to be propagated to the target object, so that it isn't omitted. apply(propagateMetadataForOption(t, o).element) - case TSchema(st: TSchemaType.SProduct[_], _, _, _, _, _, _, _, _, _, _) => productSchemas(st) - case TSchema(st: TSchemaType.SCoproduct[_], _, _, _, _, _, _, _, _, _, _) => coproductSchemas(st) - case TSchema(st: TSchemaType.SOpenProduct[_, _], _, _, _, _, _, _, _, _, _, _) => apply(st.valueSchema) + case TSchema(st: TSchemaType.SProduct[_], _, _, _, _, _, _, _, _, _, _, _) => productSchemas(st) + case TSchema(st: TSchemaType.SCoproduct[_], _, _, _, _, _, _, _, _, _, _, _) => coproductSchemas(st) + case TSchema(st: TSchemaType.SOpenProduct[_, _], _, _, _, _, _, _, _, _, _, _, _) => apply(st.valueSchema) case _ => List.empty } diff --git a/docs/openapi-docs/src/test/resources/example/expected_single_example_with_const.yml b/docs/openapi-docs/src/test/resources/example/expected_single_example_with_const.yml new file mode 100644 index 0000000000..5dce415c42 --- /dev/null +++ b/docs/openapi-docs/src/test/resources/example/expected_single_example_with_const.yml @@ -0,0 +1,26 @@ +openapi: 3.1.0 +info: + title: Entities + version: '1.0' +paths: + /: + get: + operationId: getRoot + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/FruitConst' +components: + schemas: + FruitConst: + title: FruitConst + type: object + required: + - fruitType + properties: + fruitType: + type: string + const: Golden Delicious diff --git a/docs/openapi-docs/src/test/scalajvm/sttp/tapir/docs/openapi/VerifyYamlExampleTest.scala b/docs/openapi-docs/src/test/scalajvm/sttp/tapir/docs/openapi/VerifyYamlExampleTest.scala index 7625797f50..cb7e0a3510 100644 --- a/docs/openapi-docs/src/test/scalajvm/sttp/tapir/docs/openapi/VerifyYamlExampleTest.scala +++ b/docs/openapi-docs/src/test/scalajvm/sttp/tapir/docs/openapi/VerifyYamlExampleTest.scala @@ -3,15 +3,15 @@ package sttp.tapir.docs.openapi import io.circe.generic.auto._ import org.scalatest.funsuite.AnyFunSuite import org.scalatest.matchers.should.Matchers -import sttp.capabilities.Streams import sttp.apispec.openapi.Info import sttp.apispec.openapi.circe.yaml._ +import sttp.capabilities.Streams import sttp.tapir.EndpointIO.Example import sttp.tapir.docs.openapi.dtos.{Author, Book, Country, Genre} import sttp.tapir.generic.Derived import sttp.tapir.generic.auto._ import sttp.tapir.json.circe._ -import sttp.tapir.tests.data.{Entity, Organization, Person} +import sttp.tapir.tests.data.{Entity, FruitConst, Organization, Person} import sttp.tapir.{endpoint, _} import java.nio.ByteBuffer @@ -20,6 +20,19 @@ import java.time.ZonedDateTime class VerifyYamlExampleTest extends AnyFunSuite with Matchers { + test("support const attribute") { + val expectedYaml = load("example/expected_single_example_with_const.yml") + val actualYaml = OpenAPIDocsInterpreter() + .toOpenAPI( + endpoint.get.out(jsonBody[FruitConst]), + Info("Entities", "1.0") + ) + .toYaml + + val actualYamlNoIndent = noIndentation(actualYaml) + actualYamlNoIndent shouldBe expectedYaml + } + test("support example of list and not-list types") { val expectedYaml = load("example/expected_examples_of_list_and_not_list_types.yml") val actualYaml = OpenAPIDocsInterpreter() diff --git a/tests/src/main/scala/sttp/tapir/tests/data/FruitConst.scala b/tests/src/main/scala/sttp/tapir/tests/data/FruitConst.scala new file mode 100644 index 0000000000..341a63a1b0 --- /dev/null +++ b/tests/src/main/scala/sttp/tapir/tests/data/FruitConst.scala @@ -0,0 +1,11 @@ +package sttp.tapir.tests.data + +import sttp.tapir.Schema + +case class FruitConst(fruitType: String) + +object FruitConst { + implicit val schemw: Schema[FruitConst] = Schema + .derived[FruitConst] + .modifyUnsafe[String]("fruitType")(s => s.copy(const = Some(("Golden Delicious", Some("Golden Delicious"))))) +}