diff --git a/core/src/main/scala/sttp/tapir/Schema.scala b/core/src/main/scala/sttp/tapir/Schema.scala index 65ea74e03d..653f236127 100644 --- a/core/src/main/scala/sttp/tapir/Schema.scala +++ b/core/src/main/scala/sttp/tapir/Schema.scala @@ -301,6 +301,8 @@ object Schema extends LowPrioritySchema with SchemaCompanionMacros { implicit def schemaForOption[T: Schema]: Schema[Option[T]] = implicitly[Schema[T]].asOption implicit def schemaForArray[T: Schema]: Schema[Array[T]] = implicitly[Schema[T]].asArray implicit def schemaForIterable[T: Schema, C[X] <: Iterable[X]]: Schema[C[T]] = implicitly[Schema[T]].asIterable[C] + implicit def schemaForSet[T: Schema, C[X] <: scala.collection.Set[X]]: Schema[C[T]] = + schemaForIterable[T, C].attribute(Schema.UniqueItems.Attribute, Schema.UniqueItems(true)) implicit def schemaForPart[T: Schema]: Schema[Part[T]] = implicitly[Schema[T]].map(_ => None)(_.body) implicit def schemaForEither[A, B](implicit sa: Schema[A], sb: Schema[B]): Schema[Either[A, B]] = { @@ -337,6 +339,11 @@ object Schema extends LowPrioritySchema with SchemaCompanionMacros { object Title { val Attribute: AttributeKey[Title] = new AttributeKey[Title]("sttp.tapir.Schema.Title") } + + case class UniqueItems(uniqueItems: Boolean) + object UniqueItems { + val Attribute: AttributeKey[UniqueItems] = new AttributeKey[UniqueItems]("sttp.tapir.Schema.UniqueItems") + } /** @param typeParameterShortNames * full name of type parameters, name is legacy and kept only for backward compatibility 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 2f01c06c92..bfbeedf18d 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 @@ -1,7 +1,7 @@ package sttp.tapir.docs.apispec.schema import sttp.apispec.{Schema => ASchema, _} -import sttp.tapir.Schema.{SName, Title} +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} @@ -84,7 +84,7 @@ private[docs] class TSchemaToASchema( var s = result s = if (nullable) s.copy(nullable = Some(true)) else s s = addMetadata(s, schema) - s = addTitle(s, schema) + s = addAttributes(s, schema) s = addConstraints(s, primitiveValidators, schemaIsWholeNumber) s } else result @@ -97,12 +97,14 @@ private[docs] class TSchemaToASchema( .toListMap } - private def addTitle(oschema: ASchema, tschema: TSchema[_]): ASchema = { - val fromAttr = tschema.attributes.get(Title.Attribute).map(_.value) + private def addAttributes(oschema: ASchema, tschema: TSchema[_]): ASchema = { + val titleFromAttr = tschema.attributes.get(Title.Attribute).map(_.value) // The primary motivation for using schema name as fallback title is to improve Swagger UX with // `oneOf` schemas in OpenAPI 3.1. See https://github.com/softwaremill/tapir/issues/3447 for details. - def fallback = tschema.name.map(fallbackSchemaTitle) - oschema.copy(title = fromAttr orElse fallback) + def fallbackTitle = tschema.name.map(fallbackSchemaTitle) + oschema + .copy(title = titleFromAttr orElse fallbackTitle) + .copy(uniqueItems = tschema.attribute(UniqueItems.Attribute).map(_.uniqueItems)) } private def addMetadata(oschema: ASchema, tschema: TSchema[_]): ASchema = { diff --git a/docs/openapi-docs/src/test/resources/expected_unfolded_array_with_unique_items.yml b/docs/openapi-docs/src/test/resources/expected_unfolded_array_with_unique_items.yml new file mode 100644 index 0000000000..076d346d3f --- /dev/null +++ b/docs/openapi-docs/src/test/resources/expected_unfolded_array_with_unique_items.yml @@ -0,0 +1,38 @@ +openapi: 3.1.0 +info: + title: Entities + version: '1.0' +paths: + /: + get: + operationId: getRoot + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ObjectWithSet' +components: + schemas: + FruitAmount: + title: FruitAmount + required: + - fruit + - amount + type: object + properties: + fruit: + type: string + amount: + type: integer + format: int32 + ObjectWithSet: + title: ObjectWithSet + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/FruitAmount' + uniqueItems: true diff --git a/docs/openapi-docs/src/test/scalajvm/sttp/tapir/docs/openapi/VerifyYamlTest.scala b/docs/openapi-docs/src/test/scalajvm/sttp/tapir/docs/openapi/VerifyYamlTest.scala index 7be16cdb3b..c69c291fbe 100644 --- a/docs/openapi-docs/src/test/scalajvm/sttp/tapir/docs/openapi/VerifyYamlTest.scala +++ b/docs/openapi-docs/src/test/scalajvm/sttp/tapir/docs/openapi/VerifyYamlTest.scala @@ -313,6 +313,15 @@ class VerifyYamlTest extends AnyFunSuite with Matchers { actualYamlNoIndent shouldBe expectedYaml } + test("should add uniqueItems for set-based array schema") { + val expectedYaml = load("expected_unfolded_array_with_unique_items.yml") + + val actualYaml = OpenAPIDocsInterpreter().toOpenAPI(endpoint.out(jsonBody[ObjectWithSet]), Info("Entities", "1.0")).toYaml + val actualYamlNoIndent = noIndentation(actualYaml) + + actualYamlNoIndent shouldBe expectedYaml + } + test("use fixed status code output in response") { val expectedYaml = load("expected_fixed_status_code.yml") diff --git a/docs/openapi-docs/src/test/scalajvm/sttp/tapir/docs/openapi/dtos/VerifyYamlTestData.scala b/docs/openapi-docs/src/test/scalajvm/sttp/tapir/docs/openapi/dtos/VerifyYamlTestData.scala index 7cb306e3f4..b15f817152 100644 --- a/docs/openapi-docs/src/test/scalajvm/sttp/tapir/docs/openapi/dtos/VerifyYamlTestData.scala +++ b/docs/openapi-docs/src/test/scalajvm/sttp/tapir/docs/openapi/dtos/VerifyYamlTestData.scala @@ -8,6 +8,7 @@ object VerifyYamlTestData { case class G[T](data: T) case class ObjectWrapper(value: FruitAmount) case class ObjectWithList(data: List[FruitAmount]) + case class ObjectWithSet(data: Set[FruitAmount]) case class ObjectWithOption(data: Option[FruitAmount]) case class ObjectWithDefaults(@default("foo") name: String, @default(12) count: Int) } diff --git a/project/Versions.scala b/project/Versions.scala index 72d41410e2..243819c46d 100644 --- a/project/Versions.scala +++ b/project/Versions.scala @@ -11,7 +11,7 @@ object Versions { val sttp = "3.9.4" val sttpModel = "1.7.7" val sttpShared = "1.3.17" - val sttpApispec = "0.7.4" + val sttpApispec = "0.8.0" val akkaHttp = "10.2.10" val akkaStreams = "2.6.20" val pekkoHttp = "1.0.1"