diff --git a/.mill-version b/.mill-version new file mode 100644 index 000000000..45a1b3f44 --- /dev/null +++ b/.mill-version @@ -0,0 +1 @@ +1.1.2 diff --git a/build.mill b/build.mill index 732e79376..9c368d405 100644 --- a/build.mill +++ b/build.mill @@ -18,6 +18,7 @@ val scala212 = "2.12.20" val scala213 = "2.13.16" val scala3 = "3.3.7" +val scala381 = "3.8.1" val scala3NamedTuples = "3.7.0" val scalaJSNamedTuples = "1.20.1" // the version of scala.js that the scala3-library is built for. val scalaNativeNamedTuples = "0.5.9" // the version of scala.native that the scala3-library is built for. @@ -387,6 +388,28 @@ object upickle extends Module{ } } + object jsonschema extends Module { + object jvm extends Cross[JvmModule](Seq(scala381)) + trait JvmModule extends CommonJvmModule { + def jvmId = "17" + def moduleDeps = Seq(upickle.jvm(scala3)) + override def mimaPreviousVersions = Seq.empty[String] + override def mimaPreviousArtifacts = Seq.empty[Dep] + override def mimaReportBinaryIssues() = Task.Command {} + + object test extends CommonTestModule{ + override def mvnDeps = Seq( + mvn"com.lihaoyi::utest::0.10.0-RC1", + mvn"com.networknt:json-schema-validator:1.5.8" + ) + def moduleDeps = super.moduleDeps ++ Seq( + upickle.jvm(scala3).test, + upickle.implicits.`named-tuples`.jvm(scala3NamedTuples).test + ) + } + } + } + trait UpickleModule extends CommonPublishModule { def compileMvnDeps = Seq( diff --git a/mill.bat b/mill.bat new file mode 100755 index 000000000..aa0fbc19d --- /dev/null +++ b/mill.bat @@ -0,0 +1,300 @@ +@echo off + +setlocal enabledelayedexpansion + +if [!DEFAULT_MILL_VERSION!]==[] ( set "DEFAULT_MILL_VERSION=1.1.0-RC4" ) + +if [!MILL_GITHUB_RELEASE_CDN!]==[] ( set "MILL_GITHUB_RELEASE_CDN=" ) + +if [!MILL_MAIN_CLI!]==[] ( set "MILL_MAIN_CLI=%~f0" ) + +set "MILL_REPO_URL=https://github.com/com-lihaoyi/mill" + +SET MILL_BUILD_SCRIPT= + +if exist "build.mill" ( + set MILL_BUILD_SCRIPT=build.mill +) else ( + if exist "build.mill.scala" ( + set MILL_BUILD_SCRIPT=build.mill.scala + ) else ( + if exist "build.sc" ( + set MILL_BUILD_SCRIPT=build.sc + ) else ( + rem no-op + ) + ) +) + +if [!MILL_VERSION!]==[] ( + if exist .mill-version ( + set /p MILL_VERSION=<.mill-version + ) else ( + if exist .config\mill-version ( + set /p MILL_VERSION=<.config\mill-version + ) else ( + rem Determine which config file to use for version extraction + set "MILL_VERSION_CONFIG_FILE=" + set "MILL_VERSION_SEARCH_PATTERN=" + + if exist build.mill.yaml ( + set "MILL_VERSION_CONFIG_FILE=build.mill.yaml" + set "MILL_VERSION_SEARCH_PATTERN=mill-version:" + ) else ( + if not "%MILL_BUILD_SCRIPT%"=="" ( + set "MILL_VERSION_CONFIG_FILE=%MILL_BUILD_SCRIPT%" + set "MILL_VERSION_SEARCH_PATTERN=//\|.*mill-version" + ) + ) + + rem Process the config file if found + if not "!MILL_VERSION_CONFIG_FILE!"=="" ( + rem Find the line and process it + for /f "tokens=*" %%a in ('findstr /R /C:"!MILL_VERSION_SEARCH_PATTERN!" "!MILL_VERSION_CONFIG_FILE!"') do ( + set "line=%%a" + + rem --- 1. Replicate sed 's/.*://' --- + rem This removes everything up to and including the first colon + set "line=!line:*:=!" + + rem --- 2. Replicate sed 's/#.*//' --- + rem Split on '#' and keep the first part + for /f "tokens=1 delims=#" %%b in ("!line!") do ( + set "line=%%b" + ) + + rem --- 3. Replicate sed 's/['"]//g' --- + rem Remove all quotes + set "line=!line:'=!" + set "line=!line:"=!" + + rem --- 4. Replicate sed's trim/space removal --- + rem Remove all space characters from the result. This is more robust. + set "MILL_VERSION=!line: =!" + + rem We found the version, so we can exit the loop + goto :version_found + ) + + :version_found + rem no-op + ) + ) + ) +) + +if [!MILL_VERSION!]==[] ( + set MILL_VERSION=%DEFAULT_MILL_VERSION% +) + +if [!MILL_FINAL_DOWNLOAD_FOLDER!]==[] set MILL_FINAL_DOWNLOAD_FOLDER=%USERPROFILE%\.cache\mill\download + +rem without bat file extension, cmd doesn't seem to be able to run it + +set "MILL_NATIVE_SUFFIX=-native" +set "MILL_JVM_SUFFIX=-jvm" +set "MILL_FULL_VERSION=%MILL_VERSION%" +set "MILL_DOWNLOAD_EXT=.bat" +set "ARTIFACT_SUFFIX=" +REM Check if MILL_VERSION contains MILL_NATIVE_SUFFIX +echo !MILL_VERSION! | findstr /C:"%MILL_NATIVE_SUFFIX%" >nul +if !errorlevel! equ 0 ( + set "MILL_VERSION=%MILL_VERSION:-native=%" + REM -native images compiled with graal do not support windows-arm + REM https://github.com/oracle/graal/issues/9215 + IF /I NOT "%PROCESSOR_ARCHITECTURE%"=="ARM64" ( + set "ARTIFACT_SUFFIX=-native-windows-amd64" + set "MILL_DOWNLOAD_EXT=.exe" + ) else ( + rem no-op + ) +) else ( + echo !MILL_VERSION! | findstr /C:"%MILL_JVM_SUFFIX%" >nul + if !errorlevel! equ 0 ( + set "MILL_VERSION=%MILL_VERSION:-jvm=%" + ) else ( + set "SKIP_VERSION=false" + set "MILL_PREFIX=%MILL_VERSION:~0,4%" + if "!MILL_PREFIX!"=="0.1." set "SKIP_VERSION=true" + if "!MILL_PREFIX!"=="0.2." set "SKIP_VERSION=true" + if "!MILL_PREFIX!"=="0.3." set "SKIP_VERSION=true" + if "!MILL_PREFIX!"=="0.4." set "SKIP_VERSION=true" + if "!MILL_PREFIX!"=="0.5." set "SKIP_VERSION=true" + if "!MILL_PREFIX!"=="0.6." set "SKIP_VERSION=true" + if "!MILL_PREFIX!"=="0.7." set "SKIP_VERSION=true" + if "!MILL_PREFIX!"=="0.8." set "SKIP_VERSION=true" + if "!MILL_PREFIX!"=="0.9." set "SKIP_VERSION=true" + set "MILL_PREFIX=%MILL_VERSION:~0,5%" + if "!MILL_PREFIX!"=="0.10." set "SKIP_VERSION=true" + if "!MILL_PREFIX!"=="0.11." set "SKIP_VERSION=true" + if "!MILL_PREFIX!"=="0.12." set "SKIP_VERSION=true" + + if "!SKIP_VERSION!"=="false" ( + IF /I NOT "%PROCESSOR_ARCHITECTURE%"=="ARM64" ( + set "ARTIFACT_SUFFIX=-native-windows-amd64" + set "MILL_DOWNLOAD_EXT=.exe" + ) + ) else ( + rem no-op + ) + ) +) + +set MILL=%MILL_FINAL_DOWNLOAD_FOLDER%\!MILL_FULL_VERSION!!MILL_DOWNLOAD_EXT! + +set MILL_RESOLVE_DOWNLOAD= + +if not exist "%MILL%" ( + set MILL_RESOLVE_DOWNLOAD=true +) else ( + if defined MILL_TEST_DRY_RUN_LAUNCHER_SCRIPT ( + set MILL_RESOLVE_DOWNLOAD=true + ) else ( + rem no-op + ) +) + + +if [!MILL_RESOLVE_DOWNLOAD!]==[true] ( + set MILL_VERSION_PREFIX=%MILL_VERSION:~0,4% + set MILL_SHORT_VERSION_PREFIX=%MILL_VERSION:~0,2% + rem Since 0.5.0 + set MILL_DOWNLOAD_SUFFIX=-assembly + rem Since 0.11.0 + set MILL_DOWNLOAD_FROM_MAVEN=1 + if [!MILL_VERSION_PREFIX!]==[0.0.] ( + set MILL_DOWNLOAD_SUFFIX= + set MILL_DOWNLOAD_FROM_MAVEN=0 + ) + if [!MILL_VERSION_PREFIX!]==[0.1.] ( + set MILL_DOWNLOAD_SUFFIX= + set MILL_DOWNLOAD_FROM_MAVEN=0 + ) + if [!MILL_VERSION_PREFIX!]==[0.2.] ( + set MILL_DOWNLOAD_SUFFIX= + set MILL_DOWNLOAD_FROM_MAVEN=0 + ) + if [!MILL_VERSION_PREFIX!]==[0.3.] ( + set MILL_DOWNLOAD_SUFFIX= + set MILL_DOWNLOAD_FROM_MAVEN=0 + ) + if [!MILL_VERSION_PREFIX!]==[0.4.] ( + set MILL_DOWNLOAD_SUFFIX= + set MILL_DOWNLOAD_FROM_MAVEN=0 + ) + if [!MILL_VERSION_PREFIX!]==[0.5.] set MILL_DOWNLOAD_FROM_MAVEN=0 + if [!MILL_VERSION_PREFIX!]==[0.6.] set MILL_DOWNLOAD_FROM_MAVEN=0 + if [!MILL_VERSION_PREFIX!]==[0.7.] set MILL_DOWNLOAD_FROM_MAVEN=0 + if [!MILL_VERSION_PREFIX!]==[0.8.] set MILL_DOWNLOAD_FROM_MAVEN=0 + if [!MILL_VERSION_PREFIX!]==[0.9.] set MILL_DOWNLOAD_FROM_MAVEN=0 + + set MILL_VERSION_PREFIX=%MILL_VERSION:~0,5% + if [!MILL_VERSION_PREFIX!]==[0.10.] set MILL_DOWNLOAD_FROM_MAVEN=0 + + set MILL_VERSION_PREFIX=%MILL_VERSION:~0,8% + if [!MILL_VERSION_PREFIX!]==[0.11.0-M] set MILL_DOWNLOAD_FROM_MAVEN=0 + + set MILL_VERSION_PREFIX=%MILL_VERSION:~0,5% + set DOWNLOAD_EXT=exe + if [!MILL_SHORT_VERSION_PREFIX!]==[0.] set DOWNLOAD_EXT=jar + if [!MILL_VERSION_PREFIX!]==[0.12.] set DOWNLOAD_EXT=exe + if [!MILL_VERSION!]==[0.12.0] set DOWNLOAD_EXT=jar + if [!MILL_VERSION!]==[0.12.1] set DOWNLOAD_EXT=jar + if [!MILL_VERSION!]==[0.12.2] set DOWNLOAD_EXT=jar + if [!MILL_VERSION!]==[0.12.3] set DOWNLOAD_EXT=jar + if [!MILL_VERSION!]==[0.12.4] set DOWNLOAD_EXT=jar + if [!MILL_VERSION!]==[0.12.5] set DOWNLOAD_EXT=jar + if [!MILL_VERSION!]==[0.12.6] set DOWNLOAD_EXT=jar + if [!MILL_VERSION!]==[0.12.7] set DOWNLOAD_EXT=jar + if [!MILL_VERSION!]==[0.12.8] set DOWNLOAD_EXT=jar + if [!MILL_VERSION!]==[0.12.9] set DOWNLOAD_EXT=jar + if [!MILL_VERSION!]==[0.12.10] set DOWNLOAD_EXT=jar + if [!MILL_VERSION!]==[0.12.11] set DOWNLOAD_EXT=jar + + set MILL_VERSION_PREFIX= + set MILL_SHORT_VERSION_PREFIX= + + for /F "delims=- tokens=1" %%A in ("!MILL_VERSION!") do set MILL_VERSION_BASE=%%A + set MILL_VERSION_MILESTONE= + for /F "delims=- tokens=2" %%A in ("!MILL_VERSION!") do set MILL_VERSION_MILESTONE=%%A + set MILL_VERSION_MILESTONE_START=!MILL_VERSION_MILESTONE:~0,1! + if [!MILL_VERSION_MILESTONE_START!]==[M] ( + set MILL_VERSION_TAG=!MILL_VERSION_BASE!-!MILL_VERSION_MILESTONE! + ) else ( + set MILL_VERSION_TAG=!MILL_VERSION_BASE! + ) + if [!MILL_DOWNLOAD_FROM_MAVEN!]==[1] ( + set MILL_DOWNLOAD_URL=https://repo1.maven.org/maven2/com/lihaoyi/mill-dist!ARTIFACT_SUFFIX!/!MILL_VERSION!/mill-dist!ARTIFACT_SUFFIX!-!MILL_VERSION!.!DOWNLOAD_EXT! + ) else ( + set MILL_DOWNLOAD_URL=!MILL_GITHUB_RELEASE_CDN!%MILL_REPO_URL%/releases/download/!MILL_VERSION_TAG!/!MILL_VERSION!!MILL_DOWNLOAD_SUFFIX! + ) + + if defined MILL_TEST_DRY_RUN_LAUNCHER_SCRIPT ( + echo !MILL_DOWNLOAD_URL! + echo !MILL! + exit /b 0 + ) + + rem there seems to be no way to generate a unique temporary file path (on native Windows) + if defined MILL_OUTPUT_DIR ( + set MILL_TEMP_DOWNLOAD_FILE=%MILL_OUTPUT_DIR%\mill-temp-download + if not exist "%MILL_OUTPUT_DIR%" mkdir "%MILL_OUTPUT_DIR%" + ) else ( + set MILL_TEMP_DOWNLOAD_FILE=out\mill-bootstrap-download + if not exist "out" mkdir "out" + ) + + echo Downloading mill !MILL_VERSION! from !MILL_DOWNLOAD_URL! ... 1>&2 + + curl -f -L "!MILL_DOWNLOAD_URL!" -o "!MILL_TEMP_DOWNLOAD_FILE!" + + if not exist "%MILL_FINAL_DOWNLOAD_FOLDER%" mkdir "%MILL_FINAL_DOWNLOAD_FOLDER%" + move /y "!MILL_TEMP_DOWNLOAD_FILE!" "%MILL%" + + set MILL_TEMP_DOWNLOAD_FILE= + set MILL_DOWNLOAD_SUFFIX= +) + +set MILL_FINAL_DOWNLOAD_FOLDER= +set MILL_VERSION= +set MILL_REPO_URL= + +rem Need to preserve the first position of those listed options +set MILL_FIRST_ARG= +if [%~1%]==[--bsp] ( + set MILL_FIRST_ARG=%1% +) else ( + if [%~1%]==[-i] ( + set MILL_FIRST_ARG=%1% + ) else ( + if [%~1%]==[--interactive] ( + set MILL_FIRST_ARG=%1% + ) else ( + if [%~1%]==[--no-server] ( + set MILL_FIRST_ARG=%1% + ) else ( + if [%~1%]==[--no-daemon] ( + set MILL_FIRST_ARG=%1% + ) else ( + if [%~1%]==[--repl] ( + set MILL_FIRST_ARG=%1% + ) else ( + if [%~1%]==[--help] ( + set MILL_FIRST_ARG=%1% + ) + ) + ) + ) + ) + ) +) +set "MILL_PARAMS=%*%" + +if not [!MILL_FIRST_ARG!]==[] ( + for /f "tokens=1*" %%a in ("%*") do ( + set "MILL_PARAMS=%%b" + ) +) + +rem -D mill.main.cli is for compatibility with Mill 0.10.9 - 0.13.0-M2 +"%MILL%" %MILL_FIRST_ARG% -D "mill.main.cli=%MILL_MAIN_CLI%" %MILL_PARAMS% diff --git a/upickle/jsonschema/src/upickle/jsonschema/JsonSchema.scala b/upickle/jsonschema/src/upickle/jsonschema/JsonSchema.scala new file mode 100644 index 000000000..3e407784a --- /dev/null +++ b/upickle/jsonschema/src/upickle/jsonschema/JsonSchema.scala @@ -0,0 +1,559 @@ +package upickle.jsonschema + +import scala.collection.mutable +import scala.compiletime.{constValue, erasedValue, summonFrom} +import scala.deriving.Mirror +import scala.quoted.{Expr, Quotes, Type} +import upickle.implicits.macros + +trait JsonSchema[+T] { + def schema(api: upickle.Api, registry: JsonSchema.Registry): ujson.Value +} + +object JsonSchema { + private val Draft202012 = "https://json-schema.org/draft/2020-12/schema" + private val IntegralPattern = "^-?(0|[1-9][0-9]*)$" + private val BooleanPattern = "^(true|false)$" + + final class Registry { + private val inProgress = mutable.HashSet.empty[String] + private val defs0 = mutable.LinkedHashMap.empty[String, ujson.Value] + + def ref(defKey: String): ujson.Obj = ujson.Obj("$ref" -> s"#/$$defs/$defKey") + + def define(defKey: String)(build: => ujson.Value): ujson.Obj = { + if (defs0.contains(defKey) || inProgress.contains(defKey)) ref(defKey) + else { + inProgress += defKey + try defs0(defKey) = build + finally inProgress -= defKey + ref(defKey) + } + } + + def defs: collection.immutable.ListMap[String, ujson.Value] = + collection.immutable.ListMap.from(defs0) + } + + private def primitive(tpe: String): ujson.Obj = ujson.Obj("type" -> tpe) + private def arraySchema(item: ujson.Value, uniqueItems: Boolean = false): ujson.Obj = { + val out = ujson.Obj("type" -> "array", "items" -> item) + if (uniqueItems) out("uniqueItems") = true + out + } + + trait MapKeySchema[-K] { + def propertyNames: Option[ujson.Value] + } + given defaultMapKeySchema[K]: MapKeySchema[K] with { + def propertyNames: Option[ujson.Value] = None + } + given MapKeySchema[java.util.UUID] with { + def propertyNames: Option[ujson.Value] = + Some(ujson.Obj("type" -> "string", "format" -> "uuid")) + } + given MapKeySchema[Char] with { + def propertyNames: Option[ujson.Value] = + Some(ujson.Obj("type" -> "string", "minLength" -> 1, "maxLength" -> 1)) + } + given MapKeySchema[Boolean] with { + def propertyNames: Option[ujson.Value] = + Some(ujson.Obj("type" -> "string", "pattern" -> BooleanPattern)) + } + given MapKeySchema[Int] with { + def propertyNames: Option[ujson.Value] = + Some(ujson.Obj("type" -> "string", "pattern" -> IntegralPattern)) + } + given MapKeySchema[Long] with { + def propertyNames: Option[ujson.Value] = + Some(ujson.Obj("type" -> "string", "pattern" -> IntegralPattern)) + } + given MapKeySchema[Short] with { + def propertyNames: Option[ujson.Value] = + Some(ujson.Obj("type" -> "string", "pattern" -> IntegralPattern)) + } + given MapKeySchema[Byte] with { + def propertyNames: Option[ujson.Value] = + Some(ujson.Obj("type" -> "string", "pattern" -> IntegralPattern)) + } + given MapKeySchema[BigInt] with { + def propertyNames: Option[ujson.Value] = + Some(ujson.Obj("type" -> "string", "pattern" -> IntegralPattern)) + } + + private def isTupleLabels(labels: List[String]): Boolean = + labels.zipWithIndex.forall { case (label, index) => label == s"_${index + 1}" } + + private def isRef(v: ujson.Value): Boolean = v match { + case o: ujson.Obj => o.value.size == 1 && o.obj.contains("$ref") + case _ => false + } + + private inline def typeId[T]: String = ${typeIdImpl[T]} + private def typeIdImpl[T](using q: Quotes, t: Type[T]): Expr[String] = { + import q.reflect._ + Expr(TypeRepr.of[T].show) + } + + private inline def labelsToList[T <: Tuple]: List[String] = inline erasedValue[T] match { + case _: EmptyTuple => Nil + case _: (h *: t) => constValue[h].toString :: labelsToList[t] + } + + private inline def productFieldMetadata[T]: List[(String, Boolean, Boolean, Boolean, String)] = ${productFieldMetadataImpl[T]} + private def productFieldMetadataImpl[T](using q: Quotes, t: Type[T]): Expr[List[(String, Boolean, Boolean, Boolean, String)]] = { + import q.reflect.* + def keyAnnotation(sym: Symbol): Option[String] = + sym.annotations.collectFirst { + case Apply(Select(New(tpt), _), List(Literal(StringConstant(s)))) + if tpt.tpe =:= TypeRepr.of[upickle.implicits.key] => + s + } + def flattenAnnotation(sym: Symbol): Boolean = + sym.annotations.exists(_.tpe =:= TypeRepr.of[upickle.implicits.flatten]) + + def substituteTypeArgs(owner: TypeRepr, fieldType: TypeRepr): TypeRepr = { + val constructorSym = owner.typeSymbol.primaryConstructor + val tparams0 = constructorSym.paramSymss.flatten.filter(_.isType) + fieldType.substituteTypes(tparams0, owner.typeArgs) + } + + def isCollectionFlattenable(tpe: TypeRepr): Boolean = { + val iterableSym = Symbol.requiredClass("scala.collection.Iterable") + tpe.baseType(iterableSym) match { + case AppliedType(_, List(elemTpe)) => + elemTpe.dealias match { + case AppliedType(tupleConstructor, List(_, _)) => + tupleConstructor.typeSymbol == Symbol.requiredClass("scala.Tuple2") + case _ => false + } + case _ => false + } + } + + def mapKeyKindForType(tpe: TypeRepr): String = { + val d = tpe.dealias + if (d =:= TypeRepr.of[Int] || d =:= TypeRepr.of[Long] || d =:= TypeRepr.of[Short] || d =:= TypeRepr.of[Byte] || d =:= TypeRepr.of[BigInt]) "integral" + else if (d =:= TypeRepr.of[Boolean]) "boolean" + else if (d =:= TypeRepr.of[Char]) "char" + else if (d =:= TypeRepr.of[java.util.UUID]) "uuid" + else "none" + } + + def flattenCollectionKeyKind(tpe: TypeRepr): String = { + val iterableSym = Symbol.requiredClass("scala.collection.Iterable") + tpe.baseType(iterableSym) match { + case AppliedType(_, List(elemTpe)) => + elemTpe.dealias match { + case AppliedType(tupleConstructor, List(keyTpe, _)) + if tupleConstructor.typeSymbol == Symbol.requiredClass("scala.Tuple2") => + mapKeyKindForType(keyTpe) + case _ => "none" + } + case _ => "none" + } + } + + val owner = TypeRepr.of[T] + val fields = owner.typeSymbol.primaryConstructor.paramSymss.flatten.filterNot(_.isType) + Expr.ofList(fields.map { f => + val mapped = keyAnnotation(f).getOrElse(f.name) + val isFlatten = flattenAnnotation(f) + val fieldTpe = substituteTypeArgs(owner, owner.memberType(f)) + val isFlattenMap = isFlatten && isCollectionFlattenable(fieldTpe) + val keyKind = if (isFlattenMap) flattenCollectionKeyKind(fieldTpe) else "none" + Expr((mapped, f.flags.is(Flags.HasDefault), isFlatten, isFlattenMap, keyKind)) + }) + } + + private inline def containsType[T, Ts <: Tuple]: Boolean = + inline erasedValue[Ts] match { + case _: EmptyTuple => false + case _: (T *: t) => true + case _: (_ *: t) => containsType[T, t] + } + + private inline def refSchema[T]: JsonSchema[T] = new JsonSchema[T] { + def schema(api: upickle.Api, registry: Registry): ujson.Value = registry.ref(typeId[T]) + } + + private inline def resolveSchema[T, Seen <: Tuple]: JsonSchema[T] = + inline if containsType[T, Seen] then refSchema[T] + else summonFrom { + case s: JsonSchema[T] => s + case m: Mirror.Of[T] => derivedWithSeen[T, Seen](using m) + } + + private def delayed[T](f: => JsonSchema[T]): JsonSchema[T] = new JsonSchema[T] { + lazy val value = f + def schema(api: upickle.Api, registry: Registry): ujson.Value = value.schema(api, registry) + } + + private inline def summonSchemas[T <: Tuple, Seen <: Tuple]: List[JsonSchema[Any]] = inline erasedValue[T] match { + case _: EmptyTuple => Nil + case _: (h *: t) => + delayed(resolveSchema[h, Seen]).asInstanceOf[JsonSchema[Any]] :: summonSchemas[t, Seen] + } + + private inline def summonSumSchemas[T <: Tuple, Seen <: Tuple]: List[(Boolean, String, String, JsonSchema[Any])] = + inline erasedValue[T] match { + case _: EmptyTuple => Nil + case _: (h *: t) => + ( + macros.isSingleton[h], + macros.tagName[h], + macros.shortTagName[h], + delayed(resolveSchema[h, Seen]).asInstanceOf[JsonSchema[Any]] + ) :: summonSumSchemas[t, Seen] + } + + private inline def annotatedSumTagKey[T]: Option[String] = ${annotatedSumTagKeyImpl[T]} + private def annotatedSumTagKeyImpl[T](using q: Quotes, t: Type[T]): Expr[Option[String]] = { + import q.reflect.* + TypeRepr.of[T].typeSymbol.annotations.collectFirst { + case Apply(Select(New(tpt), _), List(Literal(StringConstant(s)))) + if tpt.tpe =:= TypeRepr.of[upickle.implicits.key] => + Expr(s) + } match { + case Some(v) => '{Some($v)} + case None => '{None} + } + } + + private inline def annotatedAllowUnknownKeys[T]: Option[Boolean] = ${annotatedAllowUnknownKeysImpl[T]} + private def annotatedAllowUnknownKeysImpl[T](using q: Quotes, t: Type[T]): Expr[Option[Boolean]] = { + import q.reflect.* + TypeRepr.of[T].typeSymbol.annotations.collectFirst { + case Apply(Select(New(tpt), _), List(Literal(BooleanConstant(b)))) + if tpt.tpe =:= TypeRepr.of[upickle.implicits.allowUnknownKeys] => + Expr(b) + } match { + case Some(v) => '{Some($v)} + case None => '{None} + } + } + + private def derefSchema(schema: ujson.Value, registry: Registry): Option[ujson.Obj] = schema match { + case o: ujson.Obj => + o.obj.get("$ref") match { + case Some(ujson.Str(ref)) if ref.startsWith("#/$defs/") => + registry.defs.get(ref.stripPrefix("#/$defs/")).collect { case obj: ujson.Obj => obj } + case _ => Some(o) + } + case _ => None + } + + private def mapSchema(valueSchema: ujson.Value, keySchema: MapKeySchema[?]): ujson.Obj = { + val out = ujson.Obj("type" -> "object", "additionalProperties" -> valueSchema) + keySchema.propertyNames.foreach(v => out("propertyNames") = v) + out + } + + private def propertyNamesFromKeyKind(kind: String): Option[ujson.Value] = kind match { + case "integral" => Some(ujson.Obj("type" -> "string", "pattern" -> IntegralPattern)) + case "boolean" => Some(ujson.Obj("type" -> "string", "pattern" -> BooleanPattern)) + case "char" => Some(ujson.Obj("type" -> "string", "minLength" -> 1, "maxLength" -> 1)) + case "uuid" => Some(ujson.Obj("type" -> "string", "format" -> "uuid")) + case _ => None + } + + private def flattenMapValueSchema(schema: ujson.Value, registry: Registry): Option[ujson.Value] = { + derefSchema(schema, registry) match { + case Some(obj) if obj.obj.contains("additionalProperties") => + obj.obj.get("additionalProperties") + case Some(obj) if obj.obj.get("type").contains(ujson.Str("array")) => + obj.obj.get("items") match { + case Some(itemObj: ujson.Obj) => + itemObj.obj.get("prefixItems") match { + case Some(prefixItems: ujson.Arr) if prefixItems.value.length >= 2 => + val valueSchema = prefixItems.value(1) + Some(valueSchema) + case _ => None + } + case _ => None + } + case _ => None + } + } + + + inline def derived[T](using m: Mirror.Of[T]): JsonSchema[T] = + derivedWithSeen[T, EmptyTuple](using m) + + private inline def derivedWithSeen[T, Seen <: Tuple](using m: Mirror.Of[T]): JsonSchema[T] = + inline m match { + case _: Mirror.ProductOf[T] => productSchema[T, m.MirroredElemLabels, m.MirroredElemTypes, T *: Seen] + case _: Mirror.SumOf[T] => sumSchema[T, m.MirroredElemTypes, T *: Seen] + } + + transparent inline given product[T](using m: Mirror.ProductOf[T]): JsonSchema[T] = + productSchema[T, m.MirroredElemLabels, m.MirroredElemTypes, T *: EmptyTuple] + + transparent inline given sum[T](using m: Mirror.SumOf[T]): JsonSchema[T] = + sumSchema[T, m.MirroredElemTypes, T *: EmptyTuple] + + private inline def productSchema[T, Labels <: Tuple, Elems <: Tuple, Seen <: Tuple]: JsonSchema[T] = { + val fieldLabels = labelsToList[Labels] + val rawFieldMeta = productFieldMetadata[T] + val fieldSchemas = summonSchemas[Elems, Seen] + val objectAllowUnknownOverride = annotatedAllowUnknownKeys[T] + new JsonSchema[T] { + override def schema(api: upickle.Api, registry: Registry): ujson.Value = { + val defKey = typeId[T] + registry.define(defKey) { + if (fieldLabels.nonEmpty && isTupleLabels(fieldLabels)) { + ujson.Obj( + "type" -> "array", + "prefixItems" -> ujson.Arr.from(fieldSchemas.map(_.schema(api, registry))), + "minItems" -> fieldSchemas.size, + "maxItems" -> fieldSchemas.size + ) + } else { + val chosenLabels = + if (rawFieldMeta.size == fieldSchemas.size) rawFieldMeta.map(_._1) + else fieldLabels + val fieldMetaByLabel = rawFieldMeta.map { case (label, hasDefault, isFlatten, isFlattenMap, keyKind) => + label -> (hasDefault, isFlatten, isFlattenMap, keyKind) + }.toMap + val allowUnknown = objectAllowUnknownOverride.getOrElse(api.allowUnknownKeys) + val props = ujson.Obj() + val required = mutable.LinkedHashSet.empty[String] + var flattenMapAdditionalProperties: Option[ujson.Value] = None + var flattenMapPropertyNames: Option[ujson.Value] = None + + chosenLabels.zip(fieldSchemas).foreach { case (label, schema) => + val mappedLabel = api.objectAttributeKeyWriteMap(label).toString + fieldMetaByLabel.get(label) match { + case Some((_, _, true, keyKind)) => + flattenMapValueSchema(schema.schema(api, registry), registry) + .foreach(v => flattenMapAdditionalProperties = Some(v)) + propertyNamesFromKeyKind(keyKind).foreach(v => flattenMapPropertyNames = Some(v)) + case Some((_, true, _, _)) => + val nestedObj = derefSchema(schema.schema(api, registry), registry) + nestedObj match { + case Some(obj) => + obj.obj.get("properties").collect { case p: ujson.Obj => p }.foreach { p => + p.value.foreach { case (k, v) => props(k) = v } + } + obj.obj.get("required").collect { case arr: ujson.Arr => arr }.foreach { arr => + arr.value.foreach { + case ujson.Str(k) => required += k + case _ => + } + } + case None => + props(mappedLabel) = schema.schema(api, registry) + } + case Some((hasDefault, _, _, _)) => + props(mappedLabel) = schema.schema(api, registry) + if (!hasDefault) required += mappedLabel + case None => + props(mappedLabel) = schema.schema(api, registry) + required += mappedLabel + } + } + + val additionalProperties = flattenMapAdditionalProperties.getOrElse(ujson.Bool(allowUnknown)) + val out = ujson.Obj( + "type" -> "object", + "properties" -> props, + "required" -> ujson.Arr.from(required), + "additionalProperties" -> additionalProperties + ) + flattenMapPropertyNames.foreach { v => + if (props.value.isEmpty) out("propertyNames") = v + else { + out("propertyNames") = ujson.Obj( + "anyOf" -> ujson.Arr( + v, + ujson.Obj("enum" -> ujson.Arr.from(props.value.keys)) + ) + ) + } + } + out + } + } + } + } + } + + private inline def sumSchema[T, Elems <: Tuple, Seen <: Tuple]: JsonSchema[T] = { + val alts = summonSumSchemas[Elems, Seen] + val tagKeyOverride = annotatedSumTagKey[T] + new JsonSchema[T] { + override def schema(api: upickle.Api, registry: Registry): ujson.Value = { + val defKey = typeId[T] + registry.define(defKey) { + val tagKey = api.objectAttributeKeyWriteMap(tagKeyOverride.getOrElse(api.tagName)).toString + ujson.Obj( + "oneOf" -> ujson.Arr.from( + alts.map { + case (true, fullTagName, shortTagName, _) => + val rawTag = if (api.objectTypeKeyWriteFullyQualified) fullTagName else shortTagName + ujson.Obj("const" -> api.objectTypeKeyWriteMap(rawTag).toString) + case (false, fullTagName, shortTagName, altSchema) => + val rawTag = if (api.objectTypeKeyWriteFullyQualified) fullTagName else shortTagName + ujson.Obj( + "allOf" -> ujson.Arr( + altSchema.schema(api, registry), + ujson.Obj( + "type" -> "object", + "properties" -> ujson.Obj( + tagKey -> ujson.Obj( + "const" -> api.objectTypeKeyWriteMap(rawTag).toString + ) + ), + "required" -> ujson.Arr(tagKey) + ) + ) + ) + } + ) + ) + } + } + } + } + + given JsonSchema[String] with { def schema(api: upickle.Api, registry: Registry) = primitive("string") } + given JsonSchema[Char] with { def schema(api: upickle.Api, registry: Registry) = primitive("string") } + given JsonSchema[Symbol] with { def schema(api: upickle.Api, registry: Registry) = primitive("string") } + given JsonSchema[java.util.UUID] with { def schema(api: upickle.Api, registry: Registry) = primitive("string") } + given JsonSchema[Boolean] with { def schema(api: upickle.Api, registry: Registry) = primitive("boolean") } + + given JsonSchema[Int] with { def schema(api: upickle.Api, registry: Registry) = primitive("integer") } + given JsonSchema[Long] with { + def schema(api: upickle.Api, registry: Registry) = + ujson.Obj( + "anyOf" -> ujson.Arr( + primitive("integer"), + ujson.Obj("type" -> "string", "pattern" -> IntegralPattern) + ) + ) + } + given JsonSchema[Short] with { def schema(api: upickle.Api, registry: Registry) = primitive("integer") } + given JsonSchema[Byte] with { def schema(api: upickle.Api, registry: Registry) = primitive("integer") } + given JsonSchema[BigInt] with { + def schema(api: upickle.Api, registry: Registry) = + ujson.Obj("type" -> "string", "pattern" -> IntegralPattern) + } + + given JsonSchema[Double] with { def schema(api: upickle.Api, registry: Registry) = primitive("number") } + given JsonSchema[Float] with { def schema(api: upickle.Api, registry: Registry) = primitive("number") } + given JsonSchema[BigDecimal] with { def schema(api: upickle.Api, registry: Registry) = primitive("string") } + + given JsonSchema[Unit] with { def schema(api: upickle.Api, registry: Registry) = ujson.Obj("type" -> "null") } + given JsonSchema[ujson.Value] with { def schema(api: upickle.Api, registry: Registry) = ujson.Obj() } + + given [T](using inner: JsonSchema[T]): JsonSchema[Option[T]] = new JsonSchema[Option[T]] { + def schema(api: upickle.Api, registry: Registry): ujson.Value = + if (api.optionsAsNulls) { + ujson.Obj( + "anyOf" -> ujson.Arr( + inner.schema(api, registry), + ujson.Obj("type" -> "null"), + ujson.Obj( + "type" -> "array", + "minItems" -> 0, + "maxItems" -> 1, + "items" -> inner.schema(api, registry) + ) + ) + ) + } else { + ujson.Obj( + "type" -> "array", + "minItems" -> 0, + "maxItems" -> 1, + "items" -> inner.schema(api, registry) + ) + } + } + + given [T](using inner: JsonSchema[T]): JsonSchema[List[T]] = new JsonSchema[List[T]] { + def schema(api: upickle.Api, registry: Registry): ujson.Value = + arraySchema(inner.schema(api, registry)) + } + given [T](using inner: JsonSchema[T]): JsonSchema[Vector[T]] = new JsonSchema[Vector[T]] { + def schema(api: upickle.Api, registry: Registry): ujson.Value = + arraySchema(inner.schema(api, registry)) + } + given [T](using inner: JsonSchema[T]): JsonSchema[Seq[T]] = new JsonSchema[Seq[T]] { + def schema(api: upickle.Api, registry: Registry): ujson.Value = + arraySchema(inner.schema(api, registry)) + } + given [T](using inner: JsonSchema[T]): JsonSchema[Set[T]] = new JsonSchema[Set[T]] { + def schema(api: upickle.Api, registry: Registry): ujson.Value = + arraySchema(inner.schema(api, registry), uniqueItems = true) + } + given [T](using inner: JsonSchema[T]): JsonSchema[Array[T]] = new JsonSchema[Array[T]] { + def schema(api: upickle.Api, registry: Registry): ujson.Value = + arraySchema(inner.schema(api, registry)) + } + given [A, B](using aSchema: JsonSchema[A], bSchema: JsonSchema[B]): JsonSchema[(A, B)] = + new JsonSchema[(A, B)] { + override def schema(api: upickle.Api, registry: Registry): ujson.Value = { + ujson.Obj( + "type" -> "array", + "prefixItems" -> ujson.Arr( + aSchema.schema(api, registry), + bSchema.schema(api, registry) + ), + "minItems" -> 2, + "maxItems" -> 2 + ) + } + } + + given [K, V](using valueSchema: JsonSchema[V], keySchema: MapKeySchema[K]): JsonSchema[Map[K, V]] = + new JsonSchema[Map[K, V]] { + def schema(api: upickle.Api, registry: Registry): ujson.Value = + mapSchema(valueSchema.schema(api, registry), keySchema) + } + given [K, V]( + using valueSchema: JsonSchema[V], + keySchema: MapKeySchema[K] + ): JsonSchema[scala.collection.mutable.LinkedHashMap[K, V]] = + new JsonSchema[scala.collection.mutable.LinkedHashMap[K, V]] { + def schema(api: upickle.Api, registry: Registry): ujson.Value = + mapSchema(valueSchema.schema(api, registry), keySchema) + } + + def schemaFor[T](api: upickle.Api)(using JsonSchema[T]): ujson.Value = { + val registry = new Registry + val root = summon[JsonSchema[T]].schema(api, registry) + val defs = registry.defs + if (defs.isEmpty) { + root match { + case obj: ujson.Obj => + obj("$schema") = ujson.Str(Draft202012) + obj + case other => + ujson.Obj("$schema" -> Draft202012, "allOf" -> ujson.Arr(other)) + } + } else { + val out = ujson.Obj( + "$schema" -> Draft202012, + "$defs" -> ujson.Obj.from(defs) + ) + if (isRef(root)) out("$ref") = root("$ref") + else out("allOf") = ujson.Arr(root) + out + } + } + + def definitionsFor[T](api: upickle.Api)(using JsonSchema[T]): collection.immutable.ListMap[String, ujson.Value] = { + val registry = new Registry + summon[JsonSchema[T]].schema(api, registry) + registry.defs + } +} + +extension (api: upickle.Api) { + inline def schema[T](using api.ReadWriter[T], JsonSchema[T]): ujson.Value = + JsonSchema.schemaFor[T](api) + + inline def schemas[T](using api.ReadWriter[T], JsonSchema[T]): collection.immutable.ListMap[String, ujson.Value] = + JsonSchema.definitionsFor[T](api) +} diff --git a/upickle/jsonschema/test/resources/schemas/ClassDefs_ADTs_ADT0.json b/upickle/jsonschema/test/resources/schemas/ClassDefs_ADTs_ADT0.json new file mode 100644 index 000000000..fa31a7e66 --- /dev/null +++ b/upickle/jsonschema/test/resources/schemas/ClassDefs_ADTs_ADT0.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "upickletest.ADTs.ADT0": { + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": true + } + }, + "$ref": "#/$defs/upickletest.ADTs.ADT0" +} \ No newline at end of file diff --git a/upickle/jsonschema/test/resources/schemas/ClassDefs_ADTs_ADTa.json b/upickle/jsonschema/test/resources/schemas/ClassDefs_ADTs_ADTa.json new file mode 100644 index 000000000..6cffb0177 --- /dev/null +++ b/upickle/jsonschema/test/resources/schemas/ClassDefs_ADTs_ADTa.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "upickletest.ADTs.ADTa": { + "type": "object", + "properties": { + "i": { + "type": "integer" + } + }, + "required": [ + "i" + ], + "additionalProperties": true + } + }, + "$ref": "#/$defs/upickletest.ADTs.ADTa" +} \ No newline at end of file diff --git a/upickle/jsonschema/test/resources/schemas/ClassDefs_ADTs_ADTb.json b/upickle/jsonschema/test/resources/schemas/ClassDefs_ADTs_ADTb.json new file mode 100644 index 000000000..5238a285b --- /dev/null +++ b/upickle/jsonschema/test/resources/schemas/ClassDefs_ADTs_ADTb.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "upickletest.ADTs.ADTb": { + "type": "object", + "properties": { + "i": { + "type": "integer" + }, + "s": { + "type": "string" + } + }, + "required": [ + "i", + "s" + ], + "additionalProperties": true + } + }, + "$ref": "#/$defs/upickletest.ADTs.ADTb" +} \ No newline at end of file diff --git a/upickle/jsonschema/test/resources/schemas/ClassDefs_ADTs_ADTc.json b/upickle/jsonschema/test/resources/schemas/ClassDefs_ADTs_ADTc.json new file mode 100644 index 000000000..a50a43252 --- /dev/null +++ b/upickle/jsonschema/test/resources/schemas/ClassDefs_ADTs_ADTc.json @@ -0,0 +1,36 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "upickletest.ADTs.ADTc": { + "type": "object", + "properties": { + "i": { + "type": "integer" + }, + "s": { + "type": "string" + }, + "t": { + "type": "array", + "prefixItems": [ + { + "type": "number" + }, + { + "type": "number" + } + ], + "minItems": 2, + "maxItems": 2 + } + }, + "required": [ + "i", + "s", + "t" + ], + "additionalProperties": true + } + }, + "$ref": "#/$defs/upickletest.ADTs.ADTc" +} \ No newline at end of file diff --git a/upickle/jsonschema/test/resources/schemas/ClassDefs_ADTs_ADTd.json b/upickle/jsonschema/test/resources/schemas/ClassDefs_ADTs_ADTd.json new file mode 100644 index 000000000..ebedd139d --- /dev/null +++ b/upickle/jsonschema/test/resources/schemas/ClassDefs_ADTs_ADTd.json @@ -0,0 +1,52 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "upickletest.ADTs.ADTa": { + "type": "object", + "properties": { + "i": { + "type": "integer" + } + }, + "required": [ + "i" + ], + "additionalProperties": true + }, + "upickletest.ADTs.ADTd": { + "type": "object", + "properties": { + "i": { + "type": "integer" + }, + "s": { + "type": "string" + }, + "t": { + "type": "array", + "prefixItems": [ + { + "type": "number" + }, + { + "type": "number" + } + ], + "minItems": 2, + "maxItems": 2 + }, + "a": { + "$ref": "#/$defs/upickletest.ADTs.ADTa" + } + }, + "required": [ + "i", + "s", + "t", + "a" + ], + "additionalProperties": true + } + }, + "$ref": "#/$defs/upickletest.ADTs.ADTd" +} \ No newline at end of file diff --git a/upickle/jsonschema/test/resources/schemas/ClassDefs_ADTs_ADTe.json b/upickle/jsonschema/test/resources/schemas/ClassDefs_ADTs_ADTe.json new file mode 100644 index 000000000..3a9c386f8 --- /dev/null +++ b/upickle/jsonschema/test/resources/schemas/ClassDefs_ADTs_ADTe.json @@ -0,0 +1,59 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "upickletest.ADTs.ADTa": { + "type": "object", + "properties": { + "i": { + "type": "integer" + } + }, + "required": [ + "i" + ], + "additionalProperties": true + }, + "upickletest.ADTs.ADTe": { + "type": "object", + "properties": { + "i": { + "type": "integer" + }, + "s": { + "type": "string" + }, + "t": { + "type": "array", + "prefixItems": [ + { + "type": "number" + }, + { + "type": "number" + } + ], + "minItems": 2, + "maxItems": 2 + }, + "a": { + "$ref": "#/$defs/upickletest.ADTs.ADTa" + }, + "q": { + "type": "array", + "items": { + "type": "number" + } + } + }, + "required": [ + "i", + "s", + "t", + "a", + "q" + ], + "additionalProperties": true + } + }, + "$ref": "#/$defs/upickletest.ADTs.ADTe" +} \ No newline at end of file diff --git a/upickle/jsonschema/test/resources/schemas/ClassDefs_ADTs_ADTf.json b/upickle/jsonschema/test/resources/schemas/ClassDefs_ADTs_ADTf.json new file mode 100644 index 000000000..e679071d4 --- /dev/null +++ b/upickle/jsonschema/test/resources/schemas/ClassDefs_ADTs_ADTf.json @@ -0,0 +1,108 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "upickletest.ADTs.ADTa": { + "type": "object", + "properties": { + "i": { + "type": "integer" + } + }, + "required": [ + "i" + ], + "additionalProperties": true + }, + "upickletest.ADTs.ADTf": { + "type": "object", + "properties": { + "i": { + "type": "integer" + }, + "s": { + "type": "string" + }, + "t": { + "type": "array", + "prefixItems": [ + { + "type": "number" + }, + { + "type": "number" + } + ], + "minItems": 2, + "maxItems": 2 + }, + "a": { + "$ref": "#/$defs/upickletest.ADTs.ADTa" + }, + "q": { + "type": "array", + "items": { + "type": "number" + } + }, + "o": { + "anyOf": [ + { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "minItems": 0, + "maxItems": 1, + "items": { + "type": "boolean" + } + } + ] + }, + { + "type": "null" + }, + { + "type": "array", + "minItems": 0, + "maxItems": 1, + "items": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "minItems": 0, + "maxItems": 1, + "items": { + "type": "boolean" + } + } + ] + } + } + ] + } + }, + "required": [ + "i", + "s", + "t", + "a", + "q", + "o" + ], + "additionalProperties": true + } + }, + "$ref": "#/$defs/upickletest.ADTs.ADTf" +} \ No newline at end of file diff --git a/upickle/jsonschema/test/resources/schemas/ClassDefs_ADTs_ADTz.json b/upickle/jsonschema/test/resources/schemas/ClassDefs_ADTs_ADTz.json new file mode 100644 index 000000000..6512c0473 --- /dev/null +++ b/upickle/jsonschema/test/resources/schemas/ClassDefs_ADTs_ADTz.json @@ -0,0 +1,86 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "upickletest.ADTs.ADTz": { + "type": "object", + "properties": { + "t1": { + "type": "integer" + }, + "t2": { + "type": "string" + }, + "t3": { + "type": "integer" + }, + "t4": { + "type": "string" + }, + "t5": { + "type": "integer" + }, + "t6": { + "type": "string" + }, + "t7": { + "type": "integer" + }, + "t8": { + "type": "string" + }, + "t9": { + "type": "integer" + }, + "t10": { + "type": "string" + }, + "t11": { + "type": "integer" + }, + "t12": { + "type": "string" + }, + "t13": { + "type": "integer" + }, + "t14": { + "type": "string" + }, + "t15": { + "type": "integer" + }, + "t16": { + "type": "string" + }, + "t17": { + "type": "integer" + }, + "t18": { + "type": "string" + } + }, + "required": [ + "t1", + "t2", + "t3", + "t4", + "t5", + "t6", + "t7", + "t8", + "t9", + "t10", + "t11", + "t12", + "t13", + "t14", + "t15", + "t16", + "t17", + "t18" + ], + "additionalProperties": true + } + }, + "$ref": "#/$defs/upickletest.ADTs.ADTz" +} \ No newline at end of file diff --git a/upickle/jsonschema/test/resources/schemas/ClassDefs_C1.json b/upickle/jsonschema/test/resources/schemas/ClassDefs_C1.json new file mode 100644 index 000000000..0a2824fbb --- /dev/null +++ b/upickle/jsonschema/test/resources/schemas/ClassDefs_C1.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "upickletest.C1": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "types": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "name", + "types" + ], + "additionalProperties": true + } + }, + "$ref": "#/$defs/upickletest.C1" +} \ No newline at end of file diff --git a/upickle/jsonschema/test/resources/schemas/ClassDefs_C2.json b/upickle/jsonschema/test/resources/schemas/ClassDefs_C2.json new file mode 100644 index 000000000..b6ba5b5c3 --- /dev/null +++ b/upickle/jsonschema/test/resources/schemas/ClassDefs_C2.json @@ -0,0 +1,40 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "upickletest.C1": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "types": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "name", + "types" + ], + "additionalProperties": true + }, + "upickletest.C2": { + "type": "object", + "properties": { + "results": { + "type": "array", + "items": { + "$ref": "#/$defs/upickletest.C1" + } + } + }, + "required": [ + "results" + ], + "additionalProperties": true + } + }, + "$ref": "#/$defs/upickletest.C2" +} \ No newline at end of file diff --git a/upickle/jsonschema/test/resources/schemas/ClassDefs_Defaults_ADTa.json b/upickle/jsonschema/test/resources/schemas/ClassDefs_Defaults_ADTa.json new file mode 100644 index 000000000..9c86542a9 --- /dev/null +++ b/upickle/jsonschema/test/resources/schemas/ClassDefs_Defaults_ADTa.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "upickletest.Defaults.ADTa": { + "type": "object", + "properties": { + "i": { + "type": "integer" + } + }, + "required": [], + "additionalProperties": true + } + }, + "$ref": "#/$defs/upickletest.Defaults.ADTa" +} \ No newline at end of file diff --git a/upickle/jsonschema/test/resources/schemas/ClassDefs_Defaults_ADTb.json b/upickle/jsonschema/test/resources/schemas/ClassDefs_Defaults_ADTb.json new file mode 100644 index 000000000..e6c531032 --- /dev/null +++ b/upickle/jsonschema/test/resources/schemas/ClassDefs_Defaults_ADTb.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "upickletest.Defaults.ADTb": { + "type": "object", + "properties": { + "i": { + "type": "integer" + }, + "s": { + "type": "string" + } + }, + "required": [ + "s" + ], + "additionalProperties": true + } + }, + "$ref": "#/$defs/upickletest.Defaults.ADTb" +} \ No newline at end of file diff --git a/upickle/jsonschema/test/resources/schemas/ClassDefs_Defaults_ADTc.json b/upickle/jsonschema/test/resources/schemas/ClassDefs_Defaults_ADTc.json new file mode 100644 index 000000000..0c924a9f1 --- /dev/null +++ b/upickle/jsonschema/test/resources/schemas/ClassDefs_Defaults_ADTc.json @@ -0,0 +1,34 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "upickletest.Defaults.ADTc": { + "type": "object", + "properties": { + "i": { + "type": "integer" + }, + "s": { + "type": "string" + }, + "t": { + "type": "array", + "prefixItems": [ + { + "type": "number" + }, + { + "type": "number" + } + ], + "minItems": 2, + "maxItems": 2 + } + }, + "required": [ + "s" + ], + "additionalProperties": true + } + }, + "$ref": "#/$defs/upickletest.Defaults.ADTc" +} \ No newline at end of file diff --git a/upickle/jsonschema/test/resources/schemas/ClassDefs_GeoCoding2.json b/upickle/jsonschema/test/resources/schemas/ClassDefs_GeoCoding2.json new file mode 100644 index 000000000..be5597ede --- /dev/null +++ b/upickle/jsonschema/test/resources/schemas/ClassDefs_GeoCoding2.json @@ -0,0 +1,48 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "upickletest.Result2": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "whatever": { + "type": "string" + }, + "types": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "name", + "whatever", + "types" + ], + "additionalProperties": true + }, + "upickletest.GeoCoding2": { + "type": "object", + "properties": { + "results": { + "type": "array", + "items": { + "$ref": "#/$defs/upickletest.Result2" + } + }, + "status": { + "type": "string" + } + }, + "required": [ + "results", + "status" + ], + "additionalProperties": true + } + }, + "$ref": "#/$defs/upickletest.GeoCoding2" +} \ No newline at end of file diff --git a/upickle/jsonschema/test/resources/schemas/ClassDefs_Result2.json b/upickle/jsonschema/test/resources/schemas/ClassDefs_Result2.json new file mode 100644 index 000000000..a399eadb4 --- /dev/null +++ b/upickle/jsonschema/test/resources/schemas/ClassDefs_Result2.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "upickletest.Result2": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "whatever": { + "type": "string" + }, + "types": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "name", + "whatever", + "types" + ], + "additionalProperties": true + } + }, + "$ref": "#/$defs/upickletest.Result2" +} \ No newline at end of file diff --git a/upickle/jsonschema/test/resources/schemas/Enum_ADomain.json b/upickle/jsonschema/test/resources/schemas/Enum_ADomain.json new file mode 100644 index 000000000..eed75502f --- /dev/null +++ b/upickle/jsonschema/test/resources/schemas/Enum_ADomain.json @@ -0,0 +1,28 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "upickletest.Domain": { + "oneOf": [ + { + "const": "Something" + }, + { + "const": "reddit.com" + } + ] + }, + "upickletest.ADomain": { + "type": "object", + "properties": { + "d": { + "$ref": "#/$defs/upickletest.Domain" + } + }, + "required": [ + "d" + ], + "additionalProperties": true + } + }, + "$ref": "#/$defs/upickletest.ADomain" +} \ No newline at end of file diff --git a/upickle/jsonschema/test/resources/schemas/Enum_ColorEnum.json b/upickle/jsonschema/test/resources/schemas/Enum_ColorEnum.json new file mode 100644 index 000000000..aca9e077d --- /dev/null +++ b/upickle/jsonschema/test/resources/schemas/Enum_ColorEnum.json @@ -0,0 +1,79 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "upickletest.ColorEnum.Mix": { + "type": "object", + "properties": { + "mix": { + "type": "integer" + } + }, + "required": [ + "mix" + ], + "additionalProperties": true + }, + "upickletest.ColorEnum.Unknown": { + "type": "object", + "properties": { + "mix": { + "type": "integer" + } + }, + "required": [ + "mix" + ], + "additionalProperties": true + }, + "upickletest.ColorEnum": { + "oneOf": [ + { + "const": "Red" + }, + { + "const": "Green" + }, + { + "const": "Blue" + }, + { + "allOf": [ + { + "$ref": "#/$defs/upickletest.ColorEnum.Mix" + }, + { + "type": "object", + "properties": { + "$type": { + "const": "Mix" + } + }, + "required": [ + "$type" + ] + } + ] + }, + { + "allOf": [ + { + "$ref": "#/$defs/upickletest.ColorEnum.Unknown" + }, + { + "type": "object", + "properties": { + "$type": { + "const": "Unknown" + } + }, + "required": [ + "$type" + ] + } + ] + } + ] + } + }, + "$ref": "#/$defs/upickletest.ColorEnum" +} \ No newline at end of file diff --git a/upickle/jsonschema/test/resources/schemas/Enum_Domain.json b/upickle/jsonschema/test/resources/schemas/Enum_Domain.json new file mode 100644 index 000000000..e767e5297 --- /dev/null +++ b/upickle/jsonschema/test/resources/schemas/Enum_Domain.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "upickletest.Domain": { + "oneOf": [ + { + "const": "Something" + }, + { + "const": "reddit.com" + } + ] + } + }, + "$ref": "#/$defs/upickletest.Domain" +} \ No newline at end of file diff --git a/upickle/jsonschema/test/resources/schemas/Enum_Enclosing.json b/upickle/jsonschema/test/resources/schemas/Enum_Enclosing.json new file mode 100644 index 000000000..9dfe600a7 --- /dev/null +++ b/upickle/jsonschema/test/resources/schemas/Enum_Enclosing.json @@ -0,0 +1,50 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "upickletest.SimpleEnum": { + "oneOf": [ + { + "const": "A" + }, + { + "const": "B" + } + ] + }, + "upickletest.Enclosing": { + "type": "object", + "properties": { + "str": { + "type": "string" + }, + "simple1": { + "$ref": "#/$defs/upickletest.SimpleEnum" + }, + "simple2": { + "anyOf": [ + { + "$ref": "#/$defs/upickletest.SimpleEnum" + }, + { + "type": "null" + }, + { + "type": "array", + "minItems": 0, + "maxItems": 1, + "items": { + "$ref": "#/$defs/upickletest.SimpleEnum" + } + } + ] + } + }, + "required": [ + "str", + "simple1" + ], + "additionalProperties": true + } + }, + "$ref": "#/$defs/upickletest.Enclosing" +} \ No newline at end of file diff --git a/upickle/jsonschema/test/resources/schemas/Enum_LinkedList_Int.json b/upickle/jsonschema/test/resources/schemas/Enum_LinkedList_Int.json new file mode 100644 index 000000000..5280a255d --- /dev/null +++ b/upickle/jsonschema/test/resources/schemas/Enum_LinkedList_Int.json @@ -0,0 +1,81 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "upickletest.LinkedList.Node[scala.Int]": { + "type": "object", + "properties": { + "value": { + "type": "integer" + }, + "next": { + "$ref": "#/$defs/upickletest.LinkedList[scala.Int]" + } + }, + "required": [ + "value", + "next" + ], + "additionalProperties": true + }, + "upickletest.LinkedList.Node2[scala.Int]": { + "type": "object", + "properties": { + "value": { + "type": "integer" + }, + "next": { + "$ref": "#/$defs/upickletest.LinkedList.Node[scala.Int]" + } + }, + "required": [ + "value", + "next" + ], + "additionalProperties": true + }, + "upickletest.LinkedList[scala.Int]": { + "oneOf": [ + { + "const": "End" + }, + { + "allOf": [ + { + "$ref": "#/$defs/upickletest.LinkedList.Node[scala.Int]" + }, + { + "type": "object", + "properties": { + "$type": { + "const": "Node" + } + }, + "required": [ + "$type" + ] + } + ] + }, + { + "allOf": [ + { + "$ref": "#/$defs/upickletest.LinkedList.Node2[scala.Int]" + }, + { + "type": "object", + "properties": { + "$type": { + "const": "Node2" + } + }, + "required": [ + "$type" + ] + } + ] + } + ] + } + }, + "$ref": "#/$defs/upickletest.LinkedList[scala.Int]" +} \ No newline at end of file diff --git a/upickle/jsonschema/test/resources/schemas/Enum_SimpleEnum.json b/upickle/jsonschema/test/resources/schemas/Enum_SimpleEnum.json new file mode 100644 index 000000000..3b58e58dd --- /dev/null +++ b/upickle/jsonschema/test/resources/schemas/Enum_SimpleEnum.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "upickletest.SimpleEnum": { + "oneOf": [ + { + "const": "A" + }, + { + "const": "B" + } + ] + } + }, + "$ref": "#/$defs/upickletest.SimpleEnum" +} \ No newline at end of file diff --git a/upickle/jsonschema/test/resources/schemas/Macro_Flatten_Collection.json b/upickle/jsonschema/test/resources/schemas/Macro_Flatten_Collection.json new file mode 100644 index 000000000..f3a75b5ad --- /dev/null +++ b/upickle/jsonschema/test/resources/schemas/Macro_Flatten_Collection.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "upickletest.Flatten.ValueClass": { + "type": "object", + "properties": { + "value": { + "type": "number" + } + }, + "required": [ + "value" + ], + "additionalProperties": true + }, + "upickletest.Flatten.Collection": { + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": { + "$ref": "#/$defs/upickletest.Flatten.ValueClass" + } + } + }, + "$ref": "#/$defs/upickletest.Flatten.Collection" +} \ No newline at end of file diff --git a/upickle/jsonschema/test/resources/schemas/Macro_Flatten_FlattenIntKey.json b/upickle/jsonschema/test/resources/schemas/Macro_Flatten_FlattenIntKey.json new file mode 100644 index 000000000..5fd8cae0a --- /dev/null +++ b/upickle/jsonschema/test/resources/schemas/Macro_Flatten_FlattenIntKey.json @@ -0,0 +1,33 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "upickletest.Flatten.FlattenIntKey": { + "type": "object", + "properties": { + "i": { + "type": "integer" + } + }, + "required": [ + "i" + ], + "additionalProperties": { + "type": "string" + }, + "propertyNames": { + "anyOf": [ + { + "type": "string", + "pattern": "^-?(0|[1-9][0-9]*)$" + }, + { + "enum": [ + "i" + ] + } + ] + } + } + }, + "$ref": "#/$defs/upickletest.Flatten.FlattenIntKey" +} \ No newline at end of file diff --git a/upickle/jsonschema/test/resources/schemas/Macro_Flatten_FlattenLongKey.json b/upickle/jsonschema/test/resources/schemas/Macro_Flatten_FlattenLongKey.json new file mode 100644 index 000000000..665b45d60 --- /dev/null +++ b/upickle/jsonschema/test/resources/schemas/Macro_Flatten_FlattenLongKey.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "upickletest.Flatten.FlattenLongKey": { + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": { + "type": "integer" + }, + "propertyNames": { + "type": "string", + "pattern": "^-?(0|[1-9][0-9]*)$" + } + } + }, + "$ref": "#/$defs/upickletest.Flatten.FlattenLongKey" +} \ No newline at end of file diff --git a/upickle/jsonschema/test/resources/schemas/Macro_Flatten_FlattenSeq.json b/upickle/jsonschema/test/resources/schemas/Macro_Flatten_FlattenSeq.json new file mode 100644 index 000000000..8730434a2 --- /dev/null +++ b/upickle/jsonschema/test/resources/schemas/Macro_Flatten_FlattenSeq.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "upickletest.Flatten.FlattenSeq": { + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": { + "type": "integer" + } + } + }, + "$ref": "#/$defs/upickletest.Flatten.FlattenSeq" +} \ No newline at end of file diff --git a/upickle/jsonschema/test/resources/schemas/Macro_Flatten_FlattenSeqIntKey.json b/upickle/jsonschema/test/resources/schemas/Macro_Flatten_FlattenSeqIntKey.json new file mode 100644 index 000000000..7d6a5043e --- /dev/null +++ b/upickle/jsonschema/test/resources/schemas/Macro_Flatten_FlattenSeqIntKey.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "upickletest.Flatten.FlattenSeqIntKey": { + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": { + "type": "string" + }, + "propertyNames": { + "type": "string", + "pattern": "^-?(0|[1-9][0-9]*)$" + } + } + }, + "$ref": "#/$defs/upickletest.Flatten.FlattenSeqIntKey" +} \ No newline at end of file diff --git a/upickle/jsonschema/test/resources/schemas/Macro_Flatten_FlattenWithDefault.json b/upickle/jsonschema/test/resources/schemas/Macro_Flatten_FlattenWithDefault.json new file mode 100644 index 000000000..553deabf8 --- /dev/null +++ b/upickle/jsonschema/test/resources/schemas/Macro_Flatten_FlattenWithDefault.json @@ -0,0 +1,40 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "upickletest.Flatten.NestedWithDefault": { + "type": "object", + "properties": { + "k": { + "type": "integer" + }, + "l": { + "type": "string" + } + }, + "required": [ + "l" + ], + "additionalProperties": true + }, + "upickletest.Flatten.FlattenWithDefault": { + "type": "object", + "properties": { + "i": { + "type": "integer" + }, + "k": { + "type": "integer" + }, + "l": { + "type": "string" + } + }, + "required": [ + "i", + "l" + ], + "additionalProperties": true + } + }, + "$ref": "#/$defs/upickletest.Flatten.FlattenWithDefault" +} \ No newline at end of file diff --git a/upickle/jsonschema/test/resources/schemas/Macro_Flatten_Nested.json b/upickle/jsonschema/test/resources/schemas/Macro_Flatten_Nested.json new file mode 100644 index 000000000..101abc026 --- /dev/null +++ b/upickle/jsonschema/test/resources/schemas/Macro_Flatten_Nested.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "upickletest.Flatten.Nested": { + "type": "object", + "properties": { + "d": { + "type": "number" + } + }, + "required": [ + "d" + ], + "additionalProperties": { + "type": "integer" + } + } + }, + "$ref": "#/$defs/upickletest.Flatten.Nested" +} \ No newline at end of file diff --git a/upickle/jsonschema/test/resources/schemas/Macro_Flatten_Nested2.json b/upickle/jsonschema/test/resources/schemas/Macro_Flatten_Nested2.json new file mode 100644 index 000000000..dc2e2f6dd --- /dev/null +++ b/upickle/jsonschema/test/resources/schemas/Macro_Flatten_Nested2.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "upickletest.Flatten.Nested2": { + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "required": [ + "name" + ], + "additionalProperties": true + } + }, + "$ref": "#/$defs/upickletest.Flatten.Nested2" +} \ No newline at end of file diff --git a/upickle/jsonschema/test/resources/schemas/Macro_Flatten_Outer.json b/upickle/jsonschema/test/resources/schemas/Macro_Flatten_Outer.json new file mode 100644 index 000000000..41b59c27c --- /dev/null +++ b/upickle/jsonschema/test/resources/schemas/Macro_Flatten_Outer.json @@ -0,0 +1,66 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "upickletest.Flatten.InnerMost": { + "type": "object", + "properties": { + "a": { + "type": "string" + }, + "b": { + "type": "integer" + } + }, + "required": [ + "a", + "b" + ], + "additionalProperties": true + }, + "upickletest.Flatten.Inner": { + "type": "object", + "properties": { + "a": { + "type": "string" + }, + "b": { + "type": "integer" + }, + "c": { + "type": "boolean" + } + }, + "required": [ + "a", + "b", + "c" + ], + "additionalProperties": true + }, + "upickletest.Flatten.Outer": { + "type": "object", + "properties": { + "d": { + "type": "number" + }, + "a": { + "type": "string" + }, + "b": { + "type": "integer" + }, + "c": { + "type": "boolean" + } + }, + "required": [ + "d", + "a", + "b", + "c" + ], + "additionalProperties": true + } + }, + "$ref": "#/$defs/upickletest.Flatten.Outer" +} \ No newline at end of file diff --git a/upickle/jsonschema/test/resources/schemas/Macro_GenericIssue545_ApiResult_Person.json b/upickle/jsonschema/test/resources/schemas/Macro_GenericIssue545_ApiResult_Person.json new file mode 100644 index 000000000..6a8e82c4b --- /dev/null +++ b/upickle/jsonschema/test/resources/schemas/Macro_GenericIssue545_ApiResult_Person.json @@ -0,0 +1,51 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "upickletest.GenericIssue545.Person": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id" + ], + "additionalProperties": true + }, + "upickletest.GenericIssue545.ApiResult[upickletest.GenericIssue545.Person]": { + "type": "object", + "properties": { + "data": { + "anyOf": [ + { + "$ref": "#/$defs/upickletest.GenericIssue545.Person" + }, + { + "type": "null" + }, + { + "type": "array", + "minItems": 0, + "maxItems": 1, + "items": { + "$ref": "#/$defs/upickletest.GenericIssue545.Person" + } + } + ] + }, + "total_count": { + "type": "integer" + } + }, + "required": [ + "total_count" + ], + "additionalProperties": true + } + }, + "$ref": "#/$defs/upickletest.GenericIssue545.ApiResult[upickletest.GenericIssue545.Person]" +} \ No newline at end of file diff --git a/upickle/jsonschema/test/resources/schemas/Macro_GenericIssue545_Person.json b/upickle/jsonschema/test/resources/schemas/Macro_GenericIssue545_Person.json new file mode 100644 index 000000000..a0caafe26 --- /dev/null +++ b/upickle/jsonschema/test/resources/schemas/Macro_GenericIssue545_Person.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "upickletest.GenericIssue545.Person": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id" + ], + "additionalProperties": true + } + }, + "$ref": "#/$defs/upickletest.GenericIssue545.Person" +} \ No newline at end of file diff --git a/upickle/jsonschema/test/resources/schemas/Macro_KeyedPerson.json b/upickle/jsonschema/test/resources/schemas/Macro_KeyedPerson.json new file mode 100644 index 000000000..7e44c0525 --- /dev/null +++ b/upickle/jsonschema/test/resources/schemas/Macro_KeyedPerson.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "upickletest.KeyedPerson": { + "type": "object", + "properties": { + "first_name": { + "type": "string" + }, + "last_name": { + "type": "string" + } + }, + "required": [ + "last_name" + ], + "additionalProperties": true + } + }, + "$ref": "#/$defs/upickletest.KeyedPerson" +} \ No newline at end of file diff --git a/upickle/jsonschema/test/resources/schemas/Macro_SealedClass.json b/upickle/jsonschema/test/resources/schemas/Macro_SealedClass.json new file mode 100644 index 000000000..fe6714b7a --- /dev/null +++ b/upickle/jsonschema/test/resources/schemas/Macro_SealedClass.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "upickletest.SealedClass": { + "type": "object", + "properties": { + "i": { + "type": "integer" + }, + "s": { + "type": "string" + } + }, + "required": [ + "i", + "s" + ], + "additionalProperties": true + } + }, + "$ref": "#/$defs/upickletest.SealedClass" +} \ No newline at end of file diff --git a/upickle/jsonschema/test/resources/schemas/Macro_UnknownKeys_Allow.json b/upickle/jsonschema/test/resources/schemas/Macro_UnknownKeys_Allow.json new file mode 100644 index 000000000..b62a4af96 --- /dev/null +++ b/upickle/jsonschema/test/resources/schemas/Macro_UnknownKeys_Allow.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "upickletest.UnknownKeys.Allow": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ], + "additionalProperties": true + } + }, + "$ref": "#/$defs/upickletest.UnknownKeys.Allow" +} \ No newline at end of file diff --git a/upickle/jsonschema/test/resources/schemas/Macro_UnknownKeys_Default.json b/upickle/jsonschema/test/resources/schemas/Macro_UnknownKeys_Default.json new file mode 100644 index 000000000..53b791332 --- /dev/null +++ b/upickle/jsonschema/test/resources/schemas/Macro_UnknownKeys_Default.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "upickletest.UnknownKeys.Default": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ], + "additionalProperties": true + } + }, + "$ref": "#/$defs/upickletest.UnknownKeys.Default" +} \ No newline at end of file diff --git a/upickle/jsonschema/test/resources/schemas/Macro_UnknownKeys_DisAllow.json b/upickle/jsonschema/test/resources/schemas/Macro_UnknownKeys_DisAllow.json new file mode 100644 index 000000000..897616b6c --- /dev/null +++ b/upickle/jsonschema/test/resources/schemas/Macro_UnknownKeys_DisAllow.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "upickletest.UnknownKeys.DisAllow": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ], + "additionalProperties": false + } + }, + "$ref": "#/$defs/upickletest.UnknownKeys.DisAllow" +} \ No newline at end of file diff --git a/upickle/jsonschema/test/resources/schemas/NamedTuples_Example.json b/upickle/jsonschema/test/resources/schemas/NamedTuples_Example.json new file mode 100644 index 000000000..8677fb965 --- /dev/null +++ b/upickle/jsonschema/test/resources/schemas/NamedTuples_Example.json @@ -0,0 +1,44 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "scala.NamedTuple.NamedTuple[scala.Tuple3[\"foo\", \"bar\", \"qux\"], scala.Tuple3[scala.collection.immutable.Seq[scala.Int], scala.Predef.String, scala.Option[scala.Int]]]": { + "type": "object", + "properties": { + "foo": { + "type": "array", + "items": { + "type": "integer" + } + }, + "bar": { + "type": "string" + }, + "qux": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + }, + { + "type": "array", + "minItems": 0, + "maxItems": 1, + "items": { + "type": "integer" + } + } + ] + } + }, + "required": [ + "foo", + "bar", + "qux" + ], + "additionalProperties": true + } + }, + "$ref": "#/$defs/scala.NamedTuple.NamedTuple[scala.Tuple3[\"foo\", \"bar\", \"qux\"], scala.Tuple3[scala.collection.immutable.Seq[scala.Int], scala.Predef.String, scala.Option[scala.Int]]]" +} \ No newline at end of file diff --git a/upickle/jsonschema/test/resources/schemas/NamedTuples_MissingKeyShape.json b/upickle/jsonschema/test/resources/schemas/NamedTuples_MissingKeyShape.json new file mode 100644 index 000000000..591c82a32 --- /dev/null +++ b/upickle/jsonschema/test/resources/schemas/NamedTuples_MissingKeyShape.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "scala.NamedTuple.NamedTuple[scala.Tuple1[\"foo\"], scala.Tuple1[scala.Boolean]]": { + "type": "object", + "properties": { + "foo": { + "type": "boolean" + } + }, + "required": [ + "foo" + ], + "additionalProperties": true + } + }, + "$ref": "#/$defs/scala.NamedTuple.NamedTuple[scala.Tuple1[\"foo\"], scala.Tuple1[scala.Boolean]]" +} \ No newline at end of file diff --git a/upickle/jsonschema/test/resources/schemas/NamedTuples_Schema.json b/upickle/jsonschema/test/resources/schemas/NamedTuples_Schema.json new file mode 100644 index 000000000..e4a17dbdf --- /dev/null +++ b/upickle/jsonschema/test/resources/schemas/NamedTuples_Schema.json @@ -0,0 +1,123 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "scala.NamedTuple.NamedTuple[scala.Tuple3[\"x\", \"y\", \"z\"], scala.Tuple3[scala.Int, scala.Double, scala.Long]]": { + "type": "object", + "properties": { + "x": { + "type": "integer" + }, + "y": { + "type": "number" + }, + "z": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "string", + "pattern": "^-?(0|[1-9][0-9]*)$" + } + ] + } + }, + "required": [ + "x", + "y", + "z" + ], + "additionalProperties": true + }, + "scala.NamedTuple.NamedTuple[scala.Tuple3[\"name\", \"isHuman\", \"isAlien\"], scala.Tuple3[scala.Predef.String, scala.Boolean, scala.Boolean]]": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "isHuman": { + "type": "boolean" + }, + "isAlien": { + "type": "boolean" + } + }, + "required": [ + "name", + "isHuman", + "isAlien" + ], + "additionalProperties": true + }, + "scala.NamedTuple.NamedTuple[scala.Tuple3[\"arr\", \"optionalAny\", \"optionalInt\"], scala.Tuple3[scala.collection.immutable.Seq[scala.Int], scala.Option[scala.Int], scala.Option[scala.Int]]]": { + "type": "object", + "properties": { + "arr": { + "type": "array", + "items": { + "type": "integer" + } + }, + "optionalAny": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + }, + { + "type": "array", + "minItems": 0, + "maxItems": 1, + "items": { + "type": "integer" + } + } + ] + }, + "optionalInt": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + }, + { + "type": "array", + "minItems": 0, + "maxItems": 1, + "items": { + "type": "integer" + } + } + ] + } + }, + "required": [ + "arr", + "optionalAny", + "optionalInt" + ], + "additionalProperties": true + }, + "scala.Tuple3[scala.NamedTuple.NamedTuple[scala.Tuple3[\"x\", \"y\", \"z\"], scala.Tuple3[scala.Int, scala.Double, scala.Long]], scala.NamedTuple.NamedTuple[scala.Tuple3[\"name\", \"isHuman\", \"isAlien\"], scala.Tuple3[scala.Predef.String, scala.Boolean, scala.Boolean]], scala.NamedTuple.NamedTuple[scala.Tuple3[\"arr\", \"optionalAny\", \"optionalInt\"], scala.Tuple3[scala.collection.immutable.Seq[scala.Int], scala.Option[scala.Int], scala.Option[scala.Int]]]]": { + "type": "array", + "prefixItems": [ + { + "$ref": "#/$defs/scala.NamedTuple.NamedTuple[scala.Tuple3[\"x\", \"y\", \"z\"], scala.Tuple3[scala.Int, scala.Double, scala.Long]]" + }, + { + "$ref": "#/$defs/scala.NamedTuple.NamedTuple[scala.Tuple3[\"name\", \"isHuman\", \"isAlien\"], scala.Tuple3[scala.Predef.String, scala.Boolean, scala.Boolean]]" + }, + { + "$ref": "#/$defs/scala.NamedTuple.NamedTuple[scala.Tuple3[\"arr\", \"optionalAny\", \"optionalInt\"], scala.Tuple3[scala.collection.immutable.Seq[scala.Int], scala.Option[scala.Int], scala.Option[scala.Int]]]" + } + ], + "minItems": 3, + "maxItems": 3 + } + }, + "$ref": "#/$defs/scala.Tuple3[scala.NamedTuple.NamedTuple[scala.Tuple3[\"x\", \"y\", \"z\"], scala.Tuple3[scala.Int, scala.Double, scala.Long]], scala.NamedTuple.NamedTuple[scala.Tuple3[\"name\", \"isHuman\", \"isAlien\"], scala.Tuple3[scala.Predef.String, scala.Boolean, scala.Boolean]], scala.NamedTuple.NamedTuple[scala.Tuple3[\"arr\", \"optionalAny\", \"optionalInt\"], scala.Tuple3[scala.collection.immutable.Seq[scala.Int], scala.Option[scala.Int], scala.Option[scala.Int]]]]" +} \ No newline at end of file diff --git a/upickle/jsonschema/test/src/upickle/jsonschema/AdditionalSchemaCoverageSnapshotTests.scala b/upickle/jsonschema/test/src/upickle/jsonschema/AdditionalSchemaCoverageSnapshotTests.scala new file mode 100644 index 000000000..baf6f1eda --- /dev/null +++ b/upickle/jsonschema/test/src/upickle/jsonschema/AdditionalSchemaCoverageSnapshotTests.scala @@ -0,0 +1,105 @@ +package upickle.jsonschema + +import utest.* + +object AdditionalSchemaCoverageSnapshotTests extends TestSuite { + type NamedTupleExample = (foo: Seq[Int], bar: String, qux: Option[Int]) + type NamedTupleSchema = ( + (x: Int, y: Double, z: Long), + (name: String, isHuman: Boolean, isAlien: Boolean), + (arr: Seq[Int], optionalAny: Option[Int], optionalInt: Option[Int]) + ) + type NamedTupleMissingKeyShape = (foo: Boolean) + + val tests = Tests { + test("Enum_SimpleEnum") { + SchemaSnapshotTestUtils.assertSchemaSerializationCase[upickletest.SimpleEnum]( + "schemas/Enum_SimpleEnum.json", + upickletest.SimpleEnum.A, + """"A"""", + """"C"""", + "must be valid to one and only one schema" + ) + } + test("Enum_ColorEnum") { + SchemaSnapshotTestUtils.assertSchemaSerializationCase[upickletest.ColorEnum]( + "schemas/Enum_ColorEnum.json", + upickletest.ColorEnum.Mix(12345), + """{"$type":"Mix","mix":12345}""", + """{"$type":"Mix","mix":"12345"}""", + "integer expected" + ) + } + test("Enum_Enclosing") { + SchemaSnapshotTestUtils.assertSchemaSerializationCase[upickletest.Enclosing]( + "schemas/Enum_Enclosing.json", + upickletest.Enclosing("test", upickletest.SimpleEnum.A, Some(upickletest.SimpleEnum.B)), + """{"str":"test","simple1":"A","simple2":"B"}""", + """{"str":"test","simple1":"A","simple2":1}""", + "must be valid to one and only one schema" + ) + } + test("Enum_LinkedList_Int") { + SchemaSnapshotTestUtils.assertSchemaSerializationCase[upickletest.LinkedList[Int]]( + "schemas/Enum_LinkedList_Int.json", + upickletest.LinkedList.Node2(1, upickletest.LinkedList.Node(2, upickletest.LinkedList.End)), + """{"$type":"Node2","value":1,"next":{"$type":"Node","value":2,"next":"End"}}""", + """{"$type":"Node2","value":"1","next":{"$type":"Node","value":2,"next":"End"}}""", + "integer expected" + ) + } + test("Enum_Domain") { + SchemaSnapshotTestUtils.assertSchemaSerializationCase[upickletest.Domain]( + "schemas/Enum_Domain.json", + upickletest.Domain.`reddit.com`, + """"reddit.com"""", + """"redddit.com"""", + "must be valid to one and only one schema" + ) + } + test("Enum_ADomain") { + SchemaSnapshotTestUtils.assertSchemaSerializationCase[upickletest.ADomain]( + "schemas/Enum_ADomain.json", + upickletest.ADomain(upickletest.Domain.Something), + """{"d":"Something"}""", + """{"d":1}""", + "must be valid to one and only one schema" + ) + } + + test("NamedTuples_Example") { + import upickle.implicits.namedTuples.default.given + SchemaSnapshotTestUtils.assertSchemaSerializationCase[NamedTupleExample]( + "schemas/NamedTuples_Example.json", + (foo = Seq(1, 2, 3), bar = "hello", qux = Some(42)), + """{"foo":[1,2,3],"bar":"hello","qux":42}""", + """{"foo":[1,2,3],"bar":123,"qux":42}""", + "string expected" + ) + } + test("NamedTuples_Schema") { + import upickle.implicits.namedTuples.default.given + SchemaSnapshotTestUtils.assertSchemaSerializationCase[NamedTupleSchema]( + "schemas/NamedTuples_Schema.json", + ( + (x = 23, y = 7.5, z = 500000000000L), + (name = "Alice", isHuman = true, isAlien = false), + (arr = Seq(1, 2, 3), optionalAny = None, optionalInt = Some(42)) + ), + """[{"x":23,"y":7.5,"z":500000000000},{"name":"Alice","isHuman":true,"isAlien":false},{"arr":[1,2,3],"optionalAny":null,"optionalInt":42}]""", + """[{"x":23,"y":7.5,"z":500000000000},{"name":"Alice","isHuman":"true","isAlien":false},{"arr":[1,2,3],"optionalAny":null,"optionalInt":42}]""", + "boolean expected" + ) + } + test("NamedTuples_MissingKeyShape") { + import upickle.implicits.namedTuples.default.given + SchemaSnapshotTestUtils.assertSchemaSerializationCase[NamedTupleMissingKeyShape]( + "schemas/NamedTuples_MissingKeyShape.json", + (foo = true), + """{"foo":true}""", + """{"foo":"true"}""", + "boolean expected" + ) + } + } +} diff --git a/upickle/jsonschema/test/src/upickle/jsonschema/ClassDefsSchemaCoverageSnapshotTests.scala b/upickle/jsonschema/test/src/upickle/jsonschema/ClassDefsSchemaCoverageSnapshotTests.scala new file mode 100644 index 000000000..d9de1c174 --- /dev/null +++ b/upickle/jsonschema/test/src/upickle/jsonschema/ClassDefsSchemaCoverageSnapshotTests.scala @@ -0,0 +1,143 @@ +package upickle.jsonschema + +import utest.* + +object ClassDefsSchemaCoverageSnapshotTests extends TestSuite { + val tests = Tests { + test("ClassDefs_ADTs_ADT0") { + SchemaSnapshotTestUtils.assertSchemaSerializationCase[upickletest.ADTs.ADT0]( + "schemas/ClassDefs_ADTs_ADT0.json", + upickletest.ADTs.ADT0(), + "{}", + """[]""", + "object expected" + ) + } + test("ClassDefs_ADTs_ADTa") { + SchemaSnapshotTestUtils.assertSchemaSerializationCase[upickletest.ADTs.ADTa]( + "schemas/ClassDefs_ADTs_ADTa.json", + upickletest.ADTs.ADTa(1), + """{"i":1}""", + """{"i":"1"}""", + "integer expected" + ) + } + test("ClassDefs_ADTs_ADTb") { + SchemaSnapshotTestUtils.assertSchemaSerializationCase[upickletest.ADTs.ADTb]( + "schemas/ClassDefs_ADTs_ADTb.json", + upickletest.ADTs.ADTb(1, "x"), + """{"i":1,"s":"x"}""", + """{"i":"1","s":"x"}""", + "integer expected" + ) + } + test("ClassDefs_ADTs_ADTc") { + SchemaSnapshotTestUtils.assertSchemaSerializationCase[upickletest.ADTs.ADTc]( + "schemas/ClassDefs_ADTs_ADTc.json", + upickletest.ADTs.ADTc(1, "x", (1.1, 2.2)), + """{"i":1,"s":"x","t":[1.1,2.2]}""", + """{"i":1,"s":"x","t":[1.1,"2.2"]}""", + "number expected" + ) + } + test("ClassDefs_ADTs_ADTd") { + SchemaSnapshotTestUtils.assertSchemaSerializationCase[upickletest.ADTs.ADTd]( + "schemas/ClassDefs_ADTs_ADTd.json", + upickletest.ADTs.ADTd(1, "x", (1.1, 2.2), upickletest.ADTs.ADTa(7)), + """{"i":1,"s":"x","t":[1.1,2.2],"a":{"i":7}}""", + """{"i":1,"s":"x","t":[1.1,2.2],"a":{"i":"7"}}""", + "integer expected" + ) + } + test("ClassDefs_ADTs_ADTe") { + SchemaSnapshotTestUtils.assertSchemaSerializationCase[upickletest.ADTs.ADTe]( + "schemas/ClassDefs_ADTs_ADTe.json", + upickletest.ADTs.ADTe(1, "x", (1.1, 2.2), upickletest.ADTs.ADTa(7), Seq(1.1, 2.2)), + """{"i":1,"s":"x","t":[1.1,2.2],"a":{"i":7},"q":[1.1,2.2]}""", + """{"i":1,"s":"x","t":[1.1,2.2],"a":{"i":7},"q":["1.1",2.2]}""", + "number expected" + ) + } + test("ClassDefs_ADTs_ADTf") { + SchemaSnapshotTestUtils.assertSchemaSerializationCase[upickletest.ADTs.ADTf]( + "schemas/ClassDefs_ADTs_ADTf.json", + upickletest.ADTs.ADTf(1, "x", (1.1, 2.2), upickletest.ADTs.ADTa(7), Seq(1.1, 2.2), Some(Some(true))), + """{"i":1,"s":"x","t":[1.1,2.2],"a":{"i":7},"q":[1.1,2.2],"o":[true]}""", + """{"i":1,"s":"x","t":[1.1,2.2],"a":{"i":7},"q":[1.1,2.2],"o":["true"]}""", + "boolean expected" + ) + } + test("ClassDefs_ADTs_ADTz") { + SchemaSnapshotTestUtils.assertSchemaSerializationCase[upickletest.ADTs.ADTz]( + "schemas/ClassDefs_ADTs_ADTz.json", + upickletest.ADTs.ADTz(1, "a", 2, "b", 3, "c", 4, "d", 5, "e", 6, "f", 7, "g", 8, "h", 9, "i"), + """{"t1":1,"t2":"a","t3":2,"t4":"b","t5":3,"t6":"c","t7":4,"t8":"d","t9":5,"t10":"e","t11":6,"t12":"f","t13":7,"t14":"g","t15":8,"t16":"h","t17":9,"t18":"i"}""", + """{"t1":"1","t2":"a","t3":2,"t4":"b","t5":3,"t6":"c","t7":4,"t8":"d","t9":5,"t10":"e","t11":6,"t12":"f","t13":7,"t14":"g","t15":8,"t16":"h","t17":9,"t18":"i"}""", + "integer expected" + ) + } + test("ClassDefs_Defaults_ADTa") { + SchemaSnapshotTestUtils.assertSchemaSerializationCase[upickletest.Defaults.ADTa]( + "schemas/ClassDefs_Defaults_ADTa.json", + upickletest.Defaults.ADTa(), + "{}", + """{"i":"0"}""", + "integer expected" + ) + } + test("ClassDefs_Defaults_ADTb") { + SchemaSnapshotTestUtils.assertSchemaSerializationCase[upickletest.Defaults.ADTb]( + "schemas/ClassDefs_Defaults_ADTb.json", + upickletest.Defaults.ADTb(s = "x"), + """{"s":"x"}""", + """{"s":1}""", + "string expected" + ) + } + test("ClassDefs_Defaults_ADTc") { + SchemaSnapshotTestUtils.assertSchemaSerializationCase[upickletest.Defaults.ADTc]( + "schemas/ClassDefs_Defaults_ADTc.json", + upickletest.Defaults.ADTc(s = "x"), + """{"s":"x"}""", + """{"s":"x","t":[1,"2"]}""", + "number expected" + ) + } + test("ClassDefs_C1") { + SchemaSnapshotTestUtils.assertSchemaSerializationCase[upickletest.C1]( + "schemas/ClassDefs_C1.json", + upickletest.C1("n", List("a", "b")), + """{"name":"n","types":["a","b"]}""", + """{"name":1,"types":["a","b"]}""", + "string expected" + ) + } + test("ClassDefs_C2") { + SchemaSnapshotTestUtils.assertSchemaSerializationCase[upickletest.C2]( + "schemas/ClassDefs_C2.json", + upickletest.C2(List(upickletest.C1("n", List("a")))), + """{"results":[{"name":"n","types":["a"]}]}""", + """{"results":"oops"}""", + "array expected" + ) + } + test("ClassDefs_Result2") { + SchemaSnapshotTestUtils.assertSchemaSerializationCase[upickletest.Result2]( + "schemas/ClassDefs_Result2.json", + upickletest.Result2("n", "w", List("a")), + """{"name":"n","whatever":"w","types":["a"]}""", + """{"name":"n","whatever":"w","types":"a"}""", + "array expected" + ) + } + test("ClassDefs_GeoCoding2") { + SchemaSnapshotTestUtils.assertSchemaSerializationCase[upickletest.GeoCoding2]( + "schemas/ClassDefs_GeoCoding2.json", + upickletest.GeoCoding2(List(upickletest.Result2("n", "w", List("a"))), "OK"), + """{"results":[{"name":"n","whatever":"w","types":["a"]}],"status":"OK"}""", + """{"results":[{"name":"n","whatever":"w","types":["a"]}],"status":200}""", + "string expected" + ) + } + } +} diff --git a/upickle/jsonschema/test/src/upickle/jsonschema/JsonSchemaTests.scala b/upickle/jsonschema/test/src/upickle/jsonschema/JsonSchemaTests.scala new file mode 100644 index 000000000..fb5e49b81 --- /dev/null +++ b/upickle/jsonschema/test/src/upickle/jsonschema/JsonSchemaTests.scala @@ -0,0 +1,313 @@ +package upickle.jsonschema + +import upickle.default.* +import utest.* + +case class Address(street: String, zip: Int) derives ReadWriter +case class Person(name: String, address: Address) derives ReadWriter + +case class Node(value: Int, next: Option[Node]) derives ReadWriter + +enum LinkedList[+T] derives ReadWriter: + case End + case Cons(value: T, next: LinkedList[T]) + +case class RequiredFields(i: Int, s: String = "x") derives ReadWriter +@upickle.implicits.allowUnknownKeys(false) +case class StrictUnknownKeys(i: Int) derives ReadWriter +case class NumericStringFidelity(bigI: BigInt, bigD: BigDecimal, l: Long) derives ReadWriter +case class IntKeyMap(m: Map[Int, String]) derives ReadWriter +case class FlattenInner(a: String, b: Int) derives ReadWriter +case class FlattenOuter(d: Double, @upickle.implicits.flatten inner: FlattenInner) derives ReadWriter +case class FlattenInnerWithDefault(a: Int, b: String = "x") derives ReadWriter +case class FlattenRequiredOuter(@upickle.implicits.flatten inner: FlattenInnerWithDefault) derives ReadWriter + +@upickle.implicits.key("kind") +sealed trait KeyedTagBase derives ReadWriter +object KeyedTagBase: + case class Foo(i: Int) extends KeyedTagBase + case object Bar extends KeyedTagBase + +sealed trait PlainTagBase derives ReadWriter +object PlainTagBase: + case class Foo(i: Int) extends PlainTagBase + case object Bar extends PlainTagBase + +given JsonSchema[Address] = JsonSchema.derived +given JsonSchema[Person] = JsonSchema.derived +lazy given JsonSchema[Node] = JsonSchema.derived +given JsonSchema[LinkedList[Int]] = JsonSchema.derived +given JsonSchema[KeyedTagBase] = JsonSchema.derived +given JsonSchema[PlainTagBase] = JsonSchema.derived +given JsonSchema[RequiredFields] = JsonSchema.derived +given JsonSchema[StrictUnknownKeys] = JsonSchema.derived +given JsonSchema[NumericStringFidelity] = JsonSchema.derived +given JsonSchema[IntKeyMap] = JsonSchema.derived +given JsonSchema[FlattenInner] = JsonSchema.derived +given JsonSchema[FlattenOuter] = JsonSchema.derived +given JsonSchema[FlattenInnerWithDefault] = JsonSchema.derived +given JsonSchema[FlattenRequiredOuter] = JsonSchema.derived + +object JsonSchemaTests extends TestSuite { + val tests = Tests { + test("nestedDefinitions") { + SchemaSnapshotTestUtils.assertSerializationValidatesSchema[Person]( + Person("Bob", Address("Main", 12345)), + """{"name":"Bob","address":{"street":"Main","zip":12345}}""" + ) + + val rendered = upickle.default.schema[Person].render(indent = 2) + val expected = + """{ + | "$schema": "https://json-schema.org/draft/2020-12/schema", + | "$defs": { + | "upickle.jsonschema.Address": { + | "type": "object", + | "properties": { + | "street": { + | "type": "string" + | }, + | "zip": { + | "type": "integer" + | } + | }, + | "required": [ + | "street", + | "zip" + | ], + | "additionalProperties": true + | }, + | "upickle.jsonschema.Person": { + | "type": "object", + | "properties": { + | "name": { + | "type": "string" + | }, + | "address": { + | "$ref": "#/$defs/upickle.jsonschema.Address" + | } + | }, + | "required": [ + | "name", + | "address" + | ], + | "additionalProperties": true + | } + | }, + | "$ref": "#/$defs/upickle.jsonschema.Person" + |}""".stripMargin + assert(rendered == expected) + } + + test("recursiveCaseClass") { + SchemaSnapshotTestUtils.assertSerializationValidatesSchema[Node]( + Node(1, Some(Node(2, None))), + """{"value":1,"next":{"value":2,"next":null}}""" + ) + + val rendered = upickle.default.schema[Node].render(indent = 2) + val expected = + """{ + | "$schema": "https://json-schema.org/draft/2020-12/schema", + | "$defs": { + | "upickle.jsonschema.Node": { + | "type": "object", + | "properties": { + | "value": { + | "type": "integer" + | }, + | "next": { + | "anyOf": [ + | { + | "$ref": "#/$defs/upickle.jsonschema.Node" + | }, + | { + | "type": "null" + | }, + | { + | "type": "array", + | "minItems": 0, + | "maxItems": 1, + | "items": { + | "$ref": "#/$defs/upickle.jsonschema.Node" + | } + | } + | ] + | } + | }, + | "required": [ + | "value", + | "next" + | ], + | "additionalProperties": true + | } + | }, + | "$ref": "#/$defs/upickle.jsonschema.Node" + |}""".stripMargin + assert(rendered == expected) + } + + test("recursiveEnum") { + SchemaSnapshotTestUtils.assertSerializationValidatesSchema[LinkedList[Int]]( + LinkedList.Cons(1, LinkedList.Cons(2, LinkedList.End)), + """{"$type":"Cons","value":1,"next":{"$type":"Cons","value":2,"next":"End"}}""" + ) + + val rendered = upickle.default.schema[LinkedList[Int]].render(indent = 2) + val expected = + """{ + | "$schema": "https://json-schema.org/draft/2020-12/schema", + | "$defs": { + | "upickle.jsonschema.LinkedList.Cons[scala.Int]": { + | "type": "object", + | "properties": { + | "value": { + | "type": "integer" + | }, + | "next": { + | "$ref": "#/$defs/upickle.jsonschema.LinkedList[scala.Int]" + | } + | }, + | "required": [ + | "value", + | "next" + | ], + | "additionalProperties": true + | }, + | "upickle.jsonschema.LinkedList[scala.Int]": { + | "oneOf": [ + | { + | "const": "End" + | }, + | { + | "allOf": [ + | { + | "$ref": "#/$defs/upickle.jsonschema.LinkedList.Cons[scala.Int]" + | }, + | { + | "type": "object", + | "properties": { + | "$type": { + | "const": "Cons" + | } + | }, + | "required": [ + | "$type" + | ] + | } + | ] + | } + | ] + | } + | }, + | "$ref": "#/$defs/upickle.jsonschema.LinkedList[scala.Int]" + |}""".stripMargin + assert(rendered == expected) + } + + test("tagKeyOverrideAndShortTags") { + SchemaSnapshotTestUtils.assertSerializationValidatesSchema[KeyedTagBase]( + KeyedTagBase.Foo(1), + """{"kind":"Foo","i":1}""" + ) + SchemaSnapshotTestUtils.assertSerializationValidatesSchema[KeyedTagBase]( + KeyedTagBase.Bar, + """"Bar"""" + ) + SchemaSnapshotTestUtils.assertJsonDoesNotValidateSchema[KeyedTagBase]( + """{"$type":"Foo","i":1}""" + ) + SchemaSnapshotTestUtils.assertJsonDoesNotValidateSchema[KeyedTagBase]( + """{"kind":"upickle.jsonschema.KeyedTagBase.Foo","i":1}""" + ) + } + + test("plainShortTagRegression") { + SchemaSnapshotTestUtils.assertSerializationValidatesSchema[PlainTagBase]( + PlainTagBase.Foo(1), + """{"$type":"Foo","i":1}""" + ) + SchemaSnapshotTestUtils.assertSerializationValidatesSchema[PlainTagBase]( + PlainTagBase.Bar, + """"Bar"""" + ) + SchemaSnapshotTestUtils.assertJsonDoesNotValidateSchema[PlainTagBase]( + """{"$type":"upickle.jsonschema.PlainTagBase.Foo","i":1}""" + ) + } + + test("requiredFieldsRespected") { + SchemaSnapshotTestUtils.assertSerializationValidatesSchema[RequiredFields]( + RequiredFields(1), + """{"i":1}""" + ) + SchemaSnapshotTestUtils.assertJsonDoesNotValidateSchemaWithMessage[RequiredFields]( + """{"s":"x"}""", + "required property 'i' not found" + ) + } + + test("unknownKeysRespected") { + SchemaSnapshotTestUtils.assertSerializationValidatesSchema[StrictUnknownKeys]( + StrictUnknownKeys(1), + """{"i":1}""" + ) + SchemaSnapshotTestUtils.assertJsonDoesNotValidateSchemaWithMessage[StrictUnknownKeys]( + """{"i":1,"extra":2}""", + "property 'extra' is not defined" + ) + } + + test("numericStringFidelity") { + SchemaSnapshotTestUtils.assertSerializationValidatesSchema[NumericStringFidelity]( + NumericStringFidelity(BigInt("12345678901234567890"), BigDecimal("123.456"), 9007199254740993L), + """{"bigI":"12345678901234567890","bigD":"123.456","l":"9007199254740993"}""" + ) + SchemaSnapshotTestUtils.assertJsonDoesNotValidateSchemaWithMessage[NumericStringFidelity]( + """{"bigI":123,"bigD":"123.456","l":"9007199254740993"}""", + "string expected" + ) + SchemaSnapshotTestUtils.assertJsonDoesNotValidateSchemaWithMessage[NumericStringFidelity]( + """{"bigI":"123","bigD":"123.456","l":"not-long"}""", + "does not match the regex pattern" + ) + } + + test("mapKeyTyping") { + SchemaSnapshotTestUtils.assertSerializationValidatesSchema[IntKeyMap]( + IntKeyMap(scala.collection.immutable.ListMap(1 -> "one", 2 -> "two")), + """{"m":{"1":"one","2":"two"}}""" + ) + SchemaSnapshotTestUtils.assertJsonDoesNotValidateSchemaWithMessage[IntKeyMap]( + """{"m":{"x":"one"}}""", + "does not match the regex pattern" + ) + } + + test("flattenedCaseClassShape") { + SchemaSnapshotTestUtils.assertSerializationValidatesSchema[FlattenOuter]( + FlattenOuter(1.5, FlattenInner("x", 7)), + """{"d":1.5,"a":"x","b":7}""" + ) + SchemaSnapshotTestUtils.assertJsonDoesNotValidateSchemaWithMessage[FlattenOuter]( + """{"d":1.5,"inner":{"a":"x","b":7}}""", + "required property 'a' not found" + ) + SchemaSnapshotTestUtils.assertJsonDoesNotValidateSchemaWithMessage[FlattenOuter]( + """{"d":1.5,"a":"x"}""", + "required property 'b' not found" + ) + } + + test("flattenedRequiredFromNestedDefaults") { + SchemaSnapshotTestUtils.assertSerializationValidatesSchema[FlattenRequiredOuter]( + FlattenRequiredOuter(FlattenInnerWithDefault(1)), + """{"a":1}""" + ) + SchemaSnapshotTestUtils.assertJsonDoesNotValidateSchemaWithMessage[FlattenRequiredOuter]( + """{}""", + "required property 'a' not found" + ) + } + } +} diff --git a/upickle/jsonschema/test/src/upickle/jsonschema/MacroSchemaCoverageSnapshotTests.scala b/upickle/jsonschema/test/src/upickle/jsonschema/MacroSchemaCoverageSnapshotTests.scala new file mode 100644 index 000000000..e102daaec --- /dev/null +++ b/upickle/jsonschema/test/src/upickle/jsonschema/MacroSchemaCoverageSnapshotTests.scala @@ -0,0 +1,157 @@ +package upickle.jsonschema + +import scala.collection.immutable.ListMap +import utest.* + +object MacroSchemaCoverageSnapshotTests extends TestSuite { + val tests = Tests { + test("Macro_SealedClass") { + SchemaSnapshotTestUtils.assertSchemaSerializationCase[upickletest.SealedClass]( + "schemas/Macro_SealedClass.json", + upickletest.SealedClass(3, "Hello"), + """{"$type":"SealedClass","i":3,"s":"Hello"}""", + """{"$type":"SealedClass","i":"3","s":"Hello"}""", + "integer expected" + ) + } + test("Macro_KeyedPerson") { + SchemaSnapshotTestUtils.assertSchemaSerializationCase[upickletest.KeyedPerson]( + "schemas/Macro_KeyedPerson.json", + upickletest.KeyedPerson("A", "B"), + """{"first_name":"A","last_name":"B"}""", + """{"first_name":1,"last_name":"B"}""", + "string expected" + ) + } + test("Macro_GenericIssue545_Person") { + SchemaSnapshotTestUtils.assertSchemaSerializationCase[upickletest.GenericIssue545.Person]( + "schemas/Macro_GenericIssue545_Person.json", + upickletest.GenericIssue545.Person(1, "bob"), + """{"id":1,"name":"bob"}""", + """{"id":"1","name":"bob"}""", + "integer expected" + ) + } + test("Macro_GenericIssue545_ApiResult_Person") { + SchemaSnapshotTestUtils.assertSchemaSerializationCase[ + upickletest.GenericIssue545.ApiResult[upickletest.GenericIssue545.Person] + ]( + "schemas/Macro_GenericIssue545_ApiResult_Person.json", + upickletest.GenericIssue545.ApiResult(Some(upickletest.GenericIssue545.Person(1, "bob")), 2), + """{"data":{"id":1,"name":"bob"},"total_count":2}""", + """{"data":{"id":"1","name":"bob"},"total_count":2}""", + "integer expected" + ) + } + test("Macro_UnknownKeys_Default") { + SchemaSnapshotTestUtils.assertSchemaSerializationCase[upickletest.UnknownKeys.Default]( + "schemas/Macro_UnknownKeys_Default.json", + upickletest.UnknownKeys.Default(1, "n"), + """{"id":1,"name":"n"}""", + """{"id":"1","name":"n"}""", + "integer expected" + ) + } + test("Macro_UnknownKeys_DisAllow") { + SchemaSnapshotTestUtils.assertSchemaSerializationCase[upickletest.UnknownKeys.DisAllow]( + "schemas/Macro_UnknownKeys_DisAllow.json", + upickletest.UnknownKeys.DisAllow(1, "n"), + """{"id":1,"name":"n"}""", + """{"id":"1","name":"n"}""", + "integer expected" + ) + } + test("Macro_UnknownKeys_Allow") { + SchemaSnapshotTestUtils.assertSchemaSerializationCase[upickletest.UnknownKeys.Allow]( + "schemas/Macro_UnknownKeys_Allow.json", + upickletest.UnknownKeys.Allow(1, "n"), + """{"id":1,"name":"n"}""", + """{"id":"1","name":"n"}""", + "integer expected" + ) + } + test("Macro_Flatten_Nested") { + SchemaSnapshotTestUtils.assertSchemaSerializationCase[upickletest.Flatten.Nested]( + "schemas/Macro_Flatten_Nested.json", + upickletest.Flatten.Nested(3.0, ListMap("one" -> 1, "two" -> 2)), + """{"d":3,"one":1,"two":2}""", + """{"d":"3","one":1,"two":2}""", + "number expected" + ) + } + test("Macro_Flatten_Nested2") { + SchemaSnapshotTestUtils.assertSchemaSerializationCase[upickletest.Flatten.Nested2]( + "schemas/Macro_Flatten_Nested2.json", + upickletest.Flatten.Nested2("hello"), + """{"name":"hello"}""", + """{"name":42}""", + "string expected" + ) + } + test("Macro_Flatten_Outer") { + SchemaSnapshotTestUtils.assertSchemaSerializationCase[upickletest.Flatten.Outer]( + "schemas/Macro_Flatten_Outer.json", + upickletest.Flatten.Outer(1.1, upickletest.Flatten.Inner(upickletest.Flatten.InnerMost("test", 42), true)), + """{"d":1.1,"a":"test","b":42,"c":true}""", + """{"d":"1.1","a":"test","b":42,"c":true}""", + "number expected" + ) + } + test("Macro_Flatten_FlattenWithDefault") { + SchemaSnapshotTestUtils.assertSchemaSerializationCase[upickletest.Flatten.FlattenWithDefault]( + "schemas/Macro_Flatten_FlattenWithDefault.json", + upickletest.Flatten.FlattenWithDefault(10, upickletest.Flatten.NestedWithDefault(l = "default")), + """{"i":10,"l":"default"}""", + """{"i":"10","l":"default"}""", + "integer expected" + ) + } + test("Macro_Flatten_FlattenSeq") { + SchemaSnapshotTestUtils.assertSchemaSerializationCase[upickletest.Flatten.FlattenSeq]( + "schemas/Macro_Flatten_FlattenSeq.json", + upickletest.Flatten.FlattenSeq(Seq("a" -> 1, "b" -> 2)), + """{"a":1,"b":2}""", + """{"n":"oops","a":1,"b":2}""", + "integer expected" + ) + } + test("Macro_Flatten_Collection") { + SchemaSnapshotTestUtils.assertSchemaSerializationCase[upickletest.Flatten.Collection]( + "schemas/Macro_Flatten_Collection.json", + upickletest.Flatten.Collection( + scala.collection.mutable.LinkedHashMap("a" -> upickletest.Flatten.ValueClass(3.0), "b" -> upickletest.Flatten.ValueClass(4.0)) + ), + """{"a":{"value":3},"b":{"value":4}}""", + """{"n":"oops","a":{"value":3}}""", + "object expected" + ) + } + test("Macro_Flatten_FlattenIntKey") { + SchemaSnapshotTestUtils.assertSchemaSerializationCase[upickletest.Flatten.FlattenIntKey]( + "schemas/Macro_Flatten_FlattenIntKey.json", + upickletest.Flatten.FlattenIntKey(10, ListMap(1 -> "one", 2 -> "two")), + """{"i":10,"1":"one","2":"two"}""", + """{"i":"10","1":"one","2":"two"}""", + "integer expected" + ) + } + test("Macro_Flatten_FlattenSeqIntKey") { + SchemaSnapshotTestUtils.assertSchemaSerializationCase[upickletest.Flatten.FlattenSeqIntKey]( + "schemas/Macro_Flatten_FlattenSeqIntKey.json", + upickletest.Flatten.FlattenSeqIntKey(Seq(1 -> "one", 2 -> "two")), + """{"1":"one","2":"two"}""", + """{"n":"oops","1":"one","2":"two"}""", + "does not match the regex pattern" + ) + } + test("Macro_Flatten_FlattenLongKey") { + SchemaSnapshotTestUtils.assertSchemaSerializationCase[upickletest.Flatten.FlattenLongKey]( + "schemas/Macro_Flatten_FlattenLongKey.json", + upickletest.Flatten.FlattenLongKey(ListMap(100L -> 1, 200L -> 2)), + """{"100":1,"200":2}""", + """{"m":"oops","100":1,"200":2}""", + "integer expected" + ) + } + } +} diff --git a/upickle/jsonschema/test/src/upickle/jsonschema/SchemaSnapshotTestUtils.scala b/upickle/jsonschema/test/src/upickle/jsonschema/SchemaSnapshotTestUtils.scala new file mode 100644 index 000000000..7a1552547 --- /dev/null +++ b/upickle/jsonschema/test/src/upickle/jsonschema/SchemaSnapshotTestUtils.scala @@ -0,0 +1,81 @@ +package upickle.jsonschema + +import com.fasterxml.jackson.databind.ObjectMapper +import com.networknt.schema.{JsonSchemaFactory, SpecVersion} +import utest.* +import utest.framework.GoldenFix +import java.nio.file.Path + +object SchemaSnapshotTestUtils { + private val mapper = ObjectMapper() + private val schemaFactory = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V202012) + + private def goldenPath(resourcePath: String): Path = { + val root = sys.env.getOrElse("MILL_TEST_RESOURCE_DIR", { + throw new IllegalArgumentException("MILL_TEST_RESOURCE_DIR is not set") + }) + val path = Path.of(root).resolve(resourcePath) + if (!java.nio.file.Files.exists(path)) { + throw new IllegalArgumentException(s"Missing golden resource: $path") + } + path + } + + def assertSchemaSerializationCase[T]( + resourcePath: String, + value: T, + expectedSerializedJson: String, + invalidJson: String, + expectedInvalidError: String + )(using JsonSchema[T], upickle.default.Writer[T], GoldenFix.Reporter): Unit = { + val renderedSchema = JsonSchema.schemaFor[T](upickle.default).render(indent = 2) + assertGoldenFile(renderedSchema, goldenPath(resourcePath)) + + val serialized = upickle.default.write(value) + assert(serialized == expectedSerializedJson) + + val schema = schemaFactory.getSchema(mapper.readTree(renderedSchema)) + val validErrors = schema.validate(mapper.readTree(serialized)) + assert(validErrors.isEmpty) + val invalidErrors = schema.validate(mapper.readTree(invalidJson)) + assert(!invalidErrors.isEmpty) + val invalidMessage = invalidErrors.toString + assert(invalidMessage.nonEmpty) + assert(invalidMessage.contains(expectedInvalidError)) + } + + def assertSerializationValidatesSchema[T]( + value: T, + expectedSerializedJson: String + )(using JsonSchema[T], upickle.default.Writer[T]): Unit = { + val renderedSchema = JsonSchema.schemaFor[T](upickle.default).render(indent = 2) + val serialized = upickle.default.write(value) + assert(serialized == expectedSerializedJson) + + val schema = schemaFactory.getSchema(mapper.readTree(renderedSchema)) + val validationErrors = schema.validate(mapper.readTree(serialized)) + assert(validationErrors.isEmpty) + } + + def assertJsonDoesNotValidateSchema[T]( + json: String + )(using JsonSchema[T]): Unit = { + val renderedSchema = JsonSchema.schemaFor[T](upickle.default).render(indent = 2) + val schema = schemaFactory.getSchema(mapper.readTree(renderedSchema)) + val validationErrors = schema.validate(mapper.readTree(json)) + assert(!validationErrors.isEmpty) + } + + def assertJsonDoesNotValidateSchemaWithMessage[T]( + json: String, + expectedError: String + )(using JsonSchema[T]): Unit = { + val renderedSchema = JsonSchema.schemaFor[T](upickle.default).render(indent = 2) + val schema = schemaFactory.getSchema(mapper.readTree(renderedSchema)) + val validationErrors = schema.validate(mapper.readTree(json)) + assert(!validationErrors.isEmpty) + val renderedErrors = validationErrors.toString + assert(renderedErrors.nonEmpty) + assert(renderedErrors.contains(expectedError)) + } +} diff --git a/upickleReadme/Readme.scalatex b/upickleReadme/Readme.scalatex index 1494ed740..ba1538aad 100644 --- a/upickleReadme/Readme.scalatex +++ b/upickleReadme/Readme.scalatex @@ -102,6 +102,53 @@ uPickle supports Scala 2.12, 2.13 and 3.1+ + @sect{JSON Schema (Scala 3, EXPERIMENTAL)} + @p + uPickle can derive JSON Schema documents from the same types you serialize + with @hl.scala{Reader}/@hl.scala{Writer}/@hl.scala{ReadWriter}. This lives in + a separate module, currently targeting Scala 3. This API is experimental and + may change between minor releases. + + @hl.scala + "com.lihaoyi" %% "upickle-jsonschema" % "4.4.2" // SBT + ivy"com.lihaoyi::upickle-jsonschema:4.4.2" // Mill + + @p + Define @hl.scala{JsonSchema} instances (usually via @hl.scala{JsonSchema.derived}), + then call @hl.scala{upickle.default.schema[T]}: + + @hl.scala + import upickle.default.* + import upickle.jsonschema.* + + case class Address(street: String, zip: Int) derives ReadWriter + case class Person(name: String, address: Address) derives ReadWriter + + given JsonSchema[Address] = JsonSchema.derived + given JsonSchema[Person] = JsonSchema.derived + + val schemaJson: ujson.Value = upickle.default.schema[Person] + println(schemaJson.render(indent = 2)) + + @p + Recursive and mutually-recursive types are supported via @hl.scala{$ref}/@hl.scala{$defs}: + + @hl.scala + import upickle.default.* + import upickle.jsonschema.* + + case class Node(value: Int, next: Option[Node]) derives ReadWriter + lazy given JsonSchema[Node] = JsonSchema.derived + + val recursiveSchema = upickle.default.schema[Node] + + @p + To inspect just the collected definitions map, use @hl.scala{schemas[T]}: + + @hl.scala + val defs: collection.immutable.ListMap[String, ujson.Value] = + upickle.default.schemas[Person] + @sect{Basics} @sect{Builtins} @p