diff --git a/apispec-model/src/main/scala/sttp/apispec/Schema.scala b/apispec-model/src/main/scala/sttp/apispec/Schema.scala index b892f99..cc7d885 100644 --- a/apispec-model/src/main/scala/sttp/apispec/Schema.scala +++ b/apispec-model/src/main/scala/sttp/apispec/Schema.scala @@ -62,7 +62,13 @@ case class Schema( `then`: Option[ReferenceOr[SchemaLike]] = None, `else`: Option[ReferenceOr[SchemaLike]] = None, $defs: Option[ListMap[String, SchemaLike]] = None, - extensions: ListMap[String, ExtensionValue] = ListMap.empty + extensions: ListMap[String, ExtensionValue] = ListMap.empty, + $id: Option[String] = None, + const: Option[ExampleValue] = None, + anyOf: List[ReferenceOr[SchemaLike]] = List.empty, + unevaluatedProperties: Option[ReferenceOr[SchemaLike]] = None, + dependentRequired: ListMap[String, List[String]] = ListMap.empty, + dependentSchemas: ListMap[String, ReferenceOr[SchemaLike]] = ListMap.empty ) extends SchemaLike case class Discriminator(propertyName: String, mapping: Option[ListMap[String, String]]) diff --git a/build.sbt b/build.sbt index b5e4502..7792aa0 100644 --- a/build.sbt +++ b/build.sbt @@ -66,7 +66,8 @@ val commonNativeSettings = commonSettings ++ Seq( ) lazy val allProjectAggregates: Seq[ProjectReference] = - apispecModel.projectRefs ++ + circeTestUtils.projectRefs ++ + apispecModel.projectRefs ++ jsonSchemaCirce.projectRefs ++ openapiModel.projectRefs ++ openapiCirce.projectRefs ++ @@ -90,6 +91,28 @@ lazy val rootProject = (project in file(".")) .settings(publish / skip := true, name := "sttp-apispec", scalaVersion := scala2_13) .aggregate(projectAggregates: _*) +lazy val circeTestUtils: ProjectMatrix = (projectMatrix in file("circe-testutils")) + .settings(commonSettings) + .settings( + publish / skip := true, + name := "circe-testutils", + libraryDependencies ++= Seq( + "io.circe" %%% "circe-core" % circeVersion, + "io.circe" %%% "circe-parser" % circeVersion + ) + ) + .jvmPlatform( + scalaVersions = scalaJVMVersions, + settings = commonJvmSettings + ) + .jsPlatform( + scalaVersions = scalaJSVersions, + settings = commonJsSettings + ) + .nativePlatform( + scalaVersions = scalaNativeVersions, + settings = commonNativeSettings + ) // apispec lazy val apispecModel: ProjectMatrix = (projectMatrix in file("apispec-model")) @@ -134,7 +157,7 @@ lazy val jsonSchemaCirce: ProjectMatrix = (projectMatrix in file("jsonschema-cir scalaVersions = scalaNativeVersions, settings = commonNativeSettings ) - .dependsOn(apispecModel) + .dependsOn(apispecModel, circeTestUtils % Test) // openapi @@ -174,7 +197,7 @@ lazy val openapiCirce: ProjectMatrix = (projectMatrix in file("openapi-circe")) scalaVersions = scalaNativeVersions, settings = commonNativeSettings ) - .dependsOn(openapiModel, jsonSchemaCirce) + .dependsOn(openapiModel, jsonSchemaCirce, circeTestUtils % Test) lazy val openapiCirceYaml: ProjectMatrix = (projectMatrix in file("openapi-circe-yaml")) .settings(commonSettings) diff --git a/openapi-circe/src/test/scalajs/sttp/apispec/openapi/circe/ResourcePlatform.scala b/circe-testutils/src/main/scalajs/sttp/apispec/test/ResourcePlatform.scala similarity index 50% rename from openapi-circe/src/test/scalajs/sttp/apispec/openapi/circe/ResourcePlatform.scala rename to circe-testutils/src/main/scalajs/sttp/apispec/test/ResourcePlatform.scala index 9747010..cc0a254 100644 --- a/openapi-circe/src/test/scalajs/sttp/apispec/openapi/circe/ResourcePlatform.scala +++ b/circe-testutils/src/main/scalajs/sttp/apispec/test/ResourcePlatform.scala @@ -1,12 +1,17 @@ -package sttp.apispec.openapi.circe +package sttp.apispec.test import io.circe.{Json, Error} import io.circe.parser.decode trait ResourcePlatform { - def rscPath(path: String): String = "openapi-circe/src/test/resources" + path - def rsc(path: String): String = { + /** + * @return Base directory of sbt project we should read resources from + */ + def basedir: String + def resourcesPath(path: String): String = s"$basedir/src/test/resources$path" + + def resourceAsString(path: String): String = { import scalajs.js.Dynamic.{global => g} val fs = g.require("fs") @@ -14,11 +19,11 @@ trait ResourcePlatform { fs.readFileSync(name).toString } - readFile(rscPath(path)) + readFile(resourcesPath(path)) } def readJson(path: String): Either[Error, Json] = { - val string = rsc(path) + val string = resourceAsString(path) decode[Json](string) } } diff --git a/openapi-circe/src/test/scalajvm/sttp/apispec/openapi/circe/ResourcePlatform.scala b/circe-testutils/src/main/scalajvm/sttp/apispec/test/ResourcePlatform.scala similarity index 75% rename from openapi-circe/src/test/scalajvm/sttp/apispec/openapi/circe/ResourcePlatform.scala rename to circe-testutils/src/main/scalajvm/sttp/apispec/test/ResourcePlatform.scala index 9251cee..217496d 100644 --- a/openapi-circe/src/test/scalajvm/sttp/apispec/openapi/circe/ResourcePlatform.scala +++ b/circe-testutils/src/main/scalajvm/sttp/apispec/test/ResourcePlatform.scala @@ -1,4 +1,4 @@ -package sttp.apispec.openapi.circe +package sttp.apispec.test import io.circe._ import io.circe.parser.decode @@ -6,6 +6,11 @@ import java.io.{BufferedReader, InputStreamReader, StringWriter} import java.nio.charset.StandardCharsets trait ResourcePlatform { + + /** + * @return Base directory of sbt project we should read resources from. Not used from JVM + */ + def basedir: String def readJson(path: String): Either[Error, Json] = { val is = getClass.getResourceAsStream(path) diff --git a/circe-testutils/src/main/scalanative/sttp/apispec/test/ResourcePlatform.scala b/circe-testutils/src/main/scalanative/sttp/apispec/test/ResourcePlatform.scala new file mode 100644 index 0000000..9cf9598 --- /dev/null +++ b/circe-testutils/src/main/scalanative/sttp/apispec/test/ResourcePlatform.scala @@ -0,0 +1,18 @@ +package sttp.apispec.test + +import io.circe._ +import io.circe.parser.decode + +trait ResourcePlatform { + + /** + * @return Base directory of sbt project we should read resources from + */ + def basedir: String + def resourcesPath(path: String): String = s"$basedir/src/test/resources/$path" + + def readJson(path: String): Either[Error, Json] = { + val string = scala.io.Source.fromFile(resourcesPath(path), "UTF-8").mkString + decode[Json](string) + } +} diff --git a/jsonschema-circe/src/main/scala/sttp/apispec/internal/JsonSchemaCirceDecoders.scala b/jsonschema-circe/src/main/scala/sttp/apispec/internal/JsonSchemaCirceDecoders.scala index 327080e..44cc536 100644 --- a/jsonschema-circe/src/main/scala/sttp/apispec/internal/JsonSchemaCirceDecoders.scala +++ b/jsonschema-circe/src/main/scala/sttp/apispec/internal/JsonSchemaCirceDecoders.scala @@ -63,9 +63,21 @@ trait JsonSchemaCirceDecoders { implicit def listPatternMapDecoder[A: Decoder]: Decoder[ListMap[Pattern, ReferenceOr[A]]] = Decoder.decodeOption(Decoder.decodeMapLike[Pattern, ReferenceOr[A], ListMap]).map(_.getOrElse(ListMap.empty)) + implicit def listdependentFieldsDecoder: Decoder[ListMap[String, List[String]]] = + Decoder.decodeOption(Decoder.decodeMapLike[String, List[String], ListMap]).map(_.getOrElse(ListMap.empty)) + implicit def listReference[A: Decoder]: Decoder[List[A]] = Decoder.decodeOption(Decoder.decodeList[A]).map(_.getOrElse(Nil)) + def translateDefinitionsTo$def[A](decoder: Decoder[A]) = Decoder.instance { c => + val modded = c.withFocus(_.mapObject { obj => + val map = obj.toMap + val definitions = map.get("definitions").orElse(map.get("$defs")) + definitions.map(j => obj.remove("definitions").remove("$defs").add("$defs", j)).getOrElse(obj) + }) + decoder.tryDecode(modded) + } + def translateMinMax[A](decoder: Decoder[A]) = Decoder.instance { c => val modded = c.withFocus(_.mapObject { obj => val map = obj.toMap @@ -87,11 +99,13 @@ trait JsonSchemaCirceDecoders { withExtensions( translateMinMax( - deriveDecoder[Schema].map(s => - s.`type` match { - case Some(ArraySchemaType(x :: SchemaType.Null :: Nil)) => s.copy(`type` = Some(x), nullable = Some(true)) - case _ => s - } + translateDefinitionsTo$def( + deriveDecoder[Schema].map(s => + s.`type` match { + case Some(ArraySchemaType(x :: SchemaType.Null :: Nil)) => s.copy(`type` = Some(x), nullable = Some(true)) + case _ => s + } + ) ) ) ) diff --git a/jsonschema-circe/src/main/scala/sttp/apispec/internal/JsonSchemaCirceEncoders.scala b/jsonschema-circe/src/main/scala/sttp/apispec/internal/JsonSchemaCirceEncoders.scala index 1199290..4891b8b 100644 --- a/jsonschema-circe/src/main/scala/sttp/apispec/internal/JsonSchemaCirceEncoders.scala +++ b/jsonschema-circe/src/main/scala/sttp/apispec/internal/JsonSchemaCirceEncoders.scala @@ -18,8 +18,10 @@ trait JsonSchemaCirceEncoders { val minKey = if (s.exclusiveMinimum.getOrElse(false)) "exclusiveMinimum" else "minimum" val maxKey = if (s.exclusiveMaximum.getOrElse(false)) "exclusiveMaximum" else "maximum" JsonObject( + s"$$id" := s.$id, s"$$schema" := s.$schema, "allOf" := s.allOf, + "anyOf" := s.anyOf, "title" := s.title, "required" := s.required, "type" := (if (s.nullable.getOrElse(false)) @@ -53,6 +55,10 @@ trait JsonSchemaCirceEncoders { "then" := s.`then`, "else" := s.`else`, "$defs" := s.$defs, + "const" := s.const, + "unevaluatedProperties" := s.unevaluatedProperties, + "dependentRequired" := s.dependentRequired, + "dependentSchemas" := s.dependentSchemas, "extensions" := s.extensions ) } diff --git a/jsonschema-circe/src/test/resources/extending-recursive.json b/jsonschema-circe/src/test/resources/extending-recursive.json new file mode 100644 index 0000000..76cc961 --- /dev/null +++ b/jsonschema-circe/src/test/resources/extending-recursive.json @@ -0,0 +1,32 @@ +{ + "$id": "https://example.com/schemas/customer", + "$schema": "https://json-schema.org/draft/2020-12/schema", + + "type": "object", + "properties": { + "first_name": { "type": "string" }, + "last_name": { "type": "string" }, + "shipping_address": { "$ref": "/schemas/address" }, + "billing_address": { "$ref": "/schemas/address" } + }, + "required": ["first_name", "last_name", "shipping_address", "billing_address"], + + "$defs": { + "address": { + "$id": "/schemas/address", + "$schema": "http://json-schema.org/draft-07/schema#", + + "type": "object", + "properties": { + "street_address": { "type": "string" }, + "city": { "type": "string" }, + "state": { "$ref": "#/definitions/state" } + }, + "required": ["street_address", "city", "state"], + + "definitions": { + "state": { "enum": ["CA", "NY", "... etc ..."] } + } + } + } +} \ No newline at end of file diff --git a/jsonschema-circe/src/test/resources/self-describing-schema.json b/jsonschema-circe/src/test/resources/self-describing-schema.json new file mode 100644 index 0000000..51d2f6b --- /dev/null +++ b/jsonschema-circe/src/test/resources/self-describing-schema.json @@ -0,0 +1,46 @@ +{ + "$schema" : "http://iglucentral.com/schemas/com.snowplowanalytics.self-desc/schema/jsonschema/1-0-0#", + "description": "Meta-schema for self-describing JSON schema", + "self": { + "vendor": "com.snowplowanalytics.self-desc", + "name": "schema", + "format": "jsonschema", + "version": "1-0-0" + }, + + "allOf": [ + { + "properties": { + "self": { + "type": "object", + "properties": { + "vendor": { + "type": "string", + "pattern": "^[a-zA-Z0-9-_.]+$" + }, + "name": { + "type": "string", + "pattern": "^[a-zA-Z0-9-_]+$" + }, + "format": { + "type": "string", + "pattern": "^[a-zA-Z0-9-_]+$" + }, + "version": { + "type": "string", + "pattern": "^[0-9]+-[0-9]+-[0-9]+$" + } + }, + "required": ["vendor", "name", "format", "version"], + "additionalProperties": false + } + }, + "required": ["self"] + }, + + { + "$ref": "http://json-schema.org/draft-04/schema#" + } + ] + +} diff --git a/jsonschema-circe/src/test/scala/sttp/apispec/DecoderTest.scala b/jsonschema-circe/src/test/scala/sttp/apispec/DecoderTest.scala new file mode 100644 index 0000000..59cdb57 --- /dev/null +++ b/jsonschema-circe/src/test/scala/sttp/apispec/DecoderTest.scala @@ -0,0 +1,34 @@ +package sttp.apispec + +import org.scalatest.funsuite.AnyFunSuite +import sttp.apispec.circe._ +import sttp.apispec.test._ + +import scala.collection.immutable.ListMap + +class DecoderTest extends AnyFunSuite with ResourcePlatform { + override val basedir = "jsonschema-circe" + + test("extending rescursive") { + val Right(json) = readJson("/extending-recursive.json") + val schema = json.as[Schema] + assert(schema.isRight) + val unsafeSchema = schema.right.get + val adrEither = unsafeSchema.$defs.getOrElse(ListMap.empty)("address") + + adrEither match { + case s: Schema => assert(s.$schema == Some("http://json-schema.org/draft-07/schema#") && s.$defs.isDefined) + case _ => fail("Nope") + } + } + + test("self-decribing-schema") { + val Right(json) = readJson("/self-describing-schema.json") + val schema = json.as[Schema] + assert(schema.isRight) + + val unsafeSchema = schema.right.get + assert(unsafeSchema.description === Some("Meta-schema for self-describing JSON schema")) + + } +} diff --git a/jsonschema-circe/src/test/scala/sttp/apispec/RoundTripTest.scala b/jsonschema-circe/src/test/scala/sttp/apispec/RoundTripTest.scala new file mode 100644 index 0000000..d22e131 --- /dev/null +++ b/jsonschema-circe/src/test/scala/sttp/apispec/RoundTripTest.scala @@ -0,0 +1,17 @@ +package sttp.apispec + +import org.scalatest.funsuite.AnyFunSuite +import sttp.apispec.circe._ +import sttp.apispec.test._ +import io.circe.syntax._ +import io.circe.Decoder + +class RoundTripTest extends AnyFunSuite with ResourcePlatform { + override val basedir = "jsonschema-circe" + + test("Can parse self-encoded schema") { + val simple = Schema($schema = Some("https://json-schema.org/draft/2020-12/schema"), $id = Some("http://yourdomain.com/schemas/myschema.json")) + val decoded = Decoder[Schema].decodeJson(simple.asJson) + assert(decoded.isRight) + } +} diff --git a/openapi-circe/src/test/scala/sttp/apispec/openapi/circe/DecoderTest.scala b/openapi-circe/src/test/scala/sttp/apispec/openapi/circe/DecoderTest.scala index 3c0f246..b9c2c68 100644 --- a/openapi-circe/src/test/scala/sttp/apispec/openapi/circe/DecoderTest.scala +++ b/openapi-circe/src/test/scala/sttp/apispec/openapi/circe/DecoderTest.scala @@ -2,9 +2,12 @@ package sttp.apispec package openapi package circe +import sttp.apispec.test._ import org.scalatest.funsuite.AnyFunSuite class DecoderTest extends AnyFunSuite with ResourcePlatform { + override val basedir = "openapi-circe" + test("petstore deserialize") { val Right(openapi) = readJson("/petstore/basic-petstore.json").flatMap(_.as[OpenAPI]): @unchecked diff --git a/openapi-circe/src/test/scala/sttp/apispec/openapi/circe/EncoderTest.scala b/openapi-circe/src/test/scala/sttp/apispec/openapi/circe/EncoderTest.scala index bc483ea..10d0d0f 100644 --- a/openapi-circe/src/test/scala/sttp/apispec/openapi/circe/EncoderTest.scala +++ b/openapi-circe/src/test/scala/sttp/apispec/openapi/circe/EncoderTest.scala @@ -5,10 +5,12 @@ package threeone import io.circe.syntax._ import org.scalatest.funsuite.AnyFunSuite +import sttp.apispec.test._ import scala.collection.immutable.ListMap class EncoderTest extends AnyFunSuite with ResourcePlatform with circe.SttpOpenAPI3_1CirceEncoders { + override val basedir = "openapi-circe" val petstore: OpenAPI = OpenAPI( openapi = "3.1.0", diff --git a/openapi-circe/src/test/scala/sttp/apispec/openapi/circe/overridden/EncoderTest.scala b/openapi-circe/src/test/scala/sttp/apispec/openapi/circe/overridden/EncoderTest.scala index 8866c55..9e85751 100644 --- a/openapi-circe/src/test/scala/sttp/apispec/openapi/circe/overridden/EncoderTest.scala +++ b/openapi-circe/src/test/scala/sttp/apispec/openapi/circe/overridden/EncoderTest.scala @@ -5,9 +5,10 @@ import sttp.apispec._ import sttp.apispec.openapi._ import org.scalatest.funsuite.AnyFunSuite import scala.collection.immutable.ListMap -import sttp.apispec.openapi.circe.ResourcePlatform +import sttp.apispec.test._ class EncoderTest extends AnyFunSuite with ResourcePlatform { + override val basedir = "openapi-circe" def refOr[A](a: A): ReferenceOr[A] = Right(a) test("any boolean") { diff --git a/openapi-circe/src/test/scalanative/sttp/apispec/openapi/circe/ResourcePlatform.scala b/openapi-circe/src/test/scalanative/sttp/apispec/openapi/circe/ResourcePlatform.scala deleted file mode 100644 index 32fce67..0000000 --- a/openapi-circe/src/test/scalanative/sttp/apispec/openapi/circe/ResourcePlatform.scala +++ /dev/null @@ -1,13 +0,0 @@ -package sttp.apispec.openapi.circe - -import io.circe._ -import io.circe.parser.decode - -trait ResourcePlatform { - def rscPath(path: String): String = "openapi-circe/src/test/resources" + path - - def readJson(path: String): Either[Error, Json] = { - val string = scala.io.Source.fromFile(rscPath(path), "UTF-8").mkString - decode[Json](string) - } -}