diff --git a/api/src/test/scala/com/github/andyglow/jsonschema/SchemaMacroSpec.scala b/api/src/test/scala/com/github/andyglow/jsonschema/SchemaMacroSpec.scala index 068f1955..c9f06d01 100644 --- a/api/src/test/scala/com/github/andyglow/jsonschema/SchemaMacroSpec.scala +++ b/api/src/test/scala/com/github/andyglow/jsonschema/SchemaMacroSpec.scala @@ -32,9 +32,21 @@ class SchemaMacroSpec extends WordSpec { import `object`.Field Json.schema[Foo3] shouldEqual `object`( - Field("name" , `string`[String](None, None), required = false), - Field("bar" , `integer`, required = false), - Field("active" , `boolean`, required = false)) + Field("name" , `string`[String](None, None), required = false, default = "xxx"), + Field("bar" , `integer`, required = false, default = 5), + Field("active" , `boolean`, required = false, default = true)) + } + + "generate schema for case class of array fields with default values" in { + import `object`.Field + + Json.schema[Bar9] shouldEqual `object`( + Field("seq" , `array`(`string`[String](None, None)), required = false, default = Seq.empty[String]), + Field("set" , `set`(`integer`), required = false, default = Set(1, 5, 9)), + Field("list" , `array`(`boolean`), required = false, default = List(true, false)), + Field("vector" , `array`(`number`[Long]), required = false, default = Vector(9, 7)), + Field("strMap" , `string-map`(`number`[Double]), required = false, default = Map("foo" -> .12)), + Field("intMap" , `int-map`(`string`[String](None, None)), required = false, default = Map(1 -> "1", 2 -> "2"))) } "generate references for implicitly defined dependencies" in { @@ -172,6 +184,14 @@ object SchemaMacroSpec { case class Bar8(foo: String) extends AnyVal case class Foo9(name: String) + + case class Bar9( + seq: Seq[String] = Seq.empty, + set: Set[Int] = Set(1, 5, 9), + list: List[Boolean] = List(true, false), + vector: Vector[Long] = Vector(9L, 7L), + strMap: Map[String, Double] = Map("foo" -> .12), + intMap: Map[Int, String] = Map(1 -> "1", 2 -> "2")) } sealed trait FooBar diff --git a/api/src/test/scala/com/github/andyglow/jsonschema/model/Active.scala b/api/src/test/scala/com/github/andyglow/jsonschema/model/Active.scala new file mode 100644 index 00000000..6052ddfd --- /dev/null +++ b/api/src/test/scala/com/github/andyglow/jsonschema/model/Active.scala @@ -0,0 +1,21 @@ +package com.github.andyglow.jsonschema.model + +import com.github.andyglow.json.{ToValue, Value} + +// members goes without companion + +sealed trait Active + +case object On extends Active +case object Off extends Active +case object Suspended extends Active + +object Active { + + // ToValue is explicitly specified + implicit val ActiveV: ToValue[Active] = ToValue mk { + case On => Value.str("On") + case Off => Value.str("Off") + case Suspended => Value.str("Suspended") + } +} \ No newline at end of file diff --git a/api/src/test/scala/com/github/andyglow/jsonschema/model/BetaFeature.scala b/api/src/test/scala/com/github/andyglow/jsonschema/model/BetaFeature.scala new file mode 100644 index 00000000..5f28bb03 --- /dev/null +++ b/api/src/test/scala/com/github/andyglow/jsonschema/model/BetaFeature.scala @@ -0,0 +1,7 @@ +package com.github.andyglow.jsonschema.model + +sealed trait BetaFeature + +case object F0 extends BetaFeature +case object F1 extends BetaFeature +case object F2 extends BetaFeature diff --git a/api/src/test/scala/com/github/andyglow/jsonschema/model/Credentials.scala b/api/src/test/scala/com/github/andyglow/jsonschema/model/Credentials.scala new file mode 100644 index 00000000..fa042660 --- /dev/null +++ b/api/src/test/scala/com/github/andyglow/jsonschema/model/Credentials.scala @@ -0,0 +1,5 @@ +package com.github.andyglow.jsonschema.model + + +case class Credentials(login: String, password: String) + diff --git a/api/src/test/scala/com/github/andyglow/jsonschema/model/Role.scala b/api/src/test/scala/com/github/andyglow/jsonschema/model/Role.scala new file mode 100644 index 00000000..f2c0a325 --- /dev/null +++ b/api/src/test/scala/com/github/andyglow/jsonschema/model/Role.scala @@ -0,0 +1,15 @@ +package com.github.andyglow.jsonschema.model + + +// members goes inside companion + +sealed trait Role + +object Role { + + case object User extends Role + + case object Admin extends Role + + case object Manager extends Role +} \ No newline at end of file diff --git a/api/src/test/scala/com/github/andyglow/jsonschema/model/UserProfile.scala b/api/src/test/scala/com/github/andyglow/jsonschema/model/UserProfile.scala new file mode 100644 index 00000000..7a13c3d9 --- /dev/null +++ b/api/src/test/scala/com/github/andyglow/jsonschema/model/UserProfile.scala @@ -0,0 +1,11 @@ +package com.github.andyglow.jsonschema.model + +case class UserProfile( + firstName: String, + middleName: Option[String], + lastName: String, + age: Int, + enabledFeatures: Set[BetaFeature] = Set(F0, F1), + active: Active = On, + credentials: Credentials = Credentials("anonymous", "-"), + role: Role = Role.User) \ No newline at end of file diff --git a/build.sbt b/build.sbt index 3b2ae648..04d83536 100644 --- a/build.sbt +++ b/build.sbt @@ -17,7 +17,7 @@ lazy val commonSettings = Seq( scalaVersion := "2.11.12", - crossScalaVersions := Seq("2.11.12", "2.12.8", "2.13.0"), + crossScalaVersions := Seq("2.11.12", "2.12.10", "2.13.1"), scalacOptions ++= { val options = Seq( @@ -30,7 +30,8 @@ lazy val commonSettings = Seq( "-Yno-adapted-args", "-Ywarn-dead-code", "-Ywarn-numeric-widen", - "-Xfuture") + "-Xfuture", + "-language:higherKinds") // WORKAROUND https://github.com/scala/scala/pull/5402 CrossVersion.partialVersion(scalaVersion.value) match { @@ -109,7 +110,7 @@ lazy val macros = project in file("macros") dependsOn core settings ( name := "scala-jsonschema-macros", libraryDependencies ++= Seq( - (scalaVersion apply ("org.scala-lang" % "scala-reflect" % _ % Compile)).value) + (scalaVersion apply ("org.scala-lang" % "scala-reflect" % _ % Compile)).value.withSources.withJavadoc) ) lazy val api = { project in file("api") }.dependsOn(core, macros).settings( @@ -118,7 +119,7 @@ lazy val api = { project in file("api") }.dependsOn(core, macros).settings( name := "scala-jsonschema-api" ) -lazy val `play-json` = { project in file("play-json") }.dependsOn(core, api).settings( +lazy val `play-json` = { project in file("play-json") }.dependsOn(core, api % "compile->compile;test->test").settings( commonSettings, name := "scala-jsonschema-play-json", @@ -126,7 +127,7 @@ lazy val `play-json` = { project in file("play-json") }.dependsOn(core, api).set libraryDependencies += "com.typesafe.play" %% "play-json" % "2.7.4" ) -lazy val `spray-json` = { project in file("spray-json") }.dependsOn(core, api).settings( +lazy val `spray-json` = { project in file("spray-json") }.dependsOn(core, api % "compile->compile;test->test").settings( commonSettings, name := "scala-jsonschema-spray-json", @@ -134,7 +135,7 @@ lazy val `spray-json` = { project in file("spray-json") }.dependsOn(core, api).s libraryDependencies += "io.spray" %% "spray-json" % "1.3.5" ) -lazy val `circe-json` = { project in file("circe-json") }.dependsOn(core, api).settings( +lazy val `circe-json` = { project in file("circe-json") }.dependsOn(core, api % "compile->compile;test->test").settings( commonSettings, name := "scala-jsonschema-circe-json", @@ -150,26 +151,29 @@ lazy val `circe-json` = { project in file("circe-json") }.dependsOn(core, api).s } ) -lazy val `json4s-json` = { project in file("json4s-json") }.dependsOn(core, api).settings( +lazy val `json4s-json` = { project in file("json4s-json") }.dependsOn(core, api % "compile->compile;test->test").settings( commonSettings, name := "scala-jsonschema-json4s-json", - libraryDependencies += "org.json4s" %% "json4s-ast" % "3.6.7" + libraryDependencies += "org.json4s" %% "json4s-core" % "3.6.7" ) -lazy val `u-json` = { project in file("u-json") }.dependsOn(core, api).settings( +lazy val `u-json` = { project in file("u-json") }.dependsOn(core, api % "compile->compile;test->test").settings( commonSettings, name := "scala-jsonschema-ujson", - libraryDependencies += { + libraryDependencies ++= { val ujsonVersion = CrossVersion.partialVersion(scalaVersion.value) match { case Some((2, 11)) => "0.7.4" case _ => "0.7.5" } - "com.lihaoyi" %% "ujson" % ujsonVersion + Seq( + "com.lihaoyi" %% "ujson" % ujsonVersion, + "com.lihaoyi" %% "upickle" % ujsonVersion) + } ) diff --git a/circe-json/src/main/scala/com/github/andyglow/jsonschema/AsCirce.scala b/circe-json/src/main/scala/com/github/andyglow/jsonschema/AsCirce.scala index 5326f15f..582b82af 100644 --- a/circe-json/src/main/scala/com/github/andyglow/jsonschema/AsCirce.scala +++ b/circe-json/src/main/scala/com/github/andyglow/jsonschema/AsCirce.scala @@ -24,4 +24,21 @@ object AsCirce { def asCirce[V <: Version](v: V)(implicit asValue: AsValueBuilder[V]): Json = AsCirce(AsValue.schema(x, v)) } + + implicit def toValue[T](implicit w: Encoder[T]): ToValue[T] = new ToValue[T] { + override def apply(x: T): Value = { + val js = w.apply(x) + def translate(js: Json): Value = js match { + case Json.Null => `null` + case Json.True => `true` + case Json.False=> `false` + case x if x.isNumber => num(x.asNumber.get.toDouble) + case x if x.isString => str(x.asString.get) + case x if x.isArray => val a = x.asArray.get map translate; arr(a) + case x if x.isObject => val map = x.asObject.get.toMap mapV translate; obj(map) + } + + translate(js) + } + } } diff --git a/circe-json/src/test/scala/com/github/andyglow/jsonschema/AsCirceSpec.scala b/circe-json/src/test/scala/com/github/andyglow/jsonschema/AsCirceSpec.scala index 89abd2f7..c6034363 100644 --- a/circe-json/src/test/scala/com/github/andyglow/jsonschema/AsCirceSpec.scala +++ b/circe-json/src/test/scala/com/github/andyglow/jsonschema/AsCirceSpec.scala @@ -4,12 +4,14 @@ import org.scalatest._ import org.scalatest.Matchers._ import org.scalatest.prop.TableDrivenPropertyChecks._ import com.github.andyglow.json.Value._ +import com.github.andyglow.jsonschema.model.UserProfile import io.circe._ import json.schema.Version.Draft04 import org.scalactic.Equality class AsCirceSpec extends PropSpec { import AsCirceSpec._ + import UserProfileJson._ private val examples = Table( ("json" , "Circe"), @@ -39,21 +41,22 @@ class AsCirceSpec extends PropSpec { "middleName" -> Json.obj("type" -> Json.fromString("string")), "lastName" -> Json.obj("type" -> Json.fromString("string")), "age" -> Json.obj("type" -> Json.fromString("integer")), - "active" -> Json.obj("type" -> Json.fromString("boolean"))), + "role" -> Json.obj("type" -> Json.fromString("string"), "default" -> Json.fromString("e-user"), "enum" -> Json.arr(Json.fromString("e-admin"), Json.fromString("e-manager"), Json.fromString("e-user"))), + "active" -> Json.obj("type" -> Json.fromString("string"), "default" -> Json.fromString("On"), "enum" -> Json.arr(Json.fromString("On"), Json.fromString("Off"), Json.fromString("Suspended"))), + "enabledFeatures" -> Json.obj("type" -> Json.fromString("array"), "uniqueItems" -> Json.True, "default" -> Json.arr(Json.fromString("feature-0-name"), Json.fromString("feature-1-name")), "items" -> Json.obj("type" -> Json.fromString("string"), "enum" -> Json.arr(Json.fromString("feature-0-name"), Json.fromString("feature-1-name"), Json.fromString("feature-2-name")))), + "credentials" -> Json.obj("type" -> Json.fromString("object"), + "additionalProperties" -> Json.False, + "required" -> Json.arr(Json.fromString("login"), Json.fromString("password")), + "properties" -> Json.obj( + "login" -> Json.obj("type" -> Json.fromString("string")), + "password" -> Json.obj("type" -> Json.fromString("string"))), + "default" -> Json.obj("login" -> Json.fromString("anonymous"), "password" -> Json.fromString("-")))), "required" -> Json.arr(Json.fromString("age"), Json.fromString("lastName"), Json.fromString("firstName"))) } } object AsCirceSpec { - case class UserProfile( - firstName: String, - middleName: Option[String], - lastName: String, - age: Int, - active: Boolean = true) - - implicit val jsValEq: Equality[Json] = new Equality[Json] { override def areEqual(a: Json, b: Any): Boolean = a match { case Json.Null => b == Json.Null diff --git a/circe-json/src/test/scala/com/github/andyglow/jsonschema/UserProfileJson.scala b/circe-json/src/test/scala/com/github/andyglow/jsonschema/UserProfileJson.scala new file mode 100644 index 00000000..f985cd20 --- /dev/null +++ b/circe-json/src/test/scala/com/github/andyglow/jsonschema/UserProfileJson.scala @@ -0,0 +1,29 @@ +package com.github.andyglow.jsonschema + + +import com.github.andyglow.jsonschema.model._ +import io.circe._ +import io.circe.generic.semiauto._ + +object UserProfileJson { + + implicit val CredentialsW: Encoder[Credentials] = deriveEncoder[Credentials] + + implicit val BetaFeatureW: Encoder[BetaFeature] = new Encoder[BetaFeature] { + override def apply(o: BetaFeature): Json = o match { + case F0 => Json.fromString("feature-0-name") + case F1 => Json.fromString("feature-1-name") + case F2 => Json.fromString("feature-2-name") + } + } + + implicit val RoleW: Encoder[Role] = new Encoder[Role] { + import Role._ + + override def apply(o: Role): Json = o match { + case User => Json.fromString("e-user") + case Manager => Json.fromString("e-manager") + case Admin => Json.fromString("e-admin") + } + } +} diff --git a/core/src/main/scala-2.11/com/github/andyglow/json/LowPriorityArrayImplicits.scala b/core/src/main/scala-2.11/com/github/andyglow/json/LowPriorityArrayImplicits.scala new file mode 100644 index 00000000..d3274f78 --- /dev/null +++ b/core/src/main/scala-2.11/com/github/andyglow/json/LowPriorityArrayImplicits.scala @@ -0,0 +1,16 @@ +package com.github.andyglow.json + + +trait LowPriorityArrayImplicits { this: LowPriorityPrimitiveImplicits => + import Value._ + import ToValue._ + + implicit def ArrV[T, C[_] <: Traversable[_]](implicit + to: ToValue[T]): ToValue[C[T]] = { + + mk { items => + val v = items map { v: Any => to(v.asInstanceOf[T]) } + arr(v.toSeq) + } + } +} diff --git a/core/src/main/scala-2.12/com/github/andyglow/json/LowPriorityArrayImplicits.scala b/core/src/main/scala-2.12/com/github/andyglow/json/LowPriorityArrayImplicits.scala new file mode 100644 index 00000000..609f7868 --- /dev/null +++ b/core/src/main/scala-2.12/com/github/andyglow/json/LowPriorityArrayImplicits.scala @@ -0,0 +1,16 @@ +package com.github.andyglow.json + + +trait LowPriorityArrayImplicits { this: LowPriorityPrimitiveImplicits => + import Value._ + import ToValue._ + + implicit def ArrV[T, C[_] <: Traversable[_]](implicit + to: ToValue[T]): ToValue[C[T]] = { + + mk { items => + val v = items map { v: Any => to(v.asInstanceOf[T]) } + arr(v.toSeq) + } + } +} \ No newline at end of file diff --git a/core/src/main/scala-2.13/com/github/andyglow/json/LowPriorityArrayImplicits.scala b/core/src/main/scala-2.13/com/github/andyglow/json/LowPriorityArrayImplicits.scala new file mode 100644 index 00000000..e6747d87 --- /dev/null +++ b/core/src/main/scala-2.13/com/github/andyglow/json/LowPriorityArrayImplicits.scala @@ -0,0 +1,16 @@ +package com.github.andyglow.json + + +trait LowPriorityArrayImplicits { this: LowPriorityPrimitiveImplicits => + import Value._ + import ToValue._ + + implicit def ArrV[T, C[_] <: Iterable[_]](implicit + to: ToValue[T]): ToValue[C[T]] = { + + mk { items => + val v = items map { v: Any => to(v.asInstanceOf[T]) } + arr(v.toSeq) + } + } +} \ No newline at end of file diff --git a/core/src/main/scala/com/github/andyglow/json/ToValue.scala b/core/src/main/scala/com/github/andyglow/json/ToValue.scala new file mode 100644 index 00000000..bd2503dd --- /dev/null +++ b/core/src/main/scala/com/github/andyglow/json/ToValue.scala @@ -0,0 +1,68 @@ +package com.github.andyglow.json + + +trait ToValue[T] { + + def apply(x: T): Value +} + +trait LowPriorityPrimitiveImplicits { + import Value._ + import ToValue._ + + implicit val BoolV: ToValue[Boolean] = mk(bool.apply) + implicit val IntV: ToValue[Int] = mk(num.apply) + implicit val LongV: ToValue[Long] = mk(num.apply) + implicit val ShortV: ToValue[Short] = mk(num.apply) + implicit val FloatV: ToValue[Float] = mk(num.apply) + implicit val DoubleV: ToValue[Double] = mk(num.apply) + implicit val BigDecimalV: ToValue[BigDecimal] = mk(num.apply) + implicit val BigIntV: ToValue[BigInt] = mk(num.apply) + implicit val NumberV: ToValue[Number] = mk(num.apply) + implicit val StringV: ToValue[String] = mk(str.apply) + implicit val NullV: ToValue[Null] = mk(_ => `null`) + + // NOTICE: there are no way to work around objects (case classes) as long as different libraries + // may generate different json representations out of the single case class instance +} + +trait LowPriorityMapImplicits { this: LowPriorityPrimitiveImplicits => + import Value._ + import ToValue._ + + implicit def StrMapV[T](implicit + to: ToValue[T]): ToValue[Map[String, T]] = { + + mk { items => + val v = items map { case (k, v) => (k, to(v)) } + obj(v.toMap) + } + } + + implicit def IntMapV[T](implicit + to: ToValue[T]): ToValue[Map[Int, T]] = { + + mk { items => + val v = items map { case (k, v) => (k.toString, to(v)) } + obj(v.toMap) + } + } +} + +trait LowPriorityProductImplicits { + import Value._ + import ToValue._ + + implicit def ProductV[T <: Product]: ToValue[T] = mk(p => str(p.productPrefix)) +} +trait LowPriorityImplicits + extends LowPriorityPrimitiveImplicits + with LowPriorityArrayImplicits + with LowPriorityMapImplicits + +object ToValue extends LowPriorityImplicits { + + def mk[T](f: T => Value): ToValue[T] = new ToValue[T] { def apply(x: T): Value = f(x) } + + def apply[T](x: T)(implicit to: ToValue[T]): Value = to(x) +} \ No newline at end of file diff --git a/core/src/main/scala/com/github/andyglow/json/Value.scala b/core/src/main/scala/com/github/andyglow/json/Value.scala index 8091f581..4be06a6e 100644 --- a/core/src/main/scala/com/github/andyglow/json/Value.scala +++ b/core/src/main/scala/com/github/andyglow/json/Value.scala @@ -4,7 +4,7 @@ import scala.collection._ sealed trait Value { - override def toString: String + def toString: String } object Value { diff --git a/core/src/main/scala/com/github/andyglow/jsonschema/AsDraftSupport.scala b/core/src/main/scala/com/github/andyglow/jsonschema/AsDraftSupport.scala index fe2f463a..ee8f0331 100644 --- a/core/src/main/scala/com/github/andyglow/jsonschema/AsDraftSupport.scala +++ b/core/src/main/scala/com/github/andyglow/jsonschema/AsDraftSupport.scala @@ -25,7 +25,9 @@ trait AsDraftSupport { def mkObj(pp: Option[ValidationDef[_, _]], x: `object`[_]): obj = { val props = x.fields.map { field => - field.name -> apply(field.tpe) + val d = field.default map { d => obj("default" -> d) } getOrElse obj() + + field.name -> ( d ++ apply(field.tpe)) }.toMap val required = x.fields collect { @@ -62,7 +64,7 @@ trait AsDraftSupport { def mkEnum(pp: Option[ValidationDef[_, _]], x: `enum`[_]): obj = { obj( "type" -> "string", - "enum" -> x.values.toArr) + "enum" -> arr(x.values.toSeq)) } def mkOneOf(pp: Option[ValidationDef[_, _]], x: `oneof`[_]): obj = { diff --git a/core/src/main/scala/json/Schema.scala b/core/src/main/scala/json/Schema.scala index a5c6eae3..c9623324 100644 --- a/core/src/main/scala/json/Schema.scala +++ b/core/src/main/scala/json/Schema.scala @@ -1,7 +1,8 @@ package json +import com.github.andyglow.json.{ToValue, Value} + import scala.annotation.implicitNotFound -import scala.language.higherKinds sealed trait Schema[+T] extends Product { @@ -66,7 +67,7 @@ object Schema { final case class `object`[T](fields: Set[`object`.Field[_]]) extends Schema[T] - final case class `enum`[T](values: Set[String]) extends Schema[T] + final case class `enum`[T](values: Set[Value]) extends Schema[T] final case class `oneof`[T](subTypes: Set[Schema[_]]) extends Schema[T] @@ -119,9 +120,13 @@ object Schema { } } - object `object` { + final object `object` { - final case class Field[T](name: String, tpe: Schema[T], required: Boolean = true) { + final class Field[T]( + val name: String, + val tpe: Schema[T], + val required: Boolean, + val default: Option[Value]) { def canEqual(that: Any): Boolean = that.isInstanceOf[Field[T]] @@ -129,11 +134,59 @@ object Schema { val other = that.asInstanceOf[Field[T]] this.name == other.name && - this.required == other.required && - this.tpe == other.tpe + this.required == other.required && + this.tpe == other.tpe && + this.default == other.default } override def hashCode: Int = name.hashCode + + override def toString: String = { + val extra = (required, default) match { + case (true, None) => " /R" + case (false, None) => "" + case (true, Some(v)) => s" /R /$v" + case (false, Some(v)) => s" /$v" + } + + s"$name: ${tpe}$extra" + } + } + + final object Field { + + def apply[T]( + name: String, + tpe: Schema[T]): Field[T] = { + + new Field(name, tpe, required = true, default = None) + } + + def apply[T]( + name: String, + tpe: Schema[T], + required: Boolean): Field[T] = { + + new Field(name, tpe, required, default = None) + } + + def apply[T: ToValue]( + name: String, + tpe: Schema[T], + required: Boolean, + default: T): Field[T] = { + + new Field(name, tpe, required, Some(ToValue(default))) + } + + def fromJson[T]( + name: String, + tpe: Schema[T], + required: Boolean, + default: Option[Value]): Field[T] = { + + new Field(name, tpe, required, default) + } } def apply[T](field: Field[_], xs: Field[_]*): `object`[T] = new `object`((field +: xs.toSeq).toSet) diff --git a/json4s-json/src/main/scala/com/github/andyglow/jsonschema/AsJson4s.scala b/json4s-json/src/main/scala/com/github/andyglow/jsonschema/AsJson4s.scala index 419227c4..e80f153a 100644 --- a/json4s-json/src/main/scala/com/github/andyglow/jsonschema/AsJson4s.scala +++ b/json4s-json/src/main/scala/com/github/andyglow/jsonschema/AsJson4s.scala @@ -5,6 +5,9 @@ import com.github.andyglow.json._ import json.Schema import json.schema.Version import org.json4s.JsonAST._ +import com.github.andyglow.scalamigration._ +import org.json4s.Writer + object AsJson4s { @@ -20,11 +23,12 @@ object AsJson4s { type P def adapt(x: T): P + def unadapt(x: P): T } trait LowPriorityAdapter { - implicit val anyAdapter: Adapter.Aux[Value, JValue] = Adapter make { + implicit val anyAdapter: Adapter.Aux[Value, JValue] = Adapter.make({ case `null` => JNull case `true` => JBool.True case `false` => JBool.False @@ -32,30 +36,59 @@ object AsJson4s { case x: str => Adapter.strAdapter.adapt(x) case x: arr => Adapter.arrAdapter.adapt(x) case x: obj => Adapter.objAdapter.adapt(x) - } + }, { + case JNothing => `null` + case JNull => `null` + case JBool(x) => bool(x) + case JDouble(x) => num(x) + case JDecimal(x) => num(x) + case JLong(x) => num(x) + case JInt(x) => num(x) + case JString(x) => str(x) + case JSet(x) => Adapter.arrAdapter.unadapt(JArray(x.toList)) + case x: JArray => Adapter.arrAdapter.unadapt(x) + case x: JObject => Adapter.objAdapter.unadapt(x) + }) } object Adapter extends LowPriorityAdapter { type Aux[T, PP] = Adapter[T] { type P = PP } - def make[T, PP](f: T => PP): Aux[T, PP] = new Adapter[T] { + def adapt[T, PP](value: T)(implicit a: Aux[T, PP]): PP = a.adapt(value) + + def unadapt[T, PP](value: PP)(implicit a: Aux[T, PP]): T = a.unadapt(value) + + def make[T, PP](to: T => PP, from: PP => T): Aux[T, PP] = new Adapter[T] { type P = PP - def adapt(x: T): PP = f(x) + def adapt(x: T): PP = to(x) + def unadapt(x: PP): T = from(x) } - implicit val nullAdapter: Aux[`null`.type, JNull.type] = make(_ => JNull) - implicit val trueAdapter: Aux[`true`.type, JBool] = make(_ => JBool.True) - implicit val falseAdapter: Aux[`false`.type, JBool] = make(_ => JBool.False) - implicit val numAdapter: Aux[num, JDecimal] = make(x => JDecimal(x.value)) - implicit val strAdapter: Aux[str, JString] = make(x => JString(x.value)) - implicit val arrAdapter: Aux[arr, JArray] = make(x => JArray { x.value.toList map { AsJson4s(_) } }) - implicit val objAdapter: Aux[obj, JObject] = make { x => + implicit val nullAdapter: Aux[`null`.type, JNull.type] = make(_ => JNull, _ => `null`) + implicit val trueAdapter: Aux[`true`.type, JBool] = make(_ => JBool.True, _ => `true`) + implicit val falseAdapter: Aux[`false`.type, JBool] = make(_ => JBool.False, _ => `false`) + implicit val numAdapter: Aux[num, JDecimal] = make(x => JDecimal(x.value), x => num(x.values)) + implicit val strAdapter: Aux[str, JString] = make(x => JString(x.value), x => str(x.values)) + implicit val arrAdapter: Aux[arr, JArray] = make( + x => JArray { x.value.toList map { adapt(_) } }, + x => arr { x.arr map { unadapt(_) }}) + implicit val objAdapter: Aux[obj, JObject] = make({ x => val fields = x.value.toList.map { case (k, v) => JField(k, AsJson4s.apply(v)) } JObject(fields) + }, { x => + obj { x.obj.toMap mapV { unadapt(_) } } + }) + } + + + implicit def toValue[T](implicit w: Writer[T]): ToValue[T] = new ToValue[T] { + override def apply(x: T): Value = { + val js = w.write(x) + Adapter.unadapt(js) } } } diff --git a/json4s-json/src/test/scala/com/github/andyglow/jsonschema/AsJson4sSpec.scala b/json4s-json/src/test/scala/com/github/andyglow/jsonschema/AsJson4sSpec.scala index 3e924e2e..6dbecee7 100644 --- a/json4s-json/src/test/scala/com/github/andyglow/jsonschema/AsJson4sSpec.scala +++ b/json4s-json/src/test/scala/com/github/andyglow/jsonschema/AsJson4sSpec.scala @@ -5,12 +5,14 @@ import org.scalatest._ import org.scalatest.Matchers._ import org.scalatest.prop.TableDrivenPropertyChecks._ import com.github.andyglow.json.Value._ +import com.github.andyglow.jsonschema.model.UserProfile import json.schema.Version.Draft04 import org.json4s.JsonAST._ import org.scalactic.Equality class AsJson4sSpec extends PropSpec{ import AsJson4sSpec._ + import UserProfileJson._ private val examples = Table[Value, JValue]( ("json" , "PlayJson"), @@ -40,21 +42,22 @@ class AsJson4sSpec extends PropSpec{ "middleName" -> JObject("type" -> JString("string")), "lastName" -> JObject("type" -> JString("string")), "age" -> JObject("type" -> JString("integer")), - "active" -> JObject("type" -> JString("boolean"))), + "role" -> JObject("type" -> JString("string"), "default" -> JString("e-user"), "enum" -> JArray(List(JString("e-admin"), JString("e-manager"), JString("e-user")))), + "active" -> JObject("type" -> JString("string"), "default" -> JString("On"), "enum" -> JArray(List(JString("On"), JString("Off"), JString("Suspended")))), + "enabledFeatures" -> JObject("type" -> JString("array"), "uniqueItems" -> JBool.True, "default" -> JArray(List(JString("feature-0-name"), JString("feature-1-name"))), "items" -> JObject("type" -> JString("string"), "enum" -> JArray(List(JString("feature-0-name"), JString("feature-1-name"), JString("feature-2-name"))))), + "credentials" -> JObject("type" -> JString("object"), + "additionalProperties" -> JBool.False, + "required" -> JArray(List(JString("login"), JString("password"))), + "properties" -> JObject( + "login" -> JObject("type" -> JString("string")), + "password" -> JObject("type" -> JString("string"))), + "default" -> JObject("login" -> JString("anonymous"), "password" -> JString("-")))), "required" -> JArray(List(JString("age"), JString("lastName"), JString("firstName")))) } } object AsJson4sSpec { - case class UserProfile( - firstName: String, - middleName: Option[String], - lastName: String, - age: Int, - active: Boolean = true) - - implicit val jsValEq: Equality[JValue] = new Equality[JValue] { override def areEqual(a: JValue, b: Any): Boolean = a match { case JNull => b == JNull diff --git a/json4s-json/src/test/scala/com/github/andyglow/jsonschema/UserProfileJson.scala b/json4s-json/src/test/scala/com/github/andyglow/jsonschema/UserProfileJson.scala new file mode 100644 index 00000000..efc718ea --- /dev/null +++ b/json4s-json/src/test/scala/com/github/andyglow/jsonschema/UserProfileJson.scala @@ -0,0 +1,32 @@ +package com.github.andyglow.jsonschema + +import com.github.andyglow.jsonschema.model._ +import org.json4s.Writer +import org.json4s.JsonAST._ + +object UserProfileJson { + + implicit val CredentialsW: Writer[Credentials] = new Writer[Credentials] { + override def write(o: Credentials): JValue = JObject( + "login" -> JString(o.login), + "password" -> JString(o.password)) + } + + implicit val BetaFeatureW: Writer[BetaFeature] = new Writer[BetaFeature] { + override def write(o: BetaFeature): JValue = o match { + case F0 => JString("feature-0-name") + case F1 => JString("feature-1-name") + case F2 => JString("feature-2-name") + } + } + + implicit val RoleW: Writer[Role] = new Writer[Role] { + import Role._ + + override def write(o: Role): JValue = o match { + case User => JString("e-user") + case Manager => JString("e-manager") + case Admin => JString("e-admin") + } + } +} diff --git a/macros/src/main/scala/com/github/andyglow/jsonschema/SchemaMacro.scala b/macros/src/main/scala/com/github/andyglow/jsonschema/SchemaMacro.scala index 7fd10375..67d37a89 100644 --- a/macros/src/main/scala/com/github/andyglow/jsonschema/SchemaMacro.scala +++ b/macros/src/main/scala/com/github/andyglow/jsonschema/SchemaMacro.scala @@ -3,6 +3,8 @@ package com.github.andyglow.jsonschema import java.net.{URI, URL} import java.util.UUID +import com.github.andyglow.json.ToValue + import scala.reflect.macros.blackbox object SchemaMacro { @@ -10,12 +12,14 @@ object SchemaMacro { def impl[T : c.WeakTypeTag](c: blackbox.Context): c.Expr[json.Schema[T]] = { import c.universe._ - val jsonPkg = q"_root_.json" - val scalaPkg = q"_root_.scala" - val schemaObj = q"$jsonPkg.Schema" + val jsonPkg = q"_root_.json" + val intJsonPkg = q"_root_.com.github.andyglow.json" + val scalaPkg = q"_root_.scala" + val schemaObj = q"$jsonPkg.Schema" val subject = weakTypeOf[T] val optionTpe = weakTypeOf[Option[_]] + val toValueTpe = weakTypeOf[ToValue[_]] val setTpe = weakTypeOf[Set[_]] val jsonTypeConstructor = weakTypeOf[json.Schema[_]].typeConstructor val jsonSubject = appliedType(jsonTypeConstructor, subject) @@ -103,19 +107,37 @@ object SchemaMacro { object SE { - def unapply(tpe: Type): Option[Set[String]] = { + def unapply(tpe: Type): Option[Set[Tree]] = { + if (tpe.typeSymbol.isClass && tpe.typeSymbol.asClass.isSealed) { val instances = tpe.typeSymbol.asClass.knownDirectSubclasses - - if (instances forall { i => val c = i.asClass; c.isModuleClass && c.isCaseClass}) { - Some(instances map { _.name.decodedName.toString }) + val toValueTree = c.inferImplicitValue( + appliedType(toValueTpe, tpe), + silent = true, + withMacrosDisabled = true) + + if (instances forall { i => val c = i.asClass; c.isModuleClass}) { + if (toValueTree.nonEmpty) { + Some(instances collect { + case i: ClassSymbol => + val caseObj = i.owner.asClass.toType.decls.find { d => + d.name == i.name.toTermName + } getOrElse NoSymbol + + q"$toValueTree($caseObj)" + }) + } else { + Some(instances map { i => q"$intJsonPkg.Value.str(${i.name.decodedName.toString})" }) + } } else None } else None } - def gen(tpe: Type, names: Set[String]): Tree = q"$schemaObj.`enum`[$tpe]($names)" + def gen(tpe: Type, symbols: Set[Tree]): Tree = { + q"$schemaObj.`enum`[$tpe]($symbols)" + } } object SC { @@ -148,9 +170,7 @@ object SchemaMacro { final def lookupCompanionOf(clazz: Symbol): Symbol = clazz.companion - def possibleApplyMethodsOf(tpe: Type): List[MethodSymbol] = { - val subjectCompanionSym = tpe.typeSymbol - val subjectCompanion = lookupCompanionOf(subjectCompanionSym) + def possibleApplyMethodsOf(subjectCompanion: Symbol): List[MethodSymbol] = { val subjectCompanionTpe = subjectCompanion.typeSignature subjectCompanionTpe.decl(TermName("apply")) match { @@ -174,15 +194,19 @@ object SchemaMacro { } } - def applyMethod(tpe: Type): Option[MethodSymbol] = possibleApplyMethodsOf(tpe).headOption + def applyMethod(subjectCompanion: Symbol): Option[MethodSymbol] = + possibleApplyMethodsOf(subjectCompanion).headOption case class Field( name: TermName, tpe: Type, effectiveTpe: Type, annotations: List[Annotation], - hasDefault: Boolean, - isOption: Boolean) + default: Option[Tree], + isOption: Boolean) { + + def hasDefault: Boolean = default.isDefined + } def fieldMap(tpe: Type): Seq[Field] = { @@ -196,25 +220,36 @@ object SchemaMacro { s.name.toString.trim -> s.accessed.annotations }.toMap - def toField(fieldSym: TermSymbol): Field = { + val subjectCompanionSym = tpe.typeSymbol + val subjectCompanion = lookupCompanionOf(subjectCompanionSym) + + def toField(fieldSym: TermSymbol, i: Int): Field = { val name = fieldSym.name.toString.trim val fieldTpe = fieldSym.typeSignature val isOption = fieldTpe <:< optionTpe + val hasDefault = fieldSym.isParamWithDefault + val toV = c.inferImplicitValue(appliedType(toValueTpe, fieldTpe)) + val default = if (hasDefault) { + val getter = TermName("apply$default$" + (i + 1)) + if (toV.nonEmpty) Some(q"Some($toV($subjectCompanion.$getter))") else { + c.error(c.enclosingPosition, s"Can't infer a json value for $name") + None + } + } else + None Field( name = TermName(name), tpe = fieldTpe, effectiveTpe = if (isOption) fieldTpe.typeArgs.head else fieldTpe, annotations = annotationMap.getOrElse(name, List.empty), - isOption = isOption, - hasDefault = fieldSym.isParamWithDefault) + default = default, + isOption = isOption) } - val fields = applyMethod(tpe) flatMap { method => + val fields = applyMethod(subjectCompanion) flatMap { method => method.paramLists.headOption map { params => - val fields = params map { _.asTerm } map toField - - fields.toSeq + params.map { _.asTerm }.zipWithIndex map { case (f, i) => toField(f, i) } } } @@ -240,7 +275,11 @@ object SchemaMacro { val name = f.name.decodedName.toString val jsonType = resolve(f.effectiveTpe, if (f.isOption) stack else tpe +: stack) - q"$obj.Field[${f.effectiveTpe}](name = $name, tpe = $jsonType, required = ${ !f.isOption && !f.hasDefault })" + f.default map { d => + q"$obj.Field.fromJson[${f.effectiveTpe}](name = $name, tpe = $jsonType, required = ${ !f.isOption && !f.hasDefault }, default = $d)" + } getOrElse { + q"$obj.Field[${f.effectiveTpe}](name = $name, tpe = $jsonType, required = ${ !f.isOption && !f.hasDefault })" + } } q"$obj[$tpe](..$fields)" diff --git a/parser/src/main/scala/com/github/andyglow/jsonschema/ParseJsonSchema.scala b/parser/src/main/scala/com/github/andyglow/jsonschema/ParseJsonSchema.scala index c60f8b98..55959427 100644 --- a/parser/src/main/scala/com/github/andyglow/jsonschema/ParseJsonSchema.scala +++ b/parser/src/main/scala/com/github/andyglow/jsonschema/ParseJsonSchema.scala @@ -51,7 +51,7 @@ object ParseJsonSchema { def makeStrOrEnum = x.value.arr("enum") match { case None => makeStr - case Some(arr) => Success { `enum`((arr collect { case str(x) => x }).toSet) } + case Some(arr) => Success { `enum`(arr.toSet) } } def makeStr = Success { diff --git a/play-json/src/main/scala/com/github/andyglow/jsonschema/AsPlay.scala b/play-json/src/main/scala/com/github/andyglow/jsonschema/AsPlay.scala index 2b2ffa78..f5cf9194 100644 --- a/play-json/src/main/scala/com/github/andyglow/jsonschema/AsPlay.scala +++ b/play-json/src/main/scala/com/github/andyglow/jsonschema/AsPlay.scala @@ -20,11 +20,14 @@ object AsPlay { type P def adapt(x: T): P + + // TODO: adding this we turn entire Adapter idea into isomorphism, so consider renaming it into ISO + def unadapt(x: P): T } trait LowPriorityAdapter { - implicit val anyAdapter: Adapter.Aux[Value, JsValue] = Adapter make { + implicit val anyAdapter: Adapter.Aux[Value, JsValue] = Adapter.make({ case `null` => JsNull case `true` => JsTrue case `false` => JsFalse @@ -32,24 +35,48 @@ object AsPlay { case x: str => Adapter.strAdapter.adapt(x) case x: arr => Adapter.arrAdapter.adapt(x) case x: obj => Adapter.objAdapter.adapt(x) - } + }, { + case JsNull => `null` + case JsTrue => `true` + case JsFalse => `false` + case x: JsNumber => Adapter.numAdapter.unadapt(x) + case x: JsString => Adapter.strAdapter.unadapt(x) + case x: JsArray => Adapter.arrAdapter.unadapt(x) + case x: JsObject => Adapter.objAdapter.unadapt(x) + }) } object Adapter extends LowPriorityAdapter { type Aux[T, PP] = Adapter[T] { type P = PP } - def make[T, PP](f: T => PP): Aux[T, PP] = new Adapter[T] { + def adapt[T, PP](value: T)(implicit a: Aux[T, PP]): PP = a.adapt(value) + + def unadapt[T, PP](value: PP)(implicit a: Aux[T, PP]): T = a.unadapt(value) + + def make[T, PP](t: T => PP, f: PP => T): Aux[T, PP] = new Adapter[T] { type P = PP - def adapt(x: T): PP = f(x) + def adapt(x: T): PP = t(x) + def unadapt(x: PP): T = f(x) } - implicit val nullAdapter: Aux[`null`.type, JsNull.type] = make(_ => JsNull) - implicit val trueAdapter: Aux[`true`.type, JsBoolean] = make(_ => JsTrue) - implicit val falseAdapter: Aux[`false`.type, JsBoolean] = make(_ => JsFalse) - implicit val numAdapter: Aux[num, JsNumber] = make(x => JsNumber(x.value)) - implicit val strAdapter: Aux[str, JsString] = make(x => JsString(x.value)) - implicit val arrAdapter: Aux[arr, JsArray] = make(x => JsArray { x.value.toSeq map { AsPlay(_) } }) - implicit val objAdapter: Aux[obj, JsObject] = make(x => JsObject { x.value.toMap mapV { AsPlay(_) } }) + implicit val nullAdapter: Aux[`null`.type, JsNull.type] = make(_ => JsNull, _ => `null`) + implicit val trueAdapter: Aux[`true`.type, JsBoolean] = make(_ => JsTrue, _ => `true`) + implicit val falseAdapter: Aux[`false`.type, JsBoolean] = make(_ => JsFalse, _ => `false`) + implicit val numAdapter: Aux[num, JsNumber] = make(x => JsNumber(x.value), x => num(x.value)) + implicit val strAdapter: Aux[str, JsString] = make(x => JsString(x.value), x => str(x.value)) + implicit val arrAdapter: Aux[arr, JsArray] = make( + x => JsArray { x.value.toSeq map { adapt(_) } }, + x => arr { x.value map { unadapt(_) }}) + implicit val objAdapter: Aux[obj, JsObject] = make( + x => JsObject { x.value.toMap mapV { adapt(_) } }, + x => obj { x.value.toMap mapV { unadapt(_) } }) + } + + implicit def toValue[T](implicit w: Writes[T]): ToValue[T] = new ToValue[T] { + override def apply(x: T): Value = { + val js = w.writes(x) + Adapter.unadapt(js) + } } } diff --git a/play-json/src/test/scala/com/github/andyglow/jsonschema/AsPlaySpec.scala b/play-json/src/test/scala/com/github/andyglow/jsonschema/AsPlaySpec.scala index 71876518..57da1647 100644 --- a/play-json/src/test/scala/com/github/andyglow/jsonschema/AsPlaySpec.scala +++ b/play-json/src/test/scala/com/github/andyglow/jsonschema/AsPlaySpec.scala @@ -5,12 +5,14 @@ import org.scalatest._ import org.scalatest.Matchers._ import org.scalatest.prop.TableDrivenPropertyChecks._ import com.github.andyglow.json.Value._ +import com.github.andyglow.jsonschema.model.UserProfile import json.schema.Version.Draft04 import org.scalactic.Equality import play.api.libs.json._ class AsPlaySpec extends PropSpec{ import AsPlaySpec._ + import UserProfileJson._ private val examples = Table[Value, JsValue]( ("json" , "PlayJson"), @@ -40,19 +42,23 @@ class AsPlaySpec extends PropSpec{ "middleName" -> Json.obj("type" -> "string"), "lastName" -> Json.obj("type" -> "string"), "age" -> Json.obj("type" -> "integer"), - "active" -> Json.obj("type" -> "boolean")), + "role" -> Json.obj("type" -> "string", "default" -> "e-user", "enum" -> Json.arr("e-admin","e-manager","e-user")), + "active" -> Json.obj("type" -> "string", "default" -> "On", "enum" -> Json.arr("On", "Off", "Suspended")), + "enabledFeatures" -> Json.obj("type" -> "array", "uniqueItems" -> true, "default" -> Json.arr("feature-0-name", "feature-1-name"), "items" -> Json.obj("type" -> "string", "enum" -> Json.arr("feature-0-name", "feature-1-name", "feature-2-name"))), + "credentials" -> Json.obj("type" -> "object", + "additionalProperties" -> false, + "required" -> Json.arr("login", "password"), + "properties" -> Json.obj( + "login" -> Json.obj("type" -> "string"), + "password" -> Json.obj("type" -> "string")), + "default" -> Json.obj("login" -> "anonymous", "password" -> "-"))), "required" -> Json.arr("age", "lastName", "firstName")) } } -object AsPlaySpec { - case class UserProfile( - firstName: String, - middleName: Option[String], - lastName: String, - age: Int, - active: Boolean = true) + +object AsPlaySpec { implicit val jsValEq: Equality[JsValue] = new Equality[JsValue] { override def areEqual(a: JsValue, b: Any): Boolean = a match { diff --git a/play-json/src/test/scala/com/github/andyglow/jsonschema/UserProfileJson.scala b/play-json/src/test/scala/com/github/andyglow/jsonschema/UserProfileJson.scala new file mode 100644 index 00000000..4c342639 --- /dev/null +++ b/play-json/src/test/scala/com/github/andyglow/jsonschema/UserProfileJson.scala @@ -0,0 +1,27 @@ +package com.github.andyglow.jsonschema + +import com.github.andyglow.jsonschema.model._ +import play.api.libs.json._ + +object UserProfileJson { + + implicit val CredentialsW: Writes[Credentials] = Json.writes[Credentials] + + implicit val BetaFeatureW: Writes[BetaFeature] = new Writes[BetaFeature] { + override def writes(o: BetaFeature): JsValue = o match { + case F0 => JsString("feature-0-name") + case F1 => JsString("feature-1-name") + case F2 => JsString("feature-2-name") + } + } + + implicit val RoleW: Writes[Role] = new Writes[Role] { + import Role._ + + override def writes(o: Role): JsValue = o match { + case User => JsString("e-user") + case Manager => JsString("e-manager") + case Admin => JsString("e-admin") + } + } +} diff --git a/project/build.properties b/project/build.properties index c03cdb80..010613d5 100644 --- a/project/build.properties +++ b/project/build.properties @@ -1 +1 @@ -sbt.version = 1.2.7 \ No newline at end of file +sbt.version = 1.3.3 \ No newline at end of file diff --git a/spray-json/src/main/scala/com/github/andyglow/jsonschema/AsSpray.scala b/spray-json/src/main/scala/com/github/andyglow/jsonschema/AsSpray.scala index 92858b12..ec87e155 100644 --- a/spray-json/src/main/scala/com/github/andyglow/jsonschema/AsSpray.scala +++ b/spray-json/src/main/scala/com/github/andyglow/jsonschema/AsSpray.scala @@ -21,11 +21,12 @@ object AsSpray { type P def adapt(x: T): P + def unadapt(x: P): T } trait LowPriorityAdapter { - implicit val anyAdapter: Adapter.Aux[Value, JsValue] = Adapter make { + implicit val anyAdapter: Adapter.Aux[Value, JsValue] = Adapter.make({ case `null` => JsNull case `true` => JsTrue case `false` => JsFalse @@ -33,24 +34,49 @@ object AsSpray { case x: str => Adapter.strAdapter.adapt(x) case x: arr => Adapter.arrAdapter.adapt(x) case x: obj => Adapter.objAdapter.adapt(x) - } + }, { + case JsNull => `null` + case JsTrue => `true` + case JsFalse => `false` + case x: JsNumber => Adapter.numAdapter.unadapt(x) + case x: JsString => Adapter.strAdapter.unadapt(x) + case x: JsArray => Adapter.arrAdapter.unadapt(x) + case x: JsObject => Adapter.objAdapter.unadapt(x) + }) } object Adapter extends LowPriorityAdapter { type Aux[T, PP] = Adapter[T] { type P = PP } - def make[T, PP](f: T => PP): Aux[T, PP] = new Adapter[T] { + def adapt[T, PP](value: T)(implicit a: Aux[T, PP]): PP = a.adapt(value) + + def unadapt[T, PP](value: PP)(implicit a: Aux[T, PP]): T = a.unadapt(value) + + def make[T, PP](to: T => PP, from: PP => T): Aux[T, PP] = new Adapter[T] { type P = PP - def adapt(x: T): PP = f(x) + def adapt(x: T): PP = to(x) + def unadapt(x: PP): T = from(x) } - implicit val nullAdapter: Aux[`null`.type, JsNull.type] = make(_ => JsNull) - implicit val trueAdapter: Aux[`true`.type, JsBoolean] = make(_ => JsTrue) - implicit val falseAdapter: Aux[`false`.type, JsBoolean] = make(_ => JsFalse) - implicit val numAdapter: Aux[num, JsNumber] = make(x => JsNumber(x.value)) - implicit val strAdapter: Aux[str, JsString] = make(x => JsString(x.value)) - implicit val arrAdapter: Aux[arr, JsArray] = make(x => JsArray { x.value.toVector map { AsSpray(_) } }) - implicit val objAdapter: Aux[obj, JsObject] = make(x => JsObject { x.value.toMap mapV { AsSpray(_) } }) + implicit val nullAdapter: Aux[`null`.type, JsNull.type] = make(_ => JsNull, _ => `null`) + implicit val trueAdapter: Aux[`true`.type, JsBoolean] = make(_ => JsTrue, _ => `true`) + implicit val falseAdapter: Aux[`false`.type, JsBoolean] = make(_ => JsFalse, _ => `false`) + implicit val numAdapter: Aux[num, JsNumber] = make(x => JsNumber(x.value), x => num(x.value)) + implicit val strAdapter: Aux[str, JsString] = make(x => JsString(x.value), x => str(x.value)) + implicit val arrAdapter: Aux[arr, JsArray] = make( + x => JsArray { x.value.toVector map { adapt(_) } }, + x => arr { x.elements map { unadapt(_) }}) + implicit val objAdapter: Aux[obj, JsObject] = make( + x => JsObject { x.value.toMap mapV { adapt(_) } }, + x => obj { x.fields.toMap mapV { unadapt(_) } }) + } + + + implicit def toValue[T](implicit w: JsonWriter[T]): ToValue[T] = new ToValue[T] { + override def apply(x: T): Value = { + val js = w.write(x) + Adapter.unadapt(js) + } } } diff --git a/spray-json/src/test/scala/com/github/andyglow/jsonschema/AsSpraySpec.scala b/spray-json/src/test/scala/com/github/andyglow/jsonschema/AsSpraySpec.scala index d8536ecf..1431ef9e 100644 --- a/spray-json/src/test/scala/com/github/andyglow/jsonschema/AsSpraySpec.scala +++ b/spray-json/src/test/scala/com/github/andyglow/jsonschema/AsSpraySpec.scala @@ -5,12 +5,14 @@ import org.scalatest._ import org.scalatest.Matchers._ import org.scalatest.prop.TableDrivenPropertyChecks._ import com.github.andyglow.json.Value._ +import com.github.andyglow.jsonschema.model.UserProfile import json.schema.Version.Draft04 import org.scalactic.Equality import spray.json._ class AsSpraySpec extends PropSpec { import AsSpraySpec._ + import UserProfileJson._ private val examples = Table[Value, JsValue]( ("json" , "SprayJson"), @@ -40,20 +42,22 @@ class AsSpraySpec extends PropSpec { "middleName" -> JsObject("type" -> JsString("string")), "lastName" -> JsObject("type" -> JsString("string")), "age" -> JsObject("type" -> JsString("integer")), - "active" -> JsObject("type" -> JsString("boolean"))), + "role" -> JsObject("type" -> JsString("string"), "default" -> JsString("e-user"), "enum" -> JsArray(JsString("e-admin"), JsString("e-manager"), JsString("e-user"))), + "active" -> JsObject("type" -> JsString("string"), "default" -> JsString("On"), "enum" -> JsArray(JsString("On"), JsString("Off"), JsString("Suspended"))), + "enabledFeatures" -> JsObject("type" -> JsString("array"), "uniqueItems" -> JsTrue, "default" -> JsArray(JsString("feature-0-name"), JsString("feature-1-name")), "items" -> JsObject("type" -> JsString("string"), "enum" -> JsArray(JsString("feature-0-name"), JsString("feature-1-name"), JsString("feature-2-name")))), + "credentials" -> JsObject("type" -> JsString("object"), + "additionalProperties" -> JsFalse, + "required" -> JsArray(JsString("login"), JsString("password")), + "properties" -> JsObject( + "login" -> JsObject("type" -> JsString("string")), + "password" -> JsObject("type" -> JsString("string"))), + "default" -> JsObject("login" -> JsString("anonymous"), "password" -> JsString("-")))), "required" -> JsArray(JsString("age"), JsString("lastName"), JsString("firstName"))) } } object AsSpraySpec { - case class UserProfile( - firstName: String, - middleName: Option[String], - lastName: String, - age: Int, - active: Boolean = true) - implicit val jsValEq: Equality[JsValue] = new Equality[JsValue] { override def areEqual(a: JsValue, b: Any): Boolean = a match { case JsNull => b == JsNull diff --git a/spray-json/src/test/scala/com/github/andyglow/jsonschema/UserProfileJson.scala b/spray-json/src/test/scala/com/github/andyglow/jsonschema/UserProfileJson.scala new file mode 100644 index 00000000..b9d0ca91 --- /dev/null +++ b/spray-json/src/test/scala/com/github/andyglow/jsonschema/UserProfileJson.scala @@ -0,0 +1,27 @@ +package com.github.andyglow.jsonschema + +import com.github.andyglow.jsonschema.model._ +import spray.json._ + +object UserProfileJson extends DefaultJsonProtocol { + + implicit val CredentialsW: JsonWriter[Credentials] = jsonFormat2(Credentials) + + implicit val BetaFeatureW: JsonWriter[BetaFeature] = new JsonWriter[BetaFeature] { + override def write(o: BetaFeature): JsValue = o match { + case F0 => JsString("feature-0-name") + case F1 => JsString("feature-1-name") + case F2 => JsString("feature-2-name") + } + } + + implicit val RoleW: JsonWriter[Role] = new JsonWriter[Role] { + import Role._ + + override def write(o: Role): JsValue = o match { + case User => JsString("e-user") + case Manager => JsString("e-manager") + case Admin => JsString("e-admin") + } + } +} diff --git a/u-json/src/main/scala/com/github/andyglow/jsonschema/AsU.scala b/u-json/src/main/scala/com/github/andyglow/jsonschema/AsU.scala index 65113e7d..9071bf1d 100644 --- a/u-json/src/main/scala/com/github/andyglow/jsonschema/AsU.scala +++ b/u-json/src/main/scala/com/github/andyglow/jsonschema/AsU.scala @@ -5,7 +5,7 @@ import com.github.andyglow.json.Value._ import json.Schema import com.github.andyglow.scalamigration._ import json.schema.Version - +import upickle.default._ object AsU { @@ -20,11 +20,12 @@ object AsU { type P def adapt(x: T): P + def unadapt(x: P): T } trait LowPriorityAdapter { - implicit val anyAdapter: Adapter.Aux[Value, ujson.Value] = Adapter make { + implicit val anyAdapter: Adapter.Aux[Value, ujson.Value] = Adapter.make({ case `null` => ujson.Null case `true` => ujson.True case `false` => ujson.False @@ -32,24 +33,44 @@ object AsU { case x: str => Adapter.strAdapter.adapt(x) case x: arr => Adapter.arrAdapter.adapt(x) case x: obj => Adapter.objAdapter.adapt(x) - } + }, { + case ujson.Null => `null` + case ujson.True => `true` + case ujson.False => `false` + case x: ujson.Num => Adapter.numAdapter.unadapt(x) + case x: ujson.Str => Adapter.strAdapter.unadapt(x) + case x: ujson.Arr => Adapter.arrAdapter.unadapt(x) + case x: ujson.Obj => Adapter.objAdapter.unadapt(x) + }) } object Adapter extends LowPriorityAdapter { type Aux[T, PP] = Adapter[T] { type P = PP } - def make[T, PP](f: T => PP): Aux[T, PP] = new Adapter[T] { + def adapt[T, PP](value: T)(implicit a: Aux[T, PP]): PP = a.adapt(value) + + def unadapt[T, PP](value: PP)(implicit a: Aux[T, PP]): T = a.unadapt(value) + + def make[T, PP](to: T => PP, from: PP => T): Aux[T, PP] = new Adapter[T] { type P = PP - def adapt(x: T): PP = f(x) + def adapt(x: T): PP = to(x) + def unadapt(x: PP): T = from(x) } - implicit val nullAdapter: Aux[`null`.type, ujson.Null.type] = make(_ => ujson.Null) - implicit val trueAdapter: Aux[`true`.type, ujson.True.type] = make(_ => ujson.True) - implicit val falseAdapter: Aux[`false`.type, ujson.False.type] = make(_ => ujson.False) - implicit val numAdapter: Aux[num, ujson.Num] = make(x => ujson.Num(x.value.toDouble)) - implicit val strAdapter: Aux[str, ujson.Str] = make(x => ujson.Str(x.value)) - implicit val arrAdapter: Aux[arr, ujson.Arr] = make(x => x.value map { AsU(_) }) - implicit val objAdapter: Aux[obj, ujson.Obj] = make(x => x.value.toMap mapV { AsU(_) }) + implicit val nullAdapter: Aux[`null`.type, ujson.Null.type] = make(_ => ujson.Null, _ => `null`) + implicit val trueAdapter: Aux[`true`.type, ujson.True.type] = make(_ => ujson.True, _ => `true`) + implicit val falseAdapter: Aux[`false`.type, ujson.False.type] = make(_ => ujson.False, _ => `false`) + implicit val numAdapter: Aux[num, ujson.Num] = make(x => ujson.Num(x.value.toDouble), x => num(x.value)) + implicit val strAdapter: Aux[str, ujson.Str] = make(x => ujson.Str(x.value), x => str(x.value)) + implicit val arrAdapter: Aux[arr, ujson.Arr] = make(x => x.value map { adapt(_) }, x => arr { x.value map { unadapt(_) }}) + implicit val objAdapter: Aux[obj, ujson.Obj] = make(x => x.value.toMap mapV { adapt(_) }, x => obj { x.value.toMap mapV { unadapt(_) }}) + } + + implicit def toValue[T](implicit w: Writer[T]): ToValue[T] = new ToValue[T] { + override def apply(x: T): Value = { + val js = writeJs(x) + Adapter.unadapt(js) + } } } diff --git a/u-json/src/test/scala/com/github/andyglow/jsonschema/AsUSpec.scala b/u-json/src/test/scala/com/github/andyglow/jsonschema/AsUSpec.scala index 2b6989d0..4b1b6348 100644 --- a/u-json/src/test/scala/com/github/andyglow/jsonschema/AsUSpec.scala +++ b/u-json/src/test/scala/com/github/andyglow/jsonschema/AsUSpec.scala @@ -5,12 +5,14 @@ import org.scalatest._ import org.scalatest.Matchers._ import org.scalatest.prop.TableDrivenPropertyChecks._ import com.github.andyglow.json.Value._ +import com.github.andyglow.jsonschema.model.UserProfile import json.schema.Version._ import org.scalactic.Equality class AsUSpec extends PropSpec { import AsUSpec._ + import UserProfileJson._ private val examples = Table[Value, ujson.Value]( ("json" , "uJson"), @@ -34,14 +36,14 @@ class AsUSpec extends PropSpec { implicit val ratingSchema = json.Json.schema[Rating] ratingSchema.refName - json.Json.schema[UserProfile].asU(Draft04()) shouldEqual ujson.Obj( + json.Json.schema[User].asU(Draft04()) shouldEqual ujson.Obj( f"$$schema" -> ujson.Str("http://json-schema.org/draft-04/schema#"), "type" -> ujson.Str("object"), "additionalProperties" -> ujson.False, "properties" -> ujson.Obj( "rating" -> ujson.Obj(f"$$ref" -> ujson.Str("#/definitions/com.github.andyglow.jsonschema.AsUSpec.Rating")), "age" -> ujson.Obj("type" -> ujson.Str("integer")), - "active" -> ujson.Obj("type" -> ujson.Str("boolean")), + "active" -> ujson.Obj("type" -> ujson.Str("boolean"), "default" -> ujson.True), "name" -> ujson.Obj( "type" -> ujson.Str("object"), "additionalProperties" -> ujson.False, @@ -67,7 +69,7 @@ class AsUSpec extends PropSpec { implicit val ratingSchema = json.Json.schema[Rating] ratingSchema.refName - json.Json.schema[UserProfile].asU(Draft06(id = "http://models.org/userProfile.json")) shouldEqual ujson.Obj( + json.Json.schema[User].asU(Draft06(id = "http://models.org/userProfile.json")) shouldEqual ujson.Obj( f"$$schema" -> ujson.Str("http://json-schema.org/draft-06/schema#"), f"$$id" -> ujson.Str("http://models.org/userProfile.json"), "type" -> ujson.Str("object"), @@ -75,7 +77,7 @@ class AsUSpec extends PropSpec { "properties" -> ujson.Obj( "rating" -> ujson.Obj(f"$$ref" -> ujson.Str("#com.github.andyglow.jsonschema.AsUSpec.Rating")), "age" -> ujson.Obj("type" -> ujson.Str("integer")), - "active" -> ujson.Obj("type" -> ujson.Str("boolean")), + "active" -> ujson.Obj("type" -> ujson.Str("boolean"), "default" -> ujson.True), "name" -> ujson.Obj( "type" -> ujson.Str("object"), "additionalProperties" -> ujson.False, @@ -102,7 +104,7 @@ class AsUSpec extends PropSpec { implicit val ratingSchema = json.Json.schema[Rating] ratingSchema.refName - json.Json.schema[UserProfile].asU(Draft07(id = "http://models.org/userProfile.json")) shouldEqual ujson.Obj( + json.Json.schema[User].asU(Draft07(id = "http://models.org/userProfile.json")) shouldEqual ujson.Obj( f"$$schema" -> ujson.Str("http://json-schema.org/draft-07/schema#"), f"$$id" -> ujson.Str("http://models.org/userProfile.json"), "type" -> ujson.Str("object"), @@ -110,7 +112,7 @@ class AsUSpec extends PropSpec { "properties" -> ujson.Obj( "rating" -> ujson.Obj(f"$$ref" -> ujson.Str("#com.github.andyglow.jsonschema.AsUSpec.Rating")), "age" -> ujson.Obj("type" -> ujson.Str("integer")), - "active" -> ujson.Obj("type" -> ujson.Str("boolean")), + "active" -> ujson.Obj("type" -> ujson.Str("boolean"), "default" -> ujson.True), "name" -> ujson.Obj( "type" -> ujson.Str("object"), "additionalProperties" -> ujson.False, @@ -130,6 +132,31 @@ class AsUSpec extends PropSpec { "type" -> ujson.Str("integer"))), "required" -> ujson.Arr(ujson.Str("value"))))) } + + property("Check Schema.asU") { + import AsU._ + + json.Json.schema[UserProfile].asU(Draft04()) shouldEqual ujson.Obj( + f"$$schema" -> "http://json-schema.org/draft-04/schema#", + "type" -> "object", + "additionalProperties" -> false, + "properties" -> ujson.Obj( + "firstName" -> ujson.Obj("type" -> "string"), + "middleName" -> ujson.Obj("type" -> "string"), + "lastName" -> ujson.Obj("type" -> "string"), + "age" -> ujson.Obj("type" -> "integer"), + "role" -> ujson.Obj("type" -> "string", "default" -> "e-user", "enum" -> ujson.Arr("e-admin","e-manager","e-user")), + "active" -> ujson.Obj("type" -> "string", "default" -> "On", "enum" -> ujson.Arr("On", "Off", "Suspended")), + "enabledFeatures" -> ujson.Obj("type" -> "array", "uniqueItems" -> true, "default" -> ujson.Arr("feature-0-name", "feature-1-name"), "items" -> ujson.Obj("type" -> "string", "enum" -> ujson.Arr("feature-0-name", "feature-1-name", "feature-2-name"))), + "credentials" -> ujson.Obj("type" -> "object", + "additionalProperties" -> false, + "required" -> ujson.Arr("login", "password"), + "properties" -> ujson.Obj( + "login" -> ujson.Obj("type" -> "string"), + "password" -> ujson.Obj("type" -> "string")), + "default" -> ujson.Obj("login" -> "anonymous", "password" -> "-"))), + "required" -> ujson.Arr("age", "lastName", "firstName")) + } } object AsUSpec { @@ -141,7 +168,7 @@ object AsUSpec { case class Rating(value: Int) - case class UserProfile( + case class User( name: Name, rating: Rating, age: Int, diff --git a/u-json/src/test/scala/com/github/andyglow/jsonschema/UserProfileJson.scala b/u-json/src/test/scala/com/github/andyglow/jsonschema/UserProfileJson.scala new file mode 100644 index 00000000..bf407da5 --- /dev/null +++ b/u-json/src/test/scala/com/github/andyglow/jsonschema/UserProfileJson.scala @@ -0,0 +1,32 @@ +package com.github.andyglow.jsonschema + +import com.github.andyglow.jsonschema.model._ +import upickle.core.Visitor +import upickle.default._ + +object UserProfileJson { + + implicit val CredentialsW: Writer[Credentials] = macroW[Credentials] + + implicit val BetaFeatureW: Writer[BetaFeature] = new Writer[BetaFeature] { + override def write0[V]( + out: Visitor[_, V], + o: BetaFeature): V = o match { + case F0 => out.visitString("feature-0-name", 0) + case F1 => out.visitString("feature-1-name", 0) + case F2 => out.visitString("feature-2-name", 0) + } + } + + implicit val RoleW: Writer[Role] = new Writer[Role] { + import Role._ + + override def write0[V]( + out: Visitor[_, V], + o: Role): V = o match { + case User => out.visitString("e-user", 0) + case Manager => out.visitString("e-manager", 0) + case Admin => out.visitString("e-admin", 0) + } + } +}