Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add JsonSchema fields #69

Merged
merged 3 commits into from
May 30, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion apispec-model/src/main/scala/sttp/apispec/Schema.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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]])
Expand Down
29 changes: 26 additions & 3 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -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 ++
Expand All @@ -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"))
Expand Down Expand Up @@ -134,7 +157,7 @@ lazy val jsonSchemaCirce: ProjectMatrix = (projectMatrix in file("jsonschema-cir
scalaVersions = scalaNativeVersions,
settings = commonNativeSettings
)
.dependsOn(apispecModel)
.dependsOn(apispecModel, circeTestUtils % Test)

// openapi

Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,24 +1,29 @@
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
hamnis marked this conversation as resolved.
Show resolved Hide resolved
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")

def readFile(name: String): String = {
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)
}
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
package sttp.apispec.openapi.circe
package sttp.apispec.test

import io.circe._
import io.circe.parser.decode
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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
)
)
)
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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
)
}
Expand Down
32 changes: 32 additions & 0 deletions jsonschema-circe/src/test/resources/extending-recursive.json
Original file line number Diff line number Diff line change
@@ -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 ..."] }
}
}
}
}
46 changes: 46 additions & 0 deletions jsonschema-circe/src/test/resources/self-describing-schema.json
Original file line number Diff line number Diff line change
@@ -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#"
}
]

}
34 changes: 34 additions & 0 deletions jsonschema-circe/src/test/scala/sttp/apispec/DecoderTest.scala
Original file line number Diff line number Diff line change
@@ -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"))

}
}
17 changes: 17 additions & 0 deletions jsonschema-circe/src/test/scala/sttp/apispec/RoundTripTest.scala
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down

This file was deleted.