diff --git a/api/src/test/scala/com/github/andyglow/jsonschema/AsDraft04Spec.scala b/api/src/test/scala/com/github/andyglow/jsonschema/AsDraft04Spec.scala index 91737e27..7518bb1c 100644 --- a/api/src/test/scala/com/github/andyglow/jsonschema/AsDraft04Spec.scala +++ b/api/src/test/scala/com/github/andyglow/jsonschema/AsDraft04Spec.scala @@ -161,14 +161,6 @@ class AsDraft04Spec extends AnyWordSpec { "type" -> "string"))) } - "emit Map[Int, _]" in { - asDraft04(`int-map`(`string`[String](None, None))) shouldEqual obj( - "type" -> "object", - "patternProperties" -> obj( - "^[0-9]*$" -> obj( - "type" -> "string"))) - } - "emit Object" in { import `object`.Field diff --git a/api/src/test/scala/com/github/andyglow/jsonschema/CRLFSourceSpec.scala b/api/src/test/scala/com/github/andyglow/jsonschema/CRLFSourceSpec.scala index 4dde95a8..519a9313 100644 --- a/api/src/test/scala/com/github/andyglow/jsonschema/CRLFSourceSpec.scala +++ b/api/src/test/scala/com/github/andyglow/jsonschema/CRLFSourceSpec.scala @@ -1,6 +1,7 @@ package com.github.andyglow.jsonschema import json._ +import json.Validation._ import json.Schema._ import json.Schema.`object`.Field @@ -17,7 +18,7 @@ class CRLFSourceSpec extends AnyFunSuite { 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"))) + Field("intMap" , `string-map`[Int, String, Map](`string`[String](None, None)).withValidation(`patternProperties` := "^[0-9]+$"), required = false, default = Map(1 -> "1", 2 -> "2"))) } } 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 0eb3e26f..ddc390f6 100644 --- a/api/src/test/scala/com/github/andyglow/jsonschema/SchemaMacroSpec.scala +++ b/api/src/test/scala/com/github/andyglow/jsonschema/SchemaMacroSpec.scala @@ -1,6 +1,7 @@ package com.github.andyglow.jsonschema import json.{Json, Schema} +import json.Validation._ import json.Schema._ import org.scalatest.matchers.should.Matchers._ import org.scalatest.wordspec.AnyWordSpec @@ -45,7 +46,7 @@ class SchemaMacroSpec extends AnyWordSpec { 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"))) + Field("intMap" , `string-map`[Int, String, Map](`string`[String](None, None)).withValidation(`patternProperties` := "^[0-9]+$"), required = false, default = Map(1 -> "1", 2 -> "2"))) } "generate references for implicitly defined dependencies" in { @@ -120,20 +121,20 @@ class SchemaMacroSpec extends AnyWordSpec { } - "generate schema for Map which Sealed Family for values" in { - import `object`.Field - - Json.schema[Map[String, FooBar]] shouldEqual `string-map`( - `oneof`(Set( - `object`(Field("foo", `number`[Double]())), - `object`(Field("bar", `number`[Double]()))))) - } +// "generate schema for Map which Sealed Family for values" in { +// import `object`.Field +// +// Json.schema[Map[String, FooBar]] shouldEqual `string-map`[String, FooBar, Map]( +// `oneof`(Set( +// `object`(Field("foo", `number`[Double]())), +// `object`(Field("bar", `number`[Double]()))))) +// } - "generate schema for Map which Sealed Values Family for values" in { - - Json.schema[Map[String, AnyFooBar]] shouldEqual `string-map`( - `oneof`(Set(`string`[String](None, None), `integer`))) - } +// "generate schema for Map which Sealed Values Family for values" in { +// +// Json.schema[Map[String, AnyFooBar]] shouldEqual `string-map`[String, AnyFooBar, Map]( +// `oneof`(Set(`string`[String](None, None), `integer`))) +// } "generate schema for case class using another case class" in { import `object`.Field @@ -169,21 +170,23 @@ class SchemaMacroSpec extends AnyWordSpec { Json.schema[Map[String, Int]] shouldEqual `string-map`(`integer`) - Json.schema[Map[String, Foo9]] shouldEqual `string-map`(`object`(Field("name", `string`[String](None, None)))) + Json.schema[Map[String, Foo9]] shouldEqual `string-map`[String, Foo9, Map](`object`(Field("name", `string`[String](None, None)))) } - - "generate schema for Map[Int, _]" in { + "generate schema for Map[_: MapKeyPattern, _]" in { import `object`.Field - Json.schema[Map[Int, String]] shouldEqual `int-map`(`string`[String](None, None)) + Json.schema[Map[Long, Long]] shouldEqual `string-map`[Long, Long, Map](`number`[Long]).withValidation(`patternProperties` := "^[0-9]+$") + + Json.schema[Map[Char, Long]] shouldEqual `string-map`[Char, Long, Map](`number`[Long]).withValidation(`patternProperties` := "^.{1}$") - Json.schema[Map[Int, Int]] shouldEqual `int-map`(`integer`) + Json.schema[Map[Int, String]] shouldEqual `string-map`[Int, String, Map](`string`[String](None, None)).withValidation(`patternProperties` := "^[0-9]+$") - Json.schema[Map[Int, Foo9]] shouldEqual `int-map`(`object`(Field("name", `string`[String](None, None)))) + Json.schema[Map[Int, Int]] shouldEqual `string-map`[Int, Int, Map](`integer`).withValidation(`patternProperties` := "^[0-9]+$") + + Json.schema[Map[Int, Foo9]] shouldEqual `string-map`[Int, Foo9, Map](`object`(Field("name", `string`[String](None, None)))).withValidation(`patternProperties` := "^[0-9]+$") } } - } object SchemaMacroSpec { diff --git a/build.sbt b/build.sbt index 5bdbc5e0..72ba0082 100644 --- a/build.sbt +++ b/build.sbt @@ -280,7 +280,6 @@ lazy val enumeratum = { project in file("modules/enumeratum") }.dependsOn(core, name := "scala-jsonschema-enumeratum", libraryDependencies += "com.beachape" %% "enumeratum" % "1.6.1", - libraryDependencies += (scalaVersion apply ("org.scala-lang" % "scala-reflect" % _ % Compile)).value.withSources.withJavadoc ) lazy val root = { project in file(".") } 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 b9cbc604..bf2db3f2 100644 --- a/core/src/main/scala/com/github/andyglow/jsonschema/AsDraftSupport.scala +++ b/core/src/main/scala/com/github/andyglow/jsonschema/AsDraftSupport.scala @@ -50,11 +50,6 @@ trait AsDraftSupport { pattern -> apply(comp))) } - def mkIntMap(pp: Option[ValidationDef[_, _]], comp: Schema[_]): obj = { - obj("patternProperties" -> obj( - "^[0-9]*$" -> apply(comp))) - } - def mkArr(pp: Option[ValidationDef[_, _]], comp: Schema[_]): obj = { obj("items" -> apply(comp)) } @@ -119,7 +114,6 @@ trait AsDraftSupport { case (pp, x: `string`[_]) => mkStr(pp, x) case (pp, x: `object`[_]) => mkObj(pp, x) case (pp, `string-map`(comp)) => mkStrMap(pp, comp) - case (pp, `int-map`(comp)) => mkIntMap(pp, comp) case (pp, `array`(comp)) => mkArr(pp, comp) case (pp, `set`(comp)) => mkSet(pp, comp) case (pp, x: `enum`[_]) => mkEnum(pp, x) diff --git a/core/src/main/scala/json/Schema.scala b/core/src/main/scala/json/Schema.scala index d0b86f8f..5756d0f8 100644 --- a/core/src/main/scala/json/Schema.scala +++ b/core/src/main/scala/json/Schema.scala @@ -173,10 +173,9 @@ object Schema { } } - final case class `string-map`[T, C[_ <: String, _]]( - valueType: Schema[T]) extends Schema[C[String, T]] { + final case class `string-map`[K, V, C[_, _]](valueType: Schema[V]) extends Schema[C[K, V]] { override def jsonType = "object" - def mkCopy() = new `string-map`[T, C](valueType) + def mkCopy() = new `string-map`[K, V, C](valueType) override def canEqual(that: Any): Boolean = that match { case `string-map`(_) => true case _ => false @@ -186,18 +185,15 @@ object Schema { case _ => false } } - - final case class `int-map`[T, C[_ <: Int, _]]( - valueType: Schema[T]) extends Schema[C[Int, T]] { - override def jsonType = "object" - def mkCopy() = new `int-map`[T, C](valueType) - override def canEqual(that: Any): Boolean = that match { - case `int-map`(_) => true - case _ => false - } - override def equals(obj: Any): Boolean = obj match { - case `int-map`(c) => valueType == c && super.equals(obj) - case _ => false + final object `string-map` { + abstract class MapKeyPattern[T](val pattern: String) + final object MapKeyPattern { + implicit final object StringRE extends MapKeyPattern[String]("^.*$") + implicit final object CharRE extends MapKeyPattern[Char]("^.{1}$") + implicit final object IntRE extends MapKeyPattern[Int]("^[0-9]+$") + implicit final object LongRE extends MapKeyPattern[Long]("^[0-9]+$") + // enums + // ^(?:aaa|bbb|ccc)$ } } diff --git a/core/src/main/scala/json/schema/Predef.scala b/core/src/main/scala/json/schema/Predef.scala index 0a69dd05..64a6b75d 100644 --- a/core/src/main/scala/json/schema/Predef.scala +++ b/core/src/main/scala/json/schema/Predef.scala @@ -3,8 +3,9 @@ package json.schema import java.net.{URI, URL} import java.util.UUID -import json.Schema +import json.{Schema, Validation} import json.Schema._ +import json.Schema.`string-map`.MapKeyPattern import json.Schema.`string`.Format import json.Validation._ @@ -54,6 +55,10 @@ object Predef extends LowPriorityPredefs { implicit def setS[T](implicit p: Predef[T]): Predef[Set[T]] = Predef(`set`[T, Set](p.schema)) implicit def listS[T](implicit p: Predef[T]): Predef[List[T]] = Predef(`array`[T, List](p.schema)) implicit def vectorS[T](implicit p: Predef[T]): Predef[Vector[T]] = Predef(`array`[T, Vector](p.schema)) - implicit def strMapS[T](implicit p: Predef[T]): Predef[Map[String, T]] = Predef(`string-map`[T, Map](p.schema)) - implicit def intMapS[T](implicit p: Predef[T]): Predef[Map[Int, T]] = Predef(`int-map`[T, Map](p.schema)) + implicit def strMapS[K, V](implicit p: Predef[V], keyP: MapKeyPattern[K]): Predef[Map[K, V]] = Predef { + val schema = `string-map`[K, V, Map](p.schema) + if (keyP == `string-map`.MapKeyPattern.StringRE) schema else { + schema withValidation (Validation.`patternProperties` := keyP.pattern) + } + } } \ No newline at end of file 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 d083105f..88850863 100644 --- a/macros/src/main/scala/com/github/andyglow/jsonschema/SchemaMacro.scala +++ b/macros/src/main/scala/com/github/andyglow/jsonschema/SchemaMacro.scala @@ -2,6 +2,7 @@ package com.github.andyglow.jsonschema import com.github.andyglow.json.ToValue import com.github.andyglow.scaladoc.{Scaladoc, SlowParser} +import json.Schema.`string-map`.MapKeyPattern import scala.reflect.NameTransformer import scala.reflect.internal.util.NoSourceFile @@ -21,16 +22,19 @@ object SchemaMacro { def impl[T: c.WeakTypeTag](c: blackbox.Context): c.Expr[json.Schema[T]] = { import c.universe._ - val jsonPkg = q"_root_.json" - val intJsonPkg = q"_root_.com.github.andyglow.json" - val schemaObj = q"$jsonPkg.Schema" + val jsonPkg = q"_root_.json" + val intJsonPkg = q"_root_.com.github.andyglow.json" + val schemaObj = q"$jsonPkg.Schema" + val validationObj = q"$jsonPkg.Validation" val subject = weakTypeOf[T] val optionTpe = weakTypeOf[Option[_]] val toValueTpe = weakTypeOf[ToValue[_]] val setTpe = weakTypeOf[Set[_]] + val mapTpe = weakTypeOf[Map[_, _]] val schemaTypeConstructor = typeOf[json.Schema[_]].typeConstructor val predefTypeConstructor = typeOf[json.schema.Predef[_]].typeConstructor + val mapKeyPattern = typeOf[MapKeyPattern[_]] def getTypeScaladoc(tpe: Type): Option[Scaladoc] = { import com.github.andyglow.scalamigration._ @@ -321,23 +325,35 @@ object SchemaMacro { } } - object IntMap { + case class StringMapGen(tpe: Type, keyType: Type, valueType: Type, keyPattern: Tree) { + private val stringKey = keyType <:< typeOf[String] - def gen(tpe: Type, stack: List[Type]): Tree = { - val componentType = tpe.typeArgs.tail.head - val componentJsonType = resolve(componentType, tpe +: stack) + def gen(stack: List[Type]): Tree = { + val valueJsonType = resolve(valueType, tpe +: stack) + val tree = q"""$schemaObj.`string-map`[$keyType, $valueType, ${tpe.typeConstructor}]($valueJsonType)""" + val effectiveTree = if (!stringKey) { + q"""$tree withValidation ($validationObj.patternProperties := $keyPattern.pattern)""" + } else tree - q"""$schemaObj.`int-map`[$componentType, ${tpe.typeConstructor}]($componentJsonType)""" + effectiveTree } } object StringMap { - def gen(tpe: Type, stack: List[Type]): Tree = { - val componentType = tpe.typeArgs.tail.head - val componentJsonType = resolve(componentType, tpe +: stack) + def unapply(x: Type): Option[StringMapGen] = { + if (x <:< mapTpe) { + val keyType = x.typeArgs.head + val valueType = x.typeArgs.tail.head + val t = appliedType(mapKeyPattern, keyType) - q"""$schemaObj.`string-map`[$componentType, ${tpe.typeConstructor}]($componentJsonType)""" + c.inferImplicitValue(t) match { + case EmptyTree => None + case t => Some(StringMapGen(x, keyType, valueType, t)) + } + } else { + None + } } } @@ -439,8 +455,7 @@ object SchemaMacro { if (stack contains tpe) c.error(c.enclosingPosition, s"cyclic dependency for $tpe") def genTree: Tree = tpe match { - case x if x <:< typeOf[Map[String, _]] => StringMap.gen(x, stack) - case x if x <:< typeOf[Map[Int, _]] => IntMap.gen(x, stack) + case StringMap(g) => g.gen(stack) case x if x <:< typeOf[Array[_]] => Arr.gen(x, stack) case x if x <:< typeOf[Iterable[_]] => Arr.gen(x, stack) case SealedEnum(names) => SealedEnum.gen(tpe, names) diff --git a/modules/cats/src/main/scala/com/github/andyglow/jsonschema/CatsSupport.scala b/modules/cats/src/main/scala/com/github/andyglow/jsonschema/CatsSupport.scala index 345e75c6..540bc715 100644 --- a/modules/cats/src/main/scala/com/github/andyglow/jsonschema/CatsSupport.scala +++ b/modules/cats/src/main/scala/com/github/andyglow/jsonschema/CatsSupport.scala @@ -3,6 +3,7 @@ package com.github.andyglow.jsonschema import cats.data._ import json.Schema import json.Schema._ +import json.Schema.`string-map`.MapKeyPattern import json.Validation._ import json.schema.Predef @@ -15,13 +16,16 @@ trait LowPriorityCatsSupport extends ScalaVersionSpecificLowPriorityCatsSupport implicit def nevVB[X]: ValidationBound[NonEmptyVector[X], Iterable[_]] = mk[NonEmptyVector[X], Iterable[_]] implicit def nesVB[X]: ValidationBound[NonEmptySet[X], Iterable[_]] = mk[NonEmptySet[X], Iterable[_]] implicit def necVB[X]: ValidationBound[NonEmptyChain[X], Iterable[_]] = mk[NonEmptyChain[X], Iterable[_]] - implicit def nemStrVB[X]: ValidationBound[NonEmptyMap[String, X], Map[_, _]] = mk[NonEmptyMap[String, X], Map[_, _]] - implicit def nemIntVB[X]: ValidationBound[NonEmptyMap[Int, X], Map[_, _]] = mk[NonEmptyMap[Int, X], Map[_, _]] + implicit def nemStrVB[K, V]: ValidationBound[NonEmptyMap[K, V], Map[_, _]] = mk[NonEmptyMap[K, V], Map[_, _]] implicit def oneAndVB[F[_], X]: ValidationBound[OneAnd[F, X], Iterable[_]] = mk[OneAnd[F, X], Iterable[_]] protected def mkNEx[T, C[_]](schema: Schema[T])(implicit b: ValidationBound[C[T], Iterable[_]]) = Predef(`array`[T, C](schema).withValidation(`minItems` := 1)) - protected def mkNESM[T](schema: Schema[T])(implicit b: ValidationBound[NonEmptyMap[String, T], Map[_, _]]) = Predef(`string-map`[T, NonEmptyMap](schema).withValidation(`minProperties` := 1)) - protected def mkNEIM[T](schema: Schema[T])(implicit b: ValidationBound[NonEmptyMap[Int, T], Map[_, _]]) = Predef(`int-map`[T, NonEmptyMap](schema).withValidation(`minProperties` := 1)) + protected def mkNESM[K, V](vSchema: Schema[V], keyP: MapKeyPattern[K])(implicit b: ValidationBound[NonEmptyMap[K, V], Map[_, _]]) = Predef { + val schema = `string-map`[K, V, NonEmptyMap](vSchema).withValidation(`minProperties` := 1) + if (keyP == `string-map`.MapKeyPattern.StringRE) schema else { + schema.withValidation(`patternProperties` := keyP.pattern) + } + } implicit def chainSchemaFromPredef[T](implicit p: Predef[T]): Predef[Chain[T]] = mkNEx[T, Chain](p.schema) @@ -35,9 +39,7 @@ trait LowPriorityCatsSupport extends ScalaVersionSpecificLowPriorityCatsSupport implicit def necSchemaFromPredef[T](implicit p: Predef[T]): Predef[NonEmptyChain[T]] = mkNEx[T, NonEmptyChain](p.schema) - implicit def nemStrSchemaFromPredef[T](implicit p: Predef[T]): Predef[NonEmptyMap[String, T]] = mkNESM(p.schema) - - implicit def nemIntSchemaFromPredef[T](implicit p: Predef[T]): Predef[NonEmptyMap[Int, T]] = mkNEIM(p.schema) + implicit def nemStrSchemaFromPredef[K, V](implicit p: Predef[V], keyP: MapKeyPattern[K]): Predef[NonEmptyMap[K, V]] = mkNESM(p.schema, keyP) } object CatsSupport extends LowPriorityCatsSupport with ScalaVersionSpecificCatsSupport { @@ -54,7 +56,6 @@ object CatsSupport extends LowPriorityCatsSupport with ScalaVersionSpecificCatsS implicit def necSchema[T](implicit ss: Schema[T]): Predef[NonEmptyChain[T]] = mkNEx[T, NonEmptyChain](ss) - implicit def nemStrSchema[T](implicit ss: Schema[T]): Predef[NonEmptyMap[String, T]] = mkNESM(ss) + implicit def nemStrSchema[K, V](implicit ss: Schema[V], keyP: MapKeyPattern[K]): Predef[NonEmptyMap[K, V]] = mkNESM(ss, keyP) - implicit def nemIntSchema[T](implicit ss: Schema[T]): Predef[NonEmptyMap[Int, T]] = mkNEIM(ss) } diff --git a/modules/cats/src/test/scala/com/github/andyglow/jsonschema/CatsSupportSpec.scala b/modules/cats/src/test/scala/com/github/andyglow/jsonschema/CatsSupportSpec.scala index 47474114..30e0ece2 100644 --- a/modules/cats/src/test/scala/com/github/andyglow/jsonschema/CatsSupportSpec.scala +++ b/modules/cats/src/test/scala/com/github/andyglow/jsonschema/CatsSupportSpec.scala @@ -56,7 +56,7 @@ class CatsSupportSpec extends AnyWordSpec { "be exposed as object" in { nesmEventSchema shouldBe `object`( Field("id", `string`()), - Field("data" , `string-map`[String, NonEmptyMap](`string`()).withValidation(`minProperties` := 1))) + Field("data" , `string-map`[String, String, NonEmptyMap](`string`()).withValidation(`minProperties` := 1))) } } @@ -65,7 +65,7 @@ class CatsSupportSpec extends AnyWordSpec { "be exposed as object" in { neimEventSchema shouldBe `object`( Field("id", `string`()), - Field("data", `int-map`[String, NonEmptyMap](`string`()).withValidation(`minProperties` := 1))) + Field("data", `string-map`[Int, String, NonEmptyMap](`string`()).withValidation(`minProperties` := 1, `patternProperties` := "^[0-9]+$"))) } } diff --git a/modules/parser/src/main/scala/com/github/andyglow/jsonschema/ParseJsonSchema.scala b/modules/parser/src/main/scala/com/github/andyglow/jsonschema/ParseJsonSchema.scala index 55959427..24110b3a 100644 --- a/modules/parser/src/main/scala/com/github/andyglow/jsonschema/ParseJsonSchema.scala +++ b/modules/parser/src/main/scala/com/github/andyglow/jsonschema/ParseJsonSchema.scala @@ -5,6 +5,7 @@ import java.io.{ByteArrayInputStream, InputStream} import scala.collection._ import com.github.andyglow.json.{ParseJson, Value} import json.Schema +import json.Validation._ import scala.util.{Failure, Success, Try} @@ -78,9 +79,10 @@ object ParseJsonSchema { } def makeObj = x.value.obj("patternProperties") match { - case Some(obj(fields)) if fields.contains("^.*$") => makeType(fields.obj("^.*$").get) map { `string-map`(_) } - case Some(obj(fields)) if fields.contains("^[0-9]*$") => makeType(fields.obj("^[0-9]*$").get) map { `int-map`(_) } - case None => + case Some(obj(fields)) if fields.nonEmpty && fields.head._2.isInstanceOf[obj] => + val (k, v) = fields.head + makeType(v.asInstanceOf[obj]) map { x => `string-map`[Any, Any, scala.collection.immutable.Map](x).withValidation(`patternProperties` := k) } + case _ => val required = x.value.set("required") map { _ collect { case str(x) => x } } getOrElse Set.empty x.value.obj("properties").map { _.value }.toSuccess("properties is not defined") flatMap { props => val fields = props.collect { case (k, v: obj) =>