diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e746758e..cb2f3d27 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -59,9 +59,13 @@ jobs: run: sbt '++${{ matrix.scala }}' coverage test coverageReport - name: Scala build - if: '!startsWith(matrix.scala, ''2.13'')' + if: '!startsWith(matrix.scala, ''2.13'') && !startsWith(matrix.scala, ''3.0'')' run: sbt '++${{ matrix.scala }}' test + - name: Scala compile + if: startsWith(matrix.scala, '3.0') + run: sbt '++${{ matrix.scala }}' compile + - name: Publish to Codecov.io if: startsWith(matrix.scala, '2.13') uses: codecov/codecov-action@v2 diff --git a/build.sbt b/build.sbt index 698a5c0e..66d4c54f 100644 --- a/build.sbt +++ b/build.sbt @@ -52,6 +52,12 @@ libraryDependencies ++= Seq( "org.scalatest" %% "scalatest" % "3.2.11" % Test, "org.slf4j" % "slf4j-simple" % "1.7.36" % Test ) +libraryDependencies ++= { + CrossVersion.partialVersion(Keys.scalaVersion.value) match { + case Some((3, _)) => Seq() + case _ => Seq("org.scala-lang" % "scala-reflect" % scalaVersion.value) + } +} homepage := Some(new URL("https://github.com/swagger-akka-http/swagger-scala-module")) @@ -63,10 +69,10 @@ licenses := Seq(("Apache License 2.0", new URL("http://www.apache.org/licenses/L pomExtra := { pomExtra.value ++ Group( - - github - https://github.com/swagger-api/swagger-scala-module/issues - + + github + https://github.com/swagger-api/swagger-scala-module/issues + fehguy @@ -84,7 +90,8 @@ pomExtra := { ThisBuild / githubWorkflowBuild := Seq( WorkflowStep.Sbt(List("coverage", "test", "coverageReport"), name = Some("Scala 2.13 build"), cond = Some("startsWith(matrix.scala, '2.13')")), - WorkflowStep.Sbt(List("test"), name = Some("Scala build"), cond = Some("!startsWith(matrix.scala, '2.13')")), + WorkflowStep.Sbt(List("test"), name = Some("Scala build"), cond = Some("!startsWith(matrix.scala, '2.13') && !startsWith(matrix.scala, '3.0')")), + WorkflowStep.Sbt(List("compile"), name = Some("Scala compile"), cond = Some("startsWith(matrix.scala, '3.0')")), ) ThisBuild / githubWorkflowJavaVersions := Seq(JavaSpec(Zulu, "8")) diff --git a/src/main/scala-2/com/github/swagger/scala/converter/ErasureHelper.scala b/src/main/scala-2/com/github/swagger/scala/converter/ErasureHelper.scala new file mode 100644 index 00000000..2e76f9dd --- /dev/null +++ b/src/main/scala-2/com/github/swagger/scala/converter/ErasureHelper.scala @@ -0,0 +1,23 @@ +package com.github.swagger.scala.converter + +object ErasureHelper { + + def erasedOptionalPrimitives(cls: Class[_]): Map[String, Class[_]] = { + import scala.reflect.runtime.universe + val mirror = universe.runtimeMirror(cls.getClassLoader) + val sym = mirror.staticClass(cls.getName) + val properties = sym.selfType.members + .filterNot(_.isMethod) + .filterNot(_.isClass) + + properties.flatMap { prop => + val maybeClass: Option[Class[_]] = prop.typeSignature.typeArgs.headOption.flatMap { signature => + if (signature.typeSymbol.isClass) { + Option(mirror.runtimeClass(signature.typeSymbol.asClass)) + } else None + } + maybeClass.map(prop.name.toString.trim -> _) + }.toMap + } + +} diff --git a/src/main/scala-3/com/github/swagger/scala/converter/ErasureHelper.scala b/src/main/scala-3/com/github/swagger/scala/converter/ErasureHelper.scala new file mode 100644 index 00000000..bb0d2bbb --- /dev/null +++ b/src/main/scala-3/com/github/swagger/scala/converter/ErasureHelper.scala @@ -0,0 +1,11 @@ +package com.github.swagger.scala.converter + +import io.swagger.v3.oas.models.media.Schema + + +object ErasureHelper { + + def erasedOptionalPrimitives(cls: Class[_]): Map[String, Class[_]] = Map.empty[String, Class[_]] + +} + diff --git a/src/main/scala/com/github/swagger/scala/converter/SwaggerScalaModelConverter.scala b/src/main/scala/com/github/swagger/scala/converter/SwaggerScalaModelConverter.scala index aa1046b0..987a0621 100644 --- a/src/main/scala/com/github/swagger/scala/converter/SwaggerScalaModelConverter.scala +++ b/src/main/scala/com/github/swagger/scala/converter/SwaggerScalaModelConverter.scala @@ -15,6 +15,7 @@ import io.swagger.v3.oas.annotations.media.{ArraySchema, Schema => SchemaAnnotat import io.swagger.v3.oas.models.media.Schema import org.slf4j.LoggerFactory +import java.util import scala.util.Try import scala.util.control.NonFatal @@ -31,6 +32,7 @@ class SwaggerScalaModelConverter extends ModelResolver(SwaggerScalaModelConverte private val EnumClass = classOf[scala.Enumeration] private val OptionClass = classOf[scala.Option[_]] private val IterableClass = classOf[scala.collection.Iterable[_]] + private val MapClass = classOf[Map[_, _]] private val SetClass = classOf[scala.collection.Set[_]] private val BigDecimalClass = classOf[BigDecimal] private val BigIntClass = classOf[BigInt] @@ -71,24 +73,37 @@ class SwaggerScalaModelConverter extends ModelResolver(SwaggerScalaModelConverte } private def caseClassSchema(cls: Class[_], `type`: AnnotatedType, context: ModelConverterContext, - chain: Iterator[ModelConverter]): Option[Schema[_]] = { + chain: util.Iterator[ModelConverter]): Option[Schema[_]] = { + val erasedProperties = ErasureHelper.erasedOptionalPrimitives(cls) + if (chain.hasNext) { Option(chain.next().resolve(`type`, context, chain)).map { schema => val introspector = BeanIntrospector(cls) introspector.properties.foreach { property => + + val propertyClass = getPropertyClass(property) + val isOptional = isOption(propertyClass) + + erasedProperties.get(property.name).foreach { erasedType => + val primitiveType = PrimitiveType.fromType(erasedType) + if (primitiveType != null && isOptional) { + updateTypeOnSchema(schema, primitiveType, property.name) + } + if (primitiveType != null && isIterable(propertyClass) && !isMap(propertyClass)) { + updateTypeOnItemsSchema(schema, primitiveType, property.name) + } + } getPropertyAnnotations(property) match { case Seq() => { - val propertyClass = getPropertyClass(property) - val optionalFlag = isOption(propertyClass) - if (optionalFlag && schema.getRequired != null && schema.getRequired.contains(property.name)) { + if (isOptional && schema.getRequired != null && schema.getRequired.contains(property.name)) { schema.getRequired.remove(property.name) - } else if (!optionalFlag) { + } else if (!isOptional) { addRequiredItem(schema, property.name) } } case annotations => { val required = getRequiredSettings(annotations).headOption - .getOrElse(!isOption(getPropertyClass(property))) + .getOrElse(!isOptional) if (required) addRequiredItem(schema, property.name) } } @@ -100,6 +115,28 @@ class SwaggerScalaModelConverter extends ModelResolver(SwaggerScalaModelConverte } } + private def updateTypeOnSchema(schema: Schema[_], primitiveType: PrimitiveType, propertyName: String) = { + val property = schema.getProperties.get(propertyName) + val updatedSchema = correctSchema(property, primitiveType) + schema.addProperty(propertyName, updatedSchema) + } + + private def updateTypeOnItemsSchema(schema: Schema[_], primitiveType: PrimitiveType, propertyName: String) = { + val property = schema.getProperties.get(propertyName) + val updatedSchema = correctSchema(property.getItems, primitiveType) + property.setItems(updatedSchema) + schema.addProperty(propertyName, property) + } + + private def correctSchema(itemSchema: Schema[_], primitiveType: PrimitiveType) = { + val primitiveProperty = primitiveType.createProperty() + val propAsString = objectMapper.writeValueAsString(itemSchema) + val correctedSchema = objectMapper.readValue(propAsString, primitiveProperty.getClass) + correctedSchema.setType(primitiveProperty.getType) + correctedSchema.setFormat(primitiveProperty.getFormat) + correctedSchema + } + private def getRequiredSettings(annotatedType: AnnotatedType): Seq[Boolean] = annotatedType match { case _: AnnotatedTypeForOption => Seq.empty case _ => getRequiredSettings(nullSafeList(annotatedType.getCtxAnnotations)) @@ -276,6 +313,7 @@ class SwaggerScalaModelConverter extends ModelResolver(SwaggerScalaModelConverte private def isOption(cls: Class[_]): Boolean = cls == OptionClass private def isIterable(cls: Class[_]): Boolean = IterableClass.isAssignableFrom(cls) + private def isMap(cls: Class[_]): Boolean = MapClass.isAssignableFrom(cls) private def isCaseClass(cls: Class[_]): Boolean = ProductClass.isAssignableFrom(cls) private def nullSafeList[T](array: Array[T]): List[T] = Option(array) match { diff --git a/src/test/scala/com/github/swagger/scala/converter/ModelPropertyParserTest.scala b/src/test/scala/com/github/swagger/scala/converter/ModelPropertyParserTest.scala index 56c761e0..1fdf5d6e 100644 --- a/src/test/scala/com/github/swagger/scala/converter/ModelPropertyParserTest.scala +++ b/src/test/scala/com/github/swagger/scala/converter/ModelPropertyParserTest.scala @@ -107,9 +107,10 @@ class ModelPropertyParserTest extends AnyFlatSpec with Matchers with OptionValue val model = schemas.get("ModelWOptionInt") model should be (defined) model.value.getProperties should not be (null) - val optInt = model.value.getProperties().get("optInt") + val optInt = model.value.getProperties.get("optInt") optInt should not be (null) - optInt shouldBe a [Schema[_]] + optInt shouldBe a [IntegerSchema] + optInt.asInstanceOf[IntegerSchema].getFormat shouldEqual "int32" nullSafeList(model.value.getRequired) shouldBe empty } @@ -123,9 +124,22 @@ class ModelPropertyParserTest extends AnyFlatSpec with Matchers with OptionValue optInt should not be (null) optInt shouldBe a [IntegerSchema] optInt.asInstanceOf[IntegerSchema].getFormat shouldEqual "int32" + optInt.getDescription shouldBe "This is an optional int" nullSafeList(model.value.getRequired) shouldBe empty } + it should "allow annotation to override required with Scala Option Int" in { + val converter = ModelConverters.getInstance() + val schemas = converter.readAll(classOf[ModelWOptionIntSchemaOverrideForRequired]).asScala.toMap + val model = schemas.get("ModelWOptionIntSchemaOverrideForRequired") + model should be(defined) + model.value.getProperties should not be (null) + val optInt = model.value.getProperties().get("optInt") + optInt should not be (null) + optInt shouldBe an [IntegerSchema] + nullSafeList(model.value.getRequired) shouldEqual Seq("optInt") + } + it should "process Model with Scala Option Long" in { val converter = ModelConverters.getInstance() val schemas = converter.readAll(classOf[ModelWOptionLong]).asScala.toMap @@ -134,7 +148,7 @@ class ModelPropertyParserTest extends AnyFlatSpec with Matchers with OptionValue model.value.getProperties should not be (null) val optLong = model.value.getProperties().get("optLong") optLong should not be (null) - optLong shouldBe a [Schema[_]] + optLong shouldBe a [IntegerSchema] nullSafeList(model.value.getRequired) shouldBe empty } @@ -324,6 +338,43 @@ class ModelPropertyParserTest extends AnyFlatSpec with Matchers with OptionValue nullSafeList(arraySchema.getRequired()) shouldBe empty } + it should "process Model with Scala Seq Int" in { + val converter = ModelConverters.getInstance() + val schemas = converter.readAll(classOf[ModelWSeqInt]).asScala.toMap + val model = findModel(schemas, "ModelWSeqInt") + model should be(defined) + model.value.getProperties should not be (null) + + val stringsField = model.value.getProperties.get("ints") + + stringsField shouldBe a[ArraySchema] + val arraySchema = stringsField.asInstanceOf[ArraySchema] + arraySchema.getUniqueItems() shouldBe (null) + arraySchema.getItems shouldBe a[IntegerSchema] + nullSafeMap(arraySchema.getProperties()) shouldBe empty + nullSafeList(arraySchema.getRequired()) shouldBe empty + } + + it should "process Model with Scala Seq Int (annotated)" in { + val converter = ModelConverters.getInstance() + val schemas = converter.readAll(classOf[ModelWSeqIntAnnotated]).asScala.toMap + val model = findModel(schemas, "ModelWSeqIntAnnotated") + model should be(defined) + model.value.getProperties should not be (null) + + val stringsField = model.value.getProperties.get("ints") + + stringsField shouldBe a[ArraySchema] + val arraySchema = stringsField.asInstanceOf[ArraySchema] + arraySchema.getUniqueItems() shouldBe (null) + + + arraySchema.getItems shouldBe a[IntegerSchema] + arraySchema.getItems.getDescription shouldBe "These are ints" + nullSafeMap(arraySchema.getProperties()) shouldBe empty + nullSafeList(arraySchema.getRequired()) shouldBe empty + } + it should "process Model with Scala Set" in { val converter = ModelConverters.getInstance() val schemas = converter.readAll(classOf[ModelWSetString]).asScala.toMap diff --git a/src/test/scala/com/github/swagger/scala/converter/ScalaModelTest.scala b/src/test/scala/com/github/swagger/scala/converter/ScalaModelTest.scala index 38f9e461..08ab791d 100644 --- a/src/test/scala/com/github/swagger/scala/converter/ScalaModelTest.scala +++ b/src/test/scala/com/github/swagger/scala/converter/ScalaModelTest.scala @@ -70,7 +70,7 @@ class ScalaModelTest extends AnyFlatSpec with Matchers { val date = userSchema.getProperties().get("date") date shouldBe a [DateTimeSchema] - //date.getDescription should be ("the birthdate") +// date.getDescription should be ("the birthdate") } it should "read a model with vector property" in { @@ -85,7 +85,7 @@ class ScalaModelTest extends AnyFlatSpec with Matchers { val model = schemas("ModelWithIntVector") val prop = model.getProperties().get("ints") prop shouldBe a [ArraySchema] - prop.asInstanceOf[ArraySchema].getItems.getType should be ("object") + prop.asInstanceOf[ArraySchema].getItems.getType should be ("integer") } it should "read a model with vector of booleans" in { @@ -93,7 +93,7 @@ class ScalaModelTest extends AnyFlatSpec with Matchers { val model = schemas("ModelWithBooleanVector") val prop = model.getProperties().get("bools") prop shouldBe a [ArraySchema] - prop.asInstanceOf[ArraySchema].getItems.getType should be ("object") + prop.asInstanceOf[ArraySchema].getItems.getType should be ("boolean") } } diff --git a/src/test/scala/models/ModelWOptionInt.scala b/src/test/scala/models/ModelWOptionInt.scala index 6c05dd26..58702c4f 100644 --- a/src/test/scala/models/ModelWOptionInt.scala +++ b/src/test/scala/models/ModelWOptionInt.scala @@ -4,4 +4,6 @@ import io.swagger.v3.oas.annotations.media.Schema case class ModelWOptionInt(optInt: Option[Int]) -case class ModelWOptionIntSchemaOverride(@Schema(implementation = classOf[Int]) optInt: Option[Int]) +case class ModelWOptionIntSchemaOverride(@Schema(description = "This is an optional int") optInt: Option[Int]) + +case class ModelWOptionIntSchemaOverrideForRequired(@Schema(required = true) optInt: Option[Int]) diff --git a/src/test/scala/models/ModelWSeqInt.scala b/src/test/scala/models/ModelWSeqInt.scala new file mode 100644 index 00000000..68ed1a70 --- /dev/null +++ b/src/test/scala/models/ModelWSeqInt.scala @@ -0,0 +1,7 @@ +package models + +import io.swagger.v3.oas.annotations.media.{ArraySchema, Schema} + +case class ModelWSeqInt(ints: Seq[Int]) + +case class ModelWSeqIntAnnotated(@ArraySchema(arraySchema = new Schema(required = false), schema = new Schema(description = "These are ints")) ints: Seq[Int])