packages feed

jsonschema 0.2.0.1 → 0.3.0.0

raw patch · 9 files changed

+2323/−1729 lines, 9 filesPVP ok

version bump matches the API change (PVP)

API changes (from Hackage documentation)

+ Data.JSON.JSONSchema: APAllowed :: AdditionalProperties
+ Data.JSON.JSONSchema: APForbidden :: AdditionalProperties
+ Data.JSON.JSONSchema: APSchema :: Schema -> AdditionalProperties
+ Data.JSON.JSONSchema: Field :: Text -> Bool -> Schema -> Field
+ Data.JSON.JSONSchema: SAllOf :: [Schema] -> Schema
+ Data.JSON.JSONSchema: SAny :: Schema
+ Data.JSON.JSONSchema: SAnyOf :: [Schema] -> Schema
+ Data.JSON.JSONSchema: SArray :: Schema -> Schema
+ Data.JSON.JSONSchema: SArrayUnique :: Schema -> Schema
+ Data.JSON.JSONSchema: SBoolean :: Schema
+ Data.JSON.JSONSchema: SConst :: Value -> Schema
+ Data.JSON.JSONSchema: SEnum :: [Value] -> Schema
+ Data.JSON.JSONSchema: SInteger :: Schema
+ Data.JSON.JSONSchema: SNull :: Schema
+ Data.JSON.JSONSchema: SNullable :: Schema -> Schema
+ Data.JSON.JSONSchema: SNumber :: Schema
+ Data.JSON.JSONSchema: SObject :: [Field] -> AdditionalProperties -> Schema
+ Data.JSON.JSONSchema: SRaw :: Value -> Schema
+ Data.JSON.JSONSchema: SRef :: Text -> Schema
+ Data.JSON.JSONSchema: SString :: Maybe Text -> Schema
+ Data.JSON.JSONSchema: STuple :: [Schema] -> Schema
+ Data.JSON.JSONSchema: SchemaDocument :: [(Text, Schema)] -> Schema -> SchemaDocument
+ Data.JSON.JSONSchema: ValidationError :: [Text] -> Text -> ValidationError
+ Data.JSON.JSONSchema: [error_message] :: ValidationError -> Text
+ Data.JSON.JSONSchema: [error_path] :: ValidationError -> [Text]
+ Data.JSON.JSONSchema: [fieldName] :: Field -> Text
+ Data.JSON.JSONSchema: [fieldRequired] :: Field -> Bool
+ Data.JSON.JSONSchema: [fieldSchema] :: Field -> Schema
+ Data.JSON.JSONSchema: [schemaDefs] :: SchemaDocument -> [(Text, Schema)]
+ Data.JSON.JSONSchema: [schemaRoot] :: SchemaDocument -> Schema
+ Data.JSON.JSONSchema: data AdditionalProperties
+ Data.JSON.JSONSchema: data Field
+ Data.JSON.JSONSchema: data Schema
+ Data.JSON.JSONSchema: data SchemaDocument
+ Data.JSON.JSONSchema: data ValidationError
+ Data.JSON.JSONSchema: schemaDocumentToValue :: SchemaDocument -> Value
+ Data.JSON.JSONSchema: schemaToValue :: Schema -> Value
+ Data.JSON.JSONSchema: validate :: Value -> Value -> Either [ValidationError] ()
+ Data.JSON.JSONSchema: validateWithErrors :: Value -> Value -> [ValidationError]

Files

CHANGELOG.md view
@@ -1,5 +1,21 @@ # Changelog +## 0.3.0.0+- Add value-directed schema construction: a structured `Schema` type (with `Field`,+  `AdditionalProperties`, and `SchemaDocument`) plus `schemaToValue` /+  `schemaDocumentToValue` renderers, re-exported from `Data.JSON.JSONSchema`. Build a+  schema from runtime values when your "types" are data rather than Haskell types.+  The `SRaw` constructor is an escape hatch for embedding an arbitrary schema `Value`+  (any keyword outside the structured subset) at any node.+- Expose the detailed-error validation API through `Data.JSON.JSONSchema`:+  `validate`, `validateWithErrors`, and `ValidationError(..)` are now re-exported+  alongside `validateJSONSchema` (fixes the README's broken `JSONSchema.Validation`+  import; resolves #1).+- Fix derived schemas for non-record products (tuples): emit `minItems`/`maxItems`+  to pin the exact array length. Previously only `items: false` was emitted, which+  caps the upper bound but wrongly accepted arrays shorter than the constructor's+  arity. This changes the generated schema for such types (hence the major bump).+ ## 0.2.0.1 - Widen dependency bounds to support GHC 9.12.2. 
README.md view
@@ -1,5 +1,7 @@ # jsonschema +[![Hackage](https://img.shields.io/hackage/v/jsonschema.svg)](https://hackage.haskell.org/package/jsonschema)+ Haskell library for deriving and validating JSON Schema (2020-12).  This library provides:@@ -15,7 +17,7 @@ ## Features  - Derive schemas with the `ToJSONSchema` type class; generic default handles most ADTs.-- Records become JSON objects with named properties, emit `"required"` for every field, and forbid extras via `additionalProperties: false`. Non-record products become arrays with `prefixItems` and `items: false`.+- Records become JSON objects with named properties, emit `"required"` for every field, and forbid extras via `additionalProperties: false`. Non-record products become fixed-length arrays with `prefixItems`, `items: false`, and `minItems`/`maxItems` pinning the exact length. - Sum types are modeled with discriminator tags:   - Record constructors: object with a required `tag` (constructor name) and the record fields.   - Non-record constructors: object `{ tag, contents }`, both required, where `contents` carries the constructor’s payload (array/object).@@ -39,8 +41,7 @@ import Data.Aeson (ToJSON, Value, object, (.=)) import Data.Proxy (Proxy(..)) import Data.Text (Text)-import Data.JSON.JSONSchema      -- ToJSONSchema(..), Proxy(..), validateJSONSchema-import JSONSchema.Validation     -- validate / validateWithErrors (optional)+import Data.JSON.JSONSchema      -- ToJSONSchema(..), Proxy(..), validateJSONSchema, validate, validateWithErrors, ValidationError(..) ```  ### 1) Derive a schema for your type@@ -111,7 +112,7 @@ Or collect all validation errors:  ```haskell-import JSONSchema.Validation (validate, validateWithErrors, ValidationError(..))+import Data.JSON.JSONSchema (validate, validateWithErrors, ValidationError(..))  case validate personSchema (toJSON (Person "Alice" 30)) of   Right ()   -> putStrLn "OK"@@ -130,7 +131,42 @@ ```  -### 3) Custom schemas for special types+### 3) Value-directed construction++When your "types" are runtime *values* rather than Haskell types — e.g. an+interpreter whose type AST is a single datatype — derivation via `ToJSONSchema`+does not apply. Instead, fold your value into a `Schema` and render it; the library+owns the JSON encoding:++```haskell+import Data.JSON.JSONSchema  -- Schema(..), Field(..), AdditionalProperties(..), SchemaDocument(..), schemaToValue, schemaDocumentToValue++userDoc :: SchemaDocument+userDoc = SchemaDocument+  { schemaDefs = [("Code", SString (Just "^[a-z]+$"))]   -- a named $def+  , schemaRoot = SObject+      [ Field "code"  True  (SRef "Code")                -- required, references the $def+      , Field "count" False SInteger                     -- optional+      ]+      APForbidden                                        -- additionalProperties: false+  }++userSchema :: Value+userSchema = schemaDocumentToValue userDoc++ok  = validateJSONSchema userSchema (object ["code" .= ("abc" :: Text), "count" .= (3 :: Int)])  -- True+bad = validateJSONSchema userSchema (object ["code" .= ("ABC" :: Text)])                         -- False (off-pattern)+```++A homogeneous map/dictionary is `SObject [] (APSchema valueSchema)`+(`additionalProperties: <schema>`). The `pattern` field is matched with TDFA+regexes (POSIX ERE), not the full ECMA-262 dialect, so keep patterns simple.++`Schema` covers the data-shape subset; for any keyword outside it (e.g.+`minLength`, numeric bounds, `title`), use the `SRaw` escape hatch to embed an+arbitrary schema `Value` at any node — e.g. `Field "name" True (SRaw (object ["type" .= ("string" :: Text), "minLength" .= (3 :: Int)]))`.++### 4) Custom schemas for special types  Provide an explicit instance when you need a specific schema shape: 
jsonschema.cabal view
@@ -1,12 +1,14 @@-cabal-version:      2.2-name:               jsonschema-version:            0.2.0.1-license:            MPL-2.0-license-file:       LICENSE-author:             DPella AB-maintainer:         matti@dpella.io,-                    lobo@dpella.io-synopsis:           JSON Schema derivation and validation+cabal-version: 2.2+name: jsonschema+version: 0.3.0.0+license: MPL-2.0+license-file: LICENSE+author: DPella AB+maintainer:+  matti@dpella.io,+  lobo@dpella.io++synopsis: JSON Schema derivation and validation description:   Provides the `ToJSONSchema` type class and validation helpers for generating JSON   Schema 2020-12 documents from Haskell types and validating JSON values at runtime.@@ -14,58 +16,73 @@   sum-tag encoding, and refined handling of arrays and enumerations. Validation implements   the core and applicator vocabularies, including `$defs` and local `$ref` resolution,   pragmatic support for `unevaluated*` keywords, and detailed error traces.-extra-doc-files:    CHANGELOG.md, README.md-homepage:          https://github.com/DPella/jsonschema-bug-reports:       https://github.com/DPella/jsonschema/issues-category:          Data, JSON-tested-with:       GHC == 9.6, GHC == 9.12 +extra-doc-files:+  CHANGELOG.md+  README.md++homepage: https://github.com/DPella/jsonschema+bug-reports: https://github.com/DPella/jsonschema/issues+category: Data, JSON+tested-with:+  ghc ==9.6+  ghc ==9.12+ library   default-language:     Haskell2010+   default-extensions:-      DataKinds-    , FlexibleInstances-    , GADTs-    , GeneralizedNewtypeDeriving-    , ImportQualifiedPost-    , LambdaCase-    , OverloadedStrings-    , PolyKinds-    , RankNTypes-    , TypeApplications+    DataKinds+    FlexibleInstances+    GADTs+    GeneralizedNewtypeDeriving+    ImportQualifiedPost+    LambdaCase+    OverloadedStrings+    PolyKinds+    RankNTypes+    TypeApplications+   hs-source-dirs:     src+   exposed-modules:     Data.JSON.JSONSchema+   other-modules:+    Data.JSON.Schema     Data.JSON.ToJSONSchema     JSONSchema.Validation-  build-depends:-      base >=4.18 && <4.22-    , text >= 2.0 && <2.2-    , aeson >= 2.2 && <2.3-    , containers >= 0.6.7 && <0.9-    , vector >=0.13 && < 0.14-    , scientific >= 0.3.8 && <0.4-    , regex-tdfa >= 1.3.2 && <1.4 +  build-depends:+    aeson >=2.2 && <2.3,+    base >=4.18 && <4.22,+    containers >=0.6.7 && <0.9,+    regex-tdfa >=1.3.2 && <1.4,+    scientific >=0.3.8 && <0.4,+    text >=2.0 && <2.2,+    vector >=0.13 && <0.14,  test-suite test-jsonschema   type: exitcode-stdio-1.0   default-language:     Haskell2010+   hs-source-dirs:     test+   main-is:     Main.hs+   other-modules:     JSONSchema.Spec+   build-depends:-      base >=4.18 && <4.22-    , jsonschema-    , aeson-    , text-    , vector-    , tasty-    , tasty-hunit+    aeson,+    base >=4.18 && <4.22,+    jsonschema,+    tasty,+    tasty-hunit,+    text,+    vector,
src/Data/JSON/JSONSchema.hs view
@@ -1,44 +1,58 @@--- |--- Module:      Data.JSON.JSONSchema--- Copyright:   (c) DPella AB 2025--- License:     LicenseRef-AllRightsReserved--- Maintainer:  <matti@dpella.io>, <lobo@dpella.io>------ JSON Schema generation for Haskell types.------ This module provides a type class and generic implementation for--- automatically deriving JSON Schema descriptions from Haskell data types.--- The generated schemas follow the JSON Schema 2020-12 specification.------ = Usage------ Define instances using the default generic implementation:------ @--- data Person = Person---   { name :: Text---   , age :: Int---   } deriving (Generic)------ instance ToJSONSchema Person--- @------ Or provide custom instances for more control:------ @--- instance ToJSONSchema UUID where---   toJSONSchema _ = object---     [ "type" .= ("string" :: Text)---     , "minLength" .= 36---     , "maxLength" .= 36---     ]--- @+{- |+Module:      Data.JSON.JSONSchema+Copyright:   (c) DPella AB 2025+License:     LicenseRef-AllRightsReserved+Maintainer:  <matti@dpella.io>, <lobo@dpella.io>++JSON Schema generation for Haskell types.++This module provides a type class and generic implementation for+automatically deriving JSON Schema descriptions from Haskell data types.+The generated schemas follow the JSON Schema 2020-12 specification.++= Usage++Define instances using the default generic implementation:++@+data Person = Person+  { name :: Text+  , age :: Int+  } deriving (Generic)++instance ToJSONSchema Person+@++Or provide custom instances for more control:++@+instance ToJSONSchema UUID where+  toJSONSchema _ = object+    [ "type" .= ("string" :: Text)+    , "minLength" .= 36+    , "maxLength" .= 36+    ]+@+-} module Data.JSON.JSONSchema (-  ToJSONSchema (..),-  Proxy (..),-  validateJSONSchema,+    ToJSONSchema (..),+    Proxy (..),++    -- * Validation+    validateJSONSchema,+    validate,+    validateWithErrors,+    ValidationError (..),++    -- * Value-directed construction+    Schema (..),+    Field (..),+    AdditionalProperties (..),+    SchemaDocument (..),+    schemaToValue,+    schemaDocumentToValue, ) where -import Data.Proxy+import Data.JSON.Schema import Data.JSON.ToJSONSchema import JSONSchema.Validation
+ src/Data/JSON/Schema.hs view
@@ -0,0 +1,242 @@+{- |+Module:      Data.JSON.Schema+Copyright:   (c) DPella AB 2025+License:     LicenseRef-AllRightsReserved+Maintainer:  <matti@dpella.io>, <lobo@dpella.io>++Value-directed JSON Schema construction.++A structured, value-level description of the data-shape subset of JSON Schema+2020-12, plus pure renderers to an Aeson 'Value'. This is the value-directed+complement to the type-directed 'Data.JSON.ToJSONSchema.ToJSONSchema': rather than+reflecting on a Haskell /type/, you build a 'Schema' from runtime data (e.g. an+interpreter folding over its own type AST) and let this module own the JSON+encoding.++The vocabulary is exactly the data-shape subset the generic derivation emits and+the bundled validator accepts (@properties@/@required@/@additionalProperties@,+@prefixItems@/@items@, @anyOf@/@allOf@, @$ref@/@$defs@, @pattern@, @uniqueItems@).+Validation-only keywords (@if@/@then@/@else@, @dependentSchemas@,+@patternProperties@, @not@, …) are intentionally absent: they are for validating+arbitrary schemas, not for generating a data shape.++This is deliberately narrower than the type-directed path, and that gap is by+design rather than an oversight: 'Schema' is the ergonomic DSL for the common+data-shape case, not a typed mirror of the whole specification. When you need a+keyword outside this subset (e.g. @minLength@, numeric bounds, @title@), embed it+with 'SRaw', which renders an arbitrary schema 'Value' verbatim and composes+anywhere a 'Schema' is expected (an object field, an array element, an @anyOf@+branch). The bundled validator accepts arbitrary schemas, so the embedded 'Value'+validates like any other.++= Usage++@+doc :: t'SchemaDocument'+doc = v'SchemaDocument'+  { 'schemaDefs' = [(\"Code\", 'SString' (Just \"^[a-z]+$\"))]+  , 'schemaRoot' =+      'SObject'+        [ v'Field' \"code\" True ('SRef' \"Code\")+        , v'Field' \"count\" False 'SInteger'+        ]+        'APForbidden'+  }++schema :: Value+schema = 'schemaDocumentToValue' doc+@+-}+module Data.JSON.Schema (+    -- * Schema description+    Schema (..),+    Field (..),+    AdditionalProperties (..),++    -- * Documents+    SchemaDocument (..),++    -- * Rendering+    schemaToValue,+    schemaDocumentToValue,+) where++import Data.Aeson+import Data.Aeson.Key qualified as K+import Data.Aeson.KeyMap qualified as KM+import Data.Text (Text)+import Data.Text qualified as T++{- | A value-level description of the data-shape subset of JSON Schema 2020-12 —+the value-directed complement to the type-directed+'Data.JSON.ToJSONSchema.ToJSONSchema'. Build a 'Schema' from runtime data, then+render it with 'schemaToValue'.+-}+data Schema+    = -- | @{"type":"null"}@+      SNull+    | -- | @{"type":"boolean"}@+      SBoolean+    | -- | @{"type":"integer"}@+      SInteger+    | -- | @{"type":"number"}@+      SNumber+    | -- | @{"type":"string"}@, with a @pattern@ when 'Just'.+      SString (Maybe Text)+    | -- | @{"const": v}@+      SConst Value+    | -- | @{"enum": [..]}@+      SEnum [Value]+    | -- | @{"type":"array","items": s}@+      SArray Schema+    | -- | Array with @uniqueItems: true@ (a set).+      SArrayUnique Schema+    | -- | Fixed-length tuple: @prefixItems@ + @items:false@ + @minItems@/@maxItems@.+      STuple [Schema]+    | {- | Object: each t'Field' carries its own requiredness, plus how additional+      properties are treated.+      -}+      SObject [Field] AdditionalProperties+    | -- | @{"anyOf": [s, {"type":"null"}]}@+      SNullable Schema+    | -- | @{"anyOf": [..]}@+      SAnyOf [Schema]+    | -- | @{"allOf": [..]}@+      SAllOf [Schema]+    | -- | @{"$ref": "#/$defs/<name>"}@ (the name is JSON-Pointer-escaped).+      SRef Text+    | -- | @{}@ (matches anything).+      SAny+    | {- | Escape hatch: render an arbitrary schema 'Value' verbatim, so a node+      outside this subset (e.g. a string with @minLength@, a number with+      @multipleOf@) can still be embedded inside an otherwise structured+      'Schema'. Unchecked — the 'Value' is emitted as-is.+      -}+      SRaw Value+    deriving (Eq, Show)++{- | A single property of an 'SObject'.++Pairing requiredness with the property's schema makes a "required but undeclared"+property unrepresentable — a contradiction (@required@ together with+@additionalProperties:false@) the validator would otherwise reject.++Duplicate 'fieldName's within one 'SObject' are caller error: they collapse to a+single JSON property (last wins).+-}+data Field = Field+    { fieldName :: Text+    -- ^ The property key.+    , fieldRequired :: Bool+    -- ^ Whether the property must be present.+    , fieldSchema :: Schema+    -- ^ The schema the property's value must satisfy.+    }+    deriving (Eq, Show)++-- | How an 'SObject' treats properties not declared in its t'Field's.+data AdditionalProperties+    = -- | @{"additionalProperties": false}@+      APForbidden+    | -- | @{"additionalProperties": true}@+      APAllowed+    | -- | @{"additionalProperties": s}@ — homogeneous maps/dictionaries.+      APSchema Schema+    deriving (Eq, Show)++{- | A self-contained schema document: a root schema plus local @$defs@ for named+subschemas (so every 'SRef' resolves within the document), tagged with the 2020-12+dialect.+-}+data SchemaDocument = SchemaDocument+    { schemaDefs :: [(Text, Schema)]+    -- ^ Named subschemas, exposed under @$defs@ for 'SRef' to resolve against.+    , schemaRoot :: Schema+    -- ^ The document's root schema.+    }+    deriving (Eq, Show)++-- | Render a 'Schema' to its Aeson 'Value'. A total, pure fold.+schemaToValue :: Schema -> Value+schemaToValue = \case+    SNull -> typed "null"+    SBoolean -> typed "boolean"+    SInteger -> typed "integer"+    SNumber -> typed "number"+    SString Nothing -> typed "string"+    SString (Just p) -> object ["type" .= ("string" :: Text), "pattern" .= p]+    SConst v -> object ["const" .= v]+    SEnum vs -> object ["enum" .= vs]+    SArray s -> object ["type" .= ("array" :: Text), "items" .= schemaToValue s]+    SArrayUnique s ->+        object+            [ "type" .= ("array" :: Text)+            , "items" .= schemaToValue s+            , "uniqueItems" .= True+            ]+    STuple ss ->+        object+            [ "type" .= ("array" :: Text)+            , "prefixItems" .= map schemaToValue ss+            , "items" .= False+            , "minItems" .= length ss+            , "maxItems" .= length ss+            ]+    SObject fields ap -> objectToValue fields ap+    SNullable s -> object ["anyOf" .= [schemaToValue s, typed "null"]]+    SAnyOf ss -> object ["anyOf" .= map schemaToValue ss]+    SAllOf ss -> object ["allOf" .= map schemaToValue ss]+    SRef name -> object ["$ref" .= ("#/$defs/" <> escapePointer name)]+    SAny -> object []+    SRaw v -> v+  where+    typed t = object ["type" .= (t :: Text)]++-- | Render an 'SObject', omitting @required@ when no field is required.+objectToValue :: [Field] -> AdditionalProperties -> Value+objectToValue fields ap =+    object $+        [ "type" .= ("object" :: Text)+        , "properties" .= object [K.fromText (fieldName f) .= schemaToValue (fieldSchema f) | f <- fields]+        , "additionalProperties" .= additionalPropertiesToValue ap+        ]+            ++ requiredPairs+  where+    requiredNames = [fieldName f | f <- fields, fieldRequired f]+    requiredPairs+        | null requiredNames = []+        | otherwise = ["required" .= requiredNames]++-- | Render the @additionalProperties@ value.+additionalPropertiesToValue :: AdditionalProperties -> Value+additionalPropertiesToValue = \case+    APForbidden -> Bool False+    APAllowed -> Bool True+    APSchema s -> schemaToValue s++{- | Escape a name for use as a JSON Pointer token (RFC 6901): @~@ becomes @~0@,+then @/@ becomes @~1@. Order matters — escaping @~@ first keeps a literal @/@ from+producing a spurious @~01@.+-}+escapePointer :: Text -> Text+escapePointer = T.replace "/" "~1" . T.replace "~" "~0"++{- | Render a t'SchemaDocument' to its Aeson 'Value': the root schema inline, with+a @$schema@ dialect header and (when non-empty) a sibling @$defs@ block.++The document owns @$schema@/@$defs@: should the root be an 'SRaw' object that also+carries those keys, the document's win. A non-object root — only reachable via+'SRaw' (e.g. a boolean schema) — is wrapped under @allOf@ so the header is still+emitted rather than silently dropped.+-}+schemaDocumentToValue :: SchemaDocument -> Value+schemaDocumentToValue (SchemaDocument defs root) =+    case schemaToValue root of+        Object rootObj -> Object (header `KM.union` rootObj)+        other -> Object (KM.insert "allOf" (toJSON [other]) header)+  where+    header = KM.fromList (("$schema", String dialect) : defsPair)+    dialect = "https://json-schema.org/draft/2020-12/schema"+    defsPair+        | null defs = []+        | otherwise = [("$defs", object [K.fromText n .= schemaToValue s | (n, s) <- defs])]
src/Data/JSON/ToJSONSchema.hs view
@@ -4,44 +4,45 @@ {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE TypeOperators #-} --- |--- Module:      Data.JSON.ToJSONSchema--- Copyright:   (c) DPella AB 2025--- License:     LicenseRef-AllRightsReserved--- Maintainer:  <matti@dpella.io>, <lobo@dpella.io>------ Core machinery for deriving JSON Schema definitions from Haskell types.------ This module provides a type class and generic implementation for--- automatically deriving JSON Schema descriptions from Haskell data types.--- The generated schemas follow the JSON Schema 2020-12 specification.------ = Usage------ Define instances using the default generic implementation:------ @--- data Person = Person---   { name :: Text---   , age :: Int---   } deriving (Generic)------ instance ToJSONSchema Person--- @------ Or provide custom instances for more control:------ @--- instance ToJSONSchema UUID where---   toJSONSchema _ = object---     [ "type" .= ("string" :: Text)---     , "minLength" .= 36---     , "maxLength" .= 36---     ]--- @+{- |+Module:      Data.JSON.ToJSONSchema+Copyright:   (c) DPella AB 2025+License:     LicenseRef-AllRightsReserved+Maintainer:  <matti@dpella.io>, <lobo@dpella.io>++Core machinery for deriving JSON Schema definitions from Haskell types.++This module provides a type class and generic implementation for+automatically deriving JSON Schema descriptions from Haskell data types.+The generated schemas follow the JSON Schema 2020-12 specification.++= Usage++Define instances using the default generic implementation:++@+data Person = Person+  { name :: Text+  , age :: Int+  } deriving (Generic)++instance ToJSONSchema Person+@++Or provide custom instances for more control:++@+instance ToJSONSchema UUID where+  toJSONSchema _ = object+    [ "type" .= ("string" :: Text)+    , "minLength" .= 36+    , "maxLength" .= 36+    ]+@+-} module Data.JSON.ToJSONSchema (-  ToJSONSchema (..),-  Proxy (..),+    ToJSONSchema (..),+    Proxy (..), ) where  import Data.Aeson@@ -56,397 +57,425 @@ import GHC.Generics import GHC.TypeLits --- | Type class for converting Haskell types to JSON Schema.------ The class provides a default implementation using GHC generics,--- which works for most algebraic data types. Custom instances can--- be defined for types requiring special schema representations.+{- | Type class for converting Haskell types to JSON Schema.++The class provides a default implementation using GHC generics,+which works for most algebraic data types. Custom instances can+be defined for types requiring special schema representations.+-} class ToJSONSchema a where-  -- | Generate a JSON Schema for the given type.-  ---  -- The Proxy argument carries the type information without-  -- requiring an actual value of that type.-  ---  -- >>> toJSONSchema (Proxy :: Proxy Bool)-  -- {"type": "boolean"}-  toJSONSchema :: Proxy a -> Value-  default toJSONSchema-    :: ( Generic a-       , GToJSONSchema (Rep a)-       , Typeable a-       )-    => Proxy a-    -> Value-  -- We start with no root name; the D1 instance will set the root name-  -- and wrap the result with $defs and a top-level $ref. This allows-  -- recursive types to refer to themselves using $ref without infinite recursion.-  toJSONSchema _ = gToJSONSchema @(Rep a) False Nothing (Proxy :: Proxy (Rep a a))+    {- | Generate a JSON Schema for the given type. +    The Proxy argument carries the type information without+    requiring an actual value of that type.++    >>> toJSONSchema (Proxy :: Proxy Bool)+    {"type": "boolean"}+    -}+    toJSONSchema :: Proxy a -> Value+    default toJSONSchema ::+        ( Generic a+        , GToJSONSchema (Rep a)+        , Typeable a+        ) =>+        Proxy a ->+        Value+    -- We start with no root name; the D1 instance will set the root name+    -- and wrap the result with $defs and a top-level $ref. This allows+    -- recursive types to refer to themselves using $ref without infinite recursion.+    toJSONSchema _ = gToJSONSchema @(Rep a) False Nothing (Proxy :: Proxy (Rep a a))+ -- | String instance with overlapping to handle String as a special case, and not as [Char] instance {-# OVERLAPPING #-} ToJSONSchema String where-  toJSONSchema _ = object ["type" .= ("string" :: Text)]+    toJSONSchema _ = object ["type" .= ("string" :: Text)]  -- | Text instance. instance ToJSONSchema Text where-  toJSONSchema _ = object ["type" .= ("string" :: Text)]+    toJSONSchema _ = object ["type" .= ("string" :: Text)]  -- | Boolean schema instance. instance ToJSONSchema Bool where-  toJSONSchema _ = object ["type" .= ("boolean" :: Text)]+    toJSONSchema _ = object ["type" .= ("boolean" :: Text)]  -- | Machine integer schema instance. instance ToJSONSchema Int where-  toJSONSchema _ = object ["type" .= ("integer" :: Text)]+    toJSONSchema _ = object ["type" .= ("integer" :: Text)]  -- | Arbitrary precision integer schema instance. instance ToJSONSchema Integer where-  toJSONSchema _ = object ["type" .= ("integer" :: Text)]+    toJSONSchema _ = object ["type" .= ("integer" :: Text)]  -- | Single precision floating point schema instance. instance ToJSONSchema Float where-  toJSONSchema _ = object ["type" .= ("number" :: Text)]+    toJSONSchema _ = object ["type" .= ("number" :: Text)]  -- | Double precision floating point schema instance. instance ToJSONSchema Double where-  toJSONSchema _ = object ["type" .= ("number" :: Text)]+    toJSONSchema _ = object ["type" .= ("number" :: Text)]  -- | List schema instance for homogeneous arrays. instance (ToJSONSchema a) => ToJSONSchema [a] where-  toJSONSchema _ =-    object-      [ "type" .= ("array" :: Text)-      , "items" .= toJSONSchema (Proxy :: Proxy a)-      ]+    toJSONSchema _ =+        object+            [ "type" .= ("array" :: Text)+            , "items" .= toJSONSchema (Proxy :: Proxy a)+            ] --- | Either schema instance for tagged unions.------ Encodes as Aeson's default representation with Left/Right tags:--- @--- Left x  -> {\"Left\": x}--- Right y -> {\"Right\": y}--- @+{- | Either schema instance for tagged unions.++Encodes as Aeson's default representation with Left/Right tags:+@+Left x  -> {\"Left\": x}+Right y -> {\"Right\": y}+@+-} instance (ToJSONSchema a, ToJSONSchema b) => ToJSONSchema (Either a b) where-  toJSONSchema _ =-    object-      [ "anyOf"-          .= [ object-                [ "type" .= ("object" :: Text)-                , "properties"-                    .= object-                      [ "Left" .= toJSONSchema (Proxy :: Proxy a)-                      ]-                , "required" .= ([ "Left" ] :: [Text])-                , "additionalProperties" .= False-                ]-             , object-                [ "type" .= ("object" :: Text)-                , "properties"-                    .= object-                      [ "Right" .= toJSONSchema (Proxy :: Proxy b)-                      ]-                , "required" .= ([ "Right" ] :: [Text])-                , "additionalProperties" .= False-                ]-             ]-      ]+    toJSONSchema _ =+        object+            [ "anyOf"+                .= [ object+                        [ "type" .= ("object" :: Text)+                        , "properties"+                            .= object+                                [ "Left" .= toJSONSchema (Proxy :: Proxy a)+                                ]+                        , "required" .= (["Left"] :: [Text])+                        , "additionalProperties" .= False+                        ]+                   , object+                        [ "type" .= ("object" :: Text)+                        , "properties"+                            .= object+                                [ "Right" .= toJSONSchema (Proxy :: Proxy b)+                                ]+                        , "required" .= (["Right"] :: [Text])+                        , "additionalProperties" .= False+                        ]+                   ]+            ] --- | Maybe schema instance allowing null values.------ A Maybe value can be either the wrapped type or null:--- @--- Just x  -> x--- Nothing -> null--- @+{- | Maybe schema instance allowing null values.++A Maybe value can be either the wrapped type or null:+@+Just x  -> x+Nothing -> null+@+-} instance (ToJSONSchema a) => ToJSONSchema (Maybe a) where-  toJSONSchema _ =-    object-      [ "anyOf"-          .= [ toJSONSchema (Proxy :: Proxy a)-             , object ["type" .= ("null" :: Text)]-             ]-      ]+    toJSONSchema _ =+        object+            [ "anyOf"+                .= [ toJSONSchema (Proxy :: Proxy a)+                   , object ["type" .= ("null" :: Text)]+                   ]+            ] --- | Generic type class for deriving JSON schemas.------ This class handles the generic representation of data types--- and converts them to appropriate JSON Schema structures.------ The Bool parameter indicates whether we're inside a sum type--- that needs tagging for proper deserialization.+{- | Generic type class for deriving JSON schemas.++This class handles the generic representation of data types+and converts them to appropriate JSON Schema structures.++The Bool parameter indicates whether we're inside a sum type+that needs tagging for proper deserialization.+-} class GToJSONSchema f where-  -- | Generate schema from generic representation.-  ---  -- The Bool parameter controls tagged union representation:-  -- * True: Add "tag" field for sum type constructors-  -- * False: No tagging needed-  -- The Maybe Text carries the root datatype name, if any. When present,-  -- occurrences of the same datatype in fields will be rendered as-  -- {"$ref": "#/$defs/<root>"} to avoid infinite recursion.-  gToJSONSchema :: (ToJSONSchema a, Typeable a) => Bool -> Maybe Text -> Proxy (f a) -> Value+    {- | Generate schema from generic representation. --- | Instance for empty types (no constructors).------ Empty types are represented as JSON null since they--- can never have a value.+    The Bool parameter controls tagged union representation:+    * True: Add "tag" field for sum type constructors+    * False: No tagging needed+    The Maybe Text carries the root datatype name, if any. When present,+    occurrences of the same datatype in fields will be rendered as+    {"$ref": "#/$defs/<root>"} to avoid infinite recursion.+    -}+    gToJSONSchema :: (ToJSONSchema a, Typeable a) => Bool -> Maybe Text -> Proxy (f a) -> Value++{- | Instance for empty types (no constructors).++Empty types are represented as JSON null since they+can never have a value.+-} instance GToJSONSchema V1 where-  gToJSONSchema _ _ _ = Null+    gToJSONSchema _ _ _ = Null --- | Instance for unit types (constructors with no fields).------ Unit constructors are represented as null when untagged,--- or as objects with just a tag field when tagged.+{- | Instance for unit types (constructors with no fields).++Unit constructors are represented as null when untagged,+or as objects with just a tag field when tagged.+-} instance GToJSONSchema U1 where-  gToJSONSchema :: forall a. (ToJSONSchema a, Typeable a) => Bool -> Maybe Text -> Proxy (U1 a) -> Value-  gToJSONSchema _ _ _ = Null+    gToJSONSchema :: forall a. (ToJSONSchema a, Typeable a) => Bool -> Maybe Text -> Proxy (U1 a) -> Value+    gToJSONSchema _ _ _ = Null --- | Instance for sum types (multiple constructors).------ Sum types are encoded using JSON Schema's "anyOf" keyword,--- allowing the value to match any of the constructor schemas.------ Example:--- @--- data Color = Red | Green | Blue--- -- Generates: {"anyOf": [{...Red schema}, {...Green schema}, {...Blue schema}]}--- @+{- | Instance for sum types (multiple constructors).++Sum types are encoded using JSON Schema's "anyOf" keyword,+allowing the value to match any of the constructor schemas.++Example:+@+data Color = Red | Green | Blue+-- Generates: {"anyOf": [{...Red schema}, {...Green schema}, {...Blue schema}]}+@+-} instance (GToJSONSchema f1, GToJSONSchema f2) => GToJSONSchema (f1 :+: f2) where-  gToJSONSchema :: forall a. (ToJSONSchema a, Typeable a) => Bool -> Maybe Text -> Proxy ((:+:) f1 f2 a) -> Value-  gToJSONSchema _ root_name _ =-    let v1 = flattenKeys "anyOf" $ gToJSONSchema True root_name (Proxy :: Proxy (f1 a))-        v2 = flattenKeys "anyOf" $ gToJSONSchema True root_name (Proxy :: Proxy (f2 a))-    in  case (v1, v2) of-          (Object km1, Object km2)-            | Just (Array vec1) <- km1 KM.!? "anyOf"-            , Just (Array vec2) <- km2 KM.!? "anyOf" ->-                object ["anyOf" .= Array (vec1 <> vec2)]-          (Object km1, Object km2)-            | Just (Array vec) <- km1 KM.!? "anyOf"-            , Nothing <- km2 KM.!? "anyOf" ->-                object ["anyOf" .= Array (vec `V.snoc` v2)]-          (Object _, Object km2)-            | Just (Array vec) <- km2 KM.!? "anyOf" ->-                object ["anyOf" .= Array (v1 `V.cons` vec)]-          (_, _) -> object ["anyOf" .= [v1, v2]]+    gToJSONSchema :: forall a. (ToJSONSchema a, Typeable a) => Bool -> Maybe Text -> Proxy ((:+:) f1 f2 a) -> Value+    gToJSONSchema _ root_name _ =+        let v1 = flattenKeys "anyOf" $ gToJSONSchema True root_name (Proxy :: Proxy (f1 a))+            v2 = flattenKeys "anyOf" $ gToJSONSchema True root_name (Proxy :: Proxy (f2 a))+         in case (v1, v2) of+                (Object km1, Object km2)+                    | Just (Array vec1) <- km1 KM.!? "anyOf"+                    , Just (Array vec2) <- km2 KM.!? "anyOf" ->+                        object ["anyOf" .= Array (vec1 <> vec2)]+                (Object km1, Object km2)+                    | Just (Array vec) <- km1 KM.!? "anyOf"+                    , Nothing <- km2 KM.!? "anyOf" ->+                        object ["anyOf" .= Array (vec `V.snoc` v2)]+                (Object _, Object km2)+                    | Just (Array vec) <- km2 KM.!? "anyOf" ->+                        object ["anyOf" .= Array (v1 `V.cons` vec)]+                (_, _) -> object ["anyOf" .= [v1, v2]] --- | Instance for product types (multiple fields in a constructor).------ Non-record products are encoded as fixed-length arrays where--- each position has a specific type. The "items": false ensures--- no additional array elements are allowed.------ Example:--- @--- data Point = Point Double Double--- -- Generates: {"type": "array", "prefixItems": [{"type": "number"}, {"type": "number"}], "items": false}--- @+{- | Instance for product types (multiple fields in a constructor).++Non-record products are encoded as fixed-length arrays where each position has a+specific type, via "prefixItems" + "items": false. (The enclosing non-record+/constructor/ instance additionally pins the exact length with+"minItems"/"maxItems".)++Example:+@+data Point = Point Double Double+-- This instance emits: {"type": "array", "prefixItems": [{"type": "number"}, {"type": "number"}], "items": false}+@+-} instance (GToJSONSchema f1, GToJSONSchema f2) => GToJSONSchema (f1 :*: f2) where-  gToJSONSchema :: forall a. (ToJSONSchema a, Typeable a) => Bool -> Maybe Text -> Proxy ((:*:) f1 f2 a) -> Value-  gToJSONSchema _ root_name _ =-    object-      [ "type" .= ("array" :: Text)-      , "prefixItems"-          .= [ gToJSONSchema False root_name (Proxy :: Proxy (f1 a))-             , gToJSONSchema False root_name (Proxy :: Proxy (f2 a))-             ]-      , "items" .= False-      ]+    gToJSONSchema :: forall a. (ToJSONSchema a, Typeable a) => Bool -> Maybe Text -> Proxy ((:*:) f1 f2 a) -> Value+    gToJSONSchema _ root_name _ =+        object+            [ "type" .= ("array" :: Text)+            , "prefixItems"+                .= [ gToJSONSchema False root_name (Proxy :: Proxy (f1 a))+                   , gToJSONSchema False root_name (Proxy :: Proxy (f2 a))+                   ]+            , "items" .= False+            ] --- | Helper to flatten nested array structures in schemas.------ When building schemas for nested product types, we may get--- structures like prefixItems: [a, {prefixItems: [b, c]}].--- This function flattens them to prefixItems: [a, b, c] for--- consistency with how Aeson represents such types.+{- | Helper to flatten nested array structures in schemas.++When building schemas for nested product types, we may get+structures like prefixItems: [a, {prefixItems: [b, c]}].+This function flattens them to prefixItems: [a, b, c] for+consistency with how Aeson represents such types.+-} flattenKeys :: Key -> Value -> Value flattenKeys key (Object km)-  | Just (Array vec) <- km KM.!? key-  , length vec == 2-  , vf <- V.head vec-  , Object vlkm <- flattenKeys key (V.last vec)-  , Just (Array vec') <- vlkm KM.!? key =-      Object-        ( KM.singleton-            key-            (Array (V.cons vf vec'))-            `KM.union` km-        )+    | Just (Array vec) <- km KM.!? key+    , length vec == 2+    , vf <- V.head vec+    , Object vlkm <- flattenKeys key (V.last vec)+    , Just (Array vec') <- vlkm KM.!? key =+        Object+            ( KM.singleton+                key+                (Array (V.cons vf vec'))+                `KM.union` km+            ) flattenKeys _ o = o --- | Instance for datatype metadata.--- If this is the root, we output a $defs section with the type name and schema,--- and refer to that in $ref.+{- | Instance for datatype metadata.+If this is the root, we output a $defs section with the type name and schema,+and refer to that in $ref.+-} instance (KnownSymbol dtn, GToJSONSchema f) => GToJSONSchema (D1 (MetaData dtn m p nt) f) where-  gToJSONSchema-    :: forall a. (ToJSONSchema a, Typeable a) => Bool -> Maybe Text -> Proxy (D1 (MetaData dtn m p nt) f a) -> Value-  gToJSONSchema _ root_name _ =-    let dt_name = symbolVal (Proxy :: Proxy dtn)-        this_name = pack dt_name-        body = gToJSONSchema False (Just this_name) (Proxy :: Proxy (f a))-    in  case root_name of-          -- Top-level: wrap with $defs and $ref to support recursion-          Nothing ->-            object-              [ "$defs" .= object [fromString dt_name .= body]-              , "$ref" .= ("#/$defs/" <> this_name)-              ]-          -- Nested: just return the documented body-          Just _ -> body+    gToJSONSchema ::+        forall a. (ToJSONSchema a, Typeable a) => Bool -> Maybe Text -> Proxy (D1 (MetaData dtn m p nt) f a) -> Value+    gToJSONSchema _ root_name _ =+        let dt_name = symbolVal (Proxy :: Proxy dtn)+            this_name = pack dt_name+            body = gToJSONSchema False (Just this_name) (Proxy :: Proxy (f a))+         in case root_name of+                -- Top-level: wrap with $defs and $ref to support recursion+                Nothing ->+                    object+                        [ "$defs" .= object [fromString dt_name .= body]+                        , "$ref" .= ("#/$defs/" <> this_name)+                        ]+                -- Nested: just return the documented body+                Just _ -> body --- | Instance for type constants (actual field types).------ This delegates to the ToJSONSchema instance of the field type,--- allowing custom schemas for specific types.+{- | Instance for type constants (actual field types).++This delegates to the ToJSONSchema instance of the field type,+allowing custom schemas for specific types.+-} instance (ToJSONSchema c, Typeable c) => GToJSONSchema (K1 i c) where-  gToJSONSchema :: forall a. (ToJSONSchema a, Typeable a) => Bool -> Maybe Text -> Proxy (K1 i c a) -> Value-  gToJSONSchema _ root_name _ =-    case root_name of-      -- If we know the root type name and the field type equals the root-      -- type, emit a $ref to the root definition to avoid infinite recursion.-      Just nm ->-        if typeRep (Proxy :: Proxy a) == typeRep (Proxy :: Proxy c)-          then object ["$ref" .= ("#/$defs/" <> nm)]-          else toJSONSchema (Proxy :: Proxy c)-      Nothing -> toJSONSchema (Proxy :: Proxy c)+    gToJSONSchema :: forall a. (ToJSONSchema a, Typeable a) => Bool -> Maybe Text -> Proxy (K1 i c a) -> Value+    gToJSONSchema _ root_name _ =+        case root_name of+            -- If we know the root type name and the field type equals the root+            -- type, emit a $ref to the root definition to avoid infinite recursion.+            Just nm ->+                if typeRep (Proxy :: Proxy a) == typeRep (Proxy :: Proxy c)+                    then object ["$ref" .= ("#/$defs/" <> nm)]+                    else toJSONSchema (Proxy :: Proxy c)+            Nothing -> toJSONSchema (Proxy :: Proxy c) --- | Instance for record constructors.------ Record types are encoded as objects with named properties.--- When in a tagged sum type, adds a "tag" field with the--- constructor name for discrimination.------ Example:--- @--- data Person = Person {name :: Text, age :: Int}--- -- Generates: {--- --   "type": "object",--- --   "properties": {--- --     "name": {"type": "string"},--- --     "age": {"type": "integer"}--- --   },--- --   "additionalProperties": false--- -- }--- @-instance (KnownSymbol name, GToJSONSchema f) => GToJSONSchema (C1 (MetaCons name fixity True) f) where-  gToJSONSchema-    :: forall a. (ToJSONSchema a, Typeable a) => Bool -> Maybe Text -> Proxy (C1 (MetaCons name fixity True) f a) -> Value-  gToJSONSchema tagged root_name _ =-    let props_val = extractProperties $ gToJSONSchema tagged root_name (Proxy :: Proxy (f a))-        props_keys =-          case props_val of-            Object km -> fmap K.toText (KM.keys km)-            _ -> []-        requiredFields =-          (if tagged then ("tag" :) else id) props_keys-        requiredPairs =-          if null requiredFields-            then []-            else ["required" .= requiredFields]-    in  object-          ( [ "type" .= ("object" :: Text)-            , "properties" .= if tagged then addTag props_val else props_val-            , "additionalProperties" .= False-            ]-              ++ requiredPairs-          )-    where-      tag = object ["const" .= cn]-      addTag (Object km) = Object $ KM.singleton (fromString "tag") tag `KM.union` km-      addTag o = o-      xP (Object p) | Just (Object r) <- p KM.!? "properties" = Just r-      xP _ = Nothing-      cn = symbolVal (Proxy :: Proxy name)-      extractProperties :: Value -> Value-      extractProperties o@(Object _)-        | Object km <- flattenKeys "prefixItems" o-        , Just (Array vec) <- km KM.!? "prefixItems"-        , V.all (isJust . xP) vec =-            Object $ V.foldl KM.union KM.empty $ V.mapMaybe xP vec-      extractProperties (Object km) | Just p <- km KM.!? "properties" = p-      extractProperties o = o+{- | Instance for record constructors. --- | Instance for non-record constructors.------ Non-record constructors with multiple fields are encoded as arrays.--- When in a tagged sum type, wraps in an object with "tag" and "contents".------ Examples:--- @--- data Point = Point Double Double--- -- Untagged: {"type": "array", "prefixItems": [...], "items": false}------ data Shape = Circle Double | Rectangle Double Double--- -- Tagged: {--- --   "type": "object",--- --   "properties": {--- --     "tag": {"const": "Circle"},--- --     "contents": {"type": "number"}--- --   }--- -- }--- @-instance (KnownSymbol name, GToJSONSchema f) => GToJSONSchema (C1 (MetaCons name fixity False) f) where-  gToJSONSchema-    :: forall a. (ToJSONSchema a, Typeable a) => Bool -> Maybe Text -> Proxy (C1 (MetaCons name fixity False) f a) -> Value-  gToJSONSchema tagged root_name _ =-    let c_val = flattenKeys "prefixItems" $ gToJSONSchema False root_name (Proxy :: Proxy (f a))-        c_name = symbolVal (Proxy :: Proxy name)-        tag = object ["const" .= c_name]-    in  case c_val of-          o@(Object km) ->-            let obj = case km KM.!? "prefixItems" of-                  Just pfi@(Array _) -> object ["type" .= ("array" :: Text), "prefixItems" .= pfi, "items" .= False]-                  _ -> o-                basePairs =-                  [ "type" .= ("object" :: Text)-                  , "properties" .= object ["tag" .= tag, "contents" .= obj]+Record types are encoded as objects with named properties.+When in a tagged sum type, adds a "tag" field with the+constructor name for discrimination.++Example:+@+data Person = Person {name :: Text, age :: Int}+-- Generates: {+--   "type": "object",+--   "properties": {+--     "name": {"type": "string"},+--     "age": {"type": "integer"}+--   },+--   "additionalProperties": false+-- }+@+-}+instance (KnownSymbol name, GToJSONSchema f) => GToJSONSchema (C1 (MetaCons name fixity True) f) where+    gToJSONSchema ::+        forall a. (ToJSONSchema a, Typeable a) => Bool -> Maybe Text -> Proxy (C1 (MetaCons name fixity True) f a) -> Value+    gToJSONSchema tagged root_name _ =+        let props_val = extractProperties $ gToJSONSchema tagged root_name (Proxy :: Proxy (f a))+            props_keys =+                case props_val of+                    Object km -> fmap K.toText (KM.keys km)+                    _ -> []+            requiredFields =+                (if tagged then ("tag" :) else id) props_keys+            requiredPairs =+                if null requiredFields+                    then []+                    else ["required" .= requiredFields]+         in object+                ( [ "type" .= ("object" :: Text)+                  , "properties" .= if tagged then addTag props_val else props_val                   , "additionalProperties" .= False                   ]-                requiredPairs =-                  ["required" .= (["tag", "contents"] :: [Text])]-            in  if tagged-                  then-                    object (basePairs ++ requiredPairs)-                  else obj-          Null ->-            object-              [ "type" .= ("object" :: Text)-              , "properties" .= object ["tag" .= tag]-              , "additionalProperties" .= False-              , "required" .= ([ "tag" ] :: [Text])-              ]-          x -> x+                    ++ requiredPairs+                )+      where+        tag = object ["const" .= cn]+        addTag (Object km) = Object $ KM.singleton (fromString "tag") tag `KM.union` km+        addTag o = o+        xP (Object p) | Just (Object r) <- p KM.!? "properties" = Just r+        xP _ = Nothing+        cn = symbolVal (Proxy :: Proxy name)+        extractProperties :: Value -> Value+        extractProperties o@(Object _)+            | Object km <- flattenKeys "prefixItems" o+            , Just (Array vec) <- km KM.!? "prefixItems"+            , V.all (isJust . xP) vec =+                Object $ V.foldl KM.union KM.empty $ V.mapMaybe xP vec+        extractProperties (Object km) | Just p <- km KM.!? "properties" = p+        extractProperties o = o --- | Instance for unnamed fields (positional constructor arguments).------ Simply delegates to the field type's schema without wrapping.+{- | Instance for non-record constructors.++Non-record constructors with multiple fields are encoded as arrays.+When in a tagged sum type, wraps in an object with "tag" and "contents".++Examples:+@+data Point = Point Double Double+-- Untagged: {"type": "array", "prefixItems": [...], "items": false, "minItems": 2, "maxItems": 2}++data Shape = Circle Double | Rectangle Double Double+-- Tagged: {+--   "type": "object",+--   "properties": {+--     "tag": {"const": "Circle"},+--     "contents": {"type": "number"}+--   }+-- }+@+-}+instance (KnownSymbol name, GToJSONSchema f) => GToJSONSchema (C1 (MetaCons name fixity False) f) where+    gToJSONSchema ::+        forall a. (ToJSONSchema a, Typeable a) => Bool -> Maybe Text -> Proxy (C1 (MetaCons name fixity False) f a) -> Value+    gToJSONSchema tagged root_name _ =+        let c_val = flattenKeys "prefixItems" $ gToJSONSchema False root_name (Proxy :: Proxy (f a))+            c_name = symbolVal (Proxy :: Proxy name)+            tag = object ["const" .= c_name]+         in case c_val of+                o@(Object km) ->+                    let obj = case km KM.!? "prefixItems" of+                            -- A non-record product is a fixed-length tuple: pin the+                            -- length with minItems/maxItems so a short array is+                            -- rejected ("items": false only caps the upper end).+                            Just pfi@(Array vec) ->+                                object+                                    [ "type" .= ("array" :: Text)+                                    , "prefixItems" .= pfi+                                    , "items" .= False+                                    , "minItems" .= V.length vec+                                    , "maxItems" .= V.length vec+                                    ]+                            _ -> o+                        basePairs =+                            [ "type" .= ("object" :: Text)+                            , "properties" .= object ["tag" .= tag, "contents" .= obj]+                            , "additionalProperties" .= False+                            ]+                        requiredPairs =+                            ["required" .= (["tag", "contents"] :: [Text])]+                     in if tagged+                            then+                                object (basePairs ++ requiredPairs)+                            else obj+                Null ->+                    object+                        [ "type" .= ("object" :: Text)+                        , "properties" .= object ["tag" .= tag]+                        , "additionalProperties" .= False+                        , "required" .= (["tag"] :: [Text])+                        ]+                x -> x++{- | Instance for unnamed fields (positional constructor arguments).++Simply delegates to the field type's schema without wrapping.+-} instance (GToJSONSchema f) => GToJSONSchema (S1 (MetaSel Nothing su ss ds) f) where-  gToJSONSchema-    :: forall a. (ToJSONSchema a, Typeable a) => Bool -> Maybe Text -> Proxy (S1 (MetaSel Nothing su ss ds) f a) -> Value-  gToJSONSchema _ root_name _ = gToJSONSchema False root_name (Proxy :: Proxy (f a))+    gToJSONSchema ::+        forall a. (ToJSONSchema a, Typeable a) => Bool -> Maybe Text -> Proxy (S1 (MetaSel Nothing su ss ds) f a) -> Value+    gToJSONSchema _ root_name _ = gToJSONSchema False root_name (Proxy :: Proxy (f a)) --- | Instance for named record fields.------ Creates an object schema with a single property named after--- the field. These are combined by the record constructor instance.------ Example:--- @--- data Person = Person { name :: Text }--- -- For the 'name' field generates:--- -- {--- --   "type": "object",--- --   "properties": {--- --     "name": {"type": "string"}--- --   }--- -- }--- @+{- | Instance for named record fields.++Creates an object schema with a single property named after+the field. These are combined by the record constructor instance.++Example:+@+data Person = Person { name :: Text }+-- For the @name@ field generates:+-- {+--   "type": "object",+--   "properties": {+--     "name": {"type": "string"}+--   }+-- }+@+-} instance (KnownSymbol name, GToJSONSchema f) => GToJSONSchema (S1 (MetaSel (Just name) su ss ds) f) where-  gToJSONSchema-    :: forall a. (ToJSONSchema a, Typeable a) => Bool -> Maybe Text -> Proxy (S1 (MetaSel (Just name) su ss ds) f a) -> Value-  gToJSONSchema _ root_name _ =-    object-      [ "type" .= ("object" :: Text)-      , "properties"-          .= object-            [ fromString (symbolVal (Proxy :: Proxy name)) .= gToJSONSchema False root_name (Proxy :: Proxy (f a))+    gToJSONSchema ::+        forall a. (ToJSONSchema a, Typeable a) => Bool -> Maybe Text -> Proxy (S1 (MetaSel (Just name) su ss ds) f a) -> Value+    gToJSONSchema _ root_name _ =+        object+            [ "type" .= ("object" :: Text)+            , "properties"+                .= object+                    [ fromString (symbolVal (Proxy :: Proxy name)) .= gToJSONSchema False root_name (Proxy :: Proxy (f a))+                    ]             ]-      ]
src/JSONSchema/Validation.hs view
@@ -3,789 +3,806 @@  {-# HLINT ignore "Use ++" #-} --- |--- Module:      JSONSchema.Validation--- Copyright:   (c) DPella AB 2025--- License:     LicenseRef-AllRightsReserved--- Maintainer:  <matti@dpella.io>, <lobo@dpella.io>------ JSON Schema validation according to JSON Schema 2020-12.------ This module provides validation functions that check if a JSON value--- conforms to a given JSON Schema. It supports the core 2020-12--- semantics for validation and applicator keywords ("type", "properties", "patternProperties",--- "additionalProperties", "items", "prefixItems", numeric and string--- constraints, combinators, and conditionals). Local $ref resolution to JSON Pointers--- within the same schema document (including paths under "$defs") is supported.--- The "unevaluatedProperties" and "unevaluatedItems" keywords are implemented--- with pragmatic semantics at the current instance location (covering properties/items--- handled by properties/patternProperties/prefixItems/items/contains). The full--- annotation-merging behavior across nested applicators is not implemented.--- The Format-Assertion vocabulary is not implemented; the "format" and "content*"--- keywords are treated as annotations only, per the 2020-12 specification.------ = Usage------ @--- let schema = toJSONSchema (Proxy :: Proxy Person)--- let value = object ["name" .= "Alice", "age" .= 30]--- validateJSONSchema schema value  -- Returns True if valid--- @-module JSONSchema.Validation (-  validateJSONSchema,-  ValidationError (..),-  validate,-  validateWithErrors,-) where--import Data.Aeson-import Data.Aeson.Key qualified as K-import Data.Aeson.KeyMap qualified as KM-import Data.Char (isDigit)-import Data.List (nub)-import Data.Maybe (fromMaybe, isJust, isNothing)-import Data.Ratio (denominator)-import Data.Scientific (Scientific)-import Data.Scientific qualified as Sci-import Data.Text (Text)-import Data.Text qualified as T-import Data.Vector qualified as V-import Text.Regex.TDFA ((=~))-import Text.Regex.TDFA.Text ()---- | Validation error with context about what failed-data ValidationError = ValidationError-  { error_path :: [Text]-  -- ^ Path to the failing value (e.g. ["users", "0", "name"])-  , error_message :: Text-  -- ^ Description of the validation failure-  }-  deriving (Show, Eq)---- | Simple validation that returns True if the value matches the schema-validateJSONSchema :: Value -> Value -> Bool-validateJSONSchema schema value = null $ validateWithErrors schema value---- | Validate with error collection-validate :: Value -> Value -> Either [ValidationError] ()-validate schema value =-  case validateWithErrors schema value of-    [] -> Right ()-    errs -> Left errs---- | Validate and return all errors found for an instance against a schema.--- This walks the instance and applies the validation vocabulary for 2020-12,--- returning every violation discovered (no short-circuit).-validateWithErrors :: Value -> Value -> [ValidationError]-validateWithErrors schema value = validateValue [] schema schema value maxRefDepth---- | Max recursion depth for $ref to avoid infinite loops-maxRefDepth :: Int-maxRefDepth = 256---- | Internal validator with context.------ Parameters:---  - path: JSON Pointer-like path to the current instance location---  - root: the root schema document (for resolving local $ref)---  - schema: the current subschema to apply---  - value: the current instance value---  - fuel: remaining recursion depth for $ref resolution-validateValue :: [Text] -> Value -> Value -> Value -> Int -> [ValidationError]-validateValue path root schema value fuel =-  case schema of-    Bool True -> [] -- true schema always validates-    Bool False -> [ValidationError path "Schema is false (always fails)"]-    Object km ->-      -- \$ref short-circuit: if present, ignore other keywords (per spec)-      case km KM.!? "$ref" of-        Just (String ref_path) ->-          if fuel <= 0-            then [ValidationError path "Exceeded $ref resolution depth"]-            else case resolveRef root ref_path of-              Just ref_schema -> validateValue path root ref_schema value (fuel - 1)-              Nothing -> [ValidationError path ("Unresolved $ref: " <> ref_path)]-        _ -> validateObject path root km value fuel-    _ -> [ValidationError path "Invalid schema: must be a boolean or object"]---- | Apply object schema keywords and dispatch to other validators.------ Covers:---  - type/const/enum---  - Object keywords: properties, patternProperties, additionalProperties,---    propertyNames, required, dependentSchemas, dependentRequired---  - Array keywords present at this location: prefixItems, items (when instance is array)---  - String/Number/Object count constraints---  - Combinators: anyOf, oneOf, allOf, not---  - Conditionals: if/then/else---  - unevaluatedProperties / unevaluatedItems (local, pragmatic semantics)-validateObject :: [Text] -> Value -> KM.KeyMap Value -> Value -> Int -> [ValidationError]-validateObject path root km value fuel =-  concat-    [ validateType path km value-    , validateConst path km value-    , validateEnum path km value-    , validateProperties path root km value fuel-    , validateItems path root km value fuel-    , validatePrefixItems path root km value fuel-    , validateArrayConstraints path root km value fuel-    , validateStringConstraints path km value-    , validateNumberConstraints path km value-    , validateObjectConstraints path km value-    , validateCombinators path root km value fuel-    , validateConditional path root km value fuel-    , validateUnevaluatedProperties path root km value fuel-    , validateUnevaluatedItems path root km value fuel-    ]---- | Validate the "type" keyword. Supports a single string or an array of types.--- Recognized types: null, boolean, string, number, integer, array, object.-validateType :: [Text] -> KM.KeyMap Value -> Value -> [ValidationError]-validateType path km value =-  case km KM.!? "type" of-    Nothing -> []-    Just (String type_str) ->-      [ ValidationError path $ "Expected type " <> type_str <> " but got " <> describeType value-      | not (checkType type_str value)-      ]-    Just (Array types) ->-      let type_strs = [t | String t <- V.toList types]-      in  [ ValidationError path $ "Expected one of types " <> T.intercalate ", " type_strs <> " but got " <> describeType value-          | not (any (`checkType` value) type_strs)-          ]-    Just _ -> [ValidationError path "Invalid 'type' field in schema"]---- | Check whether a JSON value is of the given JSON Schema type.-checkType :: Text -> Value -> Bool-checkType "null" Null = True-checkType "boolean" (Bool _) = True-checkType "string" (String _) = True-checkType "number" (Number _) = True--- Per JSON Schema, "integer" means the instance is a number with an integral value,--- not limited by machine-sized Int bounds.-checkType "integer" (Number n) = Sci.isInteger n-checkType "array" (Array _) = True-checkType "object" (Object _) = True-checkType _ _ = False---- | Derive the schema type name corresponding to a value (e.g., "integer").-describeType :: Value -> Text-describeType Null = "null"-describeType (Bool _) = "boolean"-describeType (String _) = "string"-describeType (Number n)-  | Sci.isInteger n = "integer"-  | otherwise = "number"-describeType (Array _) = "array"-describeType (Object _) = "object"---- | Validate the "const" keyword (instance must be exactly equal to the given value).-validateConst :: [Text] -> KM.KeyMap Value -> Value -> [ValidationError]-validateConst path km value =-  case km KM.!? "const" of-    Nothing -> []-    Just const_val ->-      [ ValidationError path $ "Expected constant value " <> showJSON const_val <> " but got " <> showJSON value-      | value /= const_val-      ]---- | Validate the "enum" keyword (instance must be a member of the given array).-validateEnum :: [Text] -> KM.KeyMap Value -> Value -> [ValidationError]-validateEnum path km value =-  case km KM.!? "enum" of-    Nothing -> []-    Just (Array values) ->-      [ValidationError path $ "Value not in enum: " <> showJSON value | not (value `V.elem` values)]-    Just _ -> [ValidationError path "Invalid 'enum' field in schema"]---- | Validate object-related keywords at this location.------ Implements:---  - properties: validate each defined property if present---  - patternProperties: validate properties whose names match regex patterns---  - additionalProperties: validate and/or forbid properties not matched above---  - propertyNames: validate each property name as a string instance---  - required: ensure listed properties exist---  - dependentSchemas: when a property exists, apply another schema to the whole object---  - dependentRequired: when a property exists, require additional properties-validateProperties :: [Text] -> Value -> KM.KeyMap Value -> Value -> Int -> [ValidationError]-validateProperties path root km value fuel =-  case value of-    Object obj ->-      let props_schema = km KM.!? "properties"-          pattern_props_schema = km KM.!? "patternProperties"-          additional_props_schema = km KM.!? "additionalProperties"-          property_names_schema = km KM.!? "propertyNames"-          required_props = case km KM.!? "required" of-            Just (Array arr) -> [t | String t <- V.toList arr]-            _ -> []--          -- Validate properties defined in 'properties'-          prop_errors = case props_schema of-            Just (Object schema_obj) ->-              concat-                [ case obj KM.!? key of-                  Just prop_value ->-                    validateValue (path <> [K.toText key]) root schema prop_value fuel-                  Nothing -> []-                | (key, schema) <- KM.toList schema_obj-                ]-            _ -> []--          -- Validate properties matching patterns in 'patternProperties'-          pattern_prop_errors = case pattern_props_schema of-            Just (Object patterns) ->-              concat-                [ concat-                  [ if K.toText obj_key =~ K.toText pattern_key-                    then validateValue (path <> [K.toText obj_key]) root pattern_schema obj_value fuel-                    else []-                  | (pattern_key, pattern_schema) <- KM.toList patterns-                  ]-                | (obj_key, obj_value) <- KM.toList obj-                ]-            _ -> []--          -- Determine which properties are handled by properties or patternProperties-          handled_by_props = case props_schema of-            Just (Object schema_obj) -> KM.keys schema_obj-            _ -> []-          handled_by_patterns = case pattern_props_schema of-            Just (Object patterns) ->-              [ obj_key-              | obj_key <- KM.keys obj-              , any (\pattern_key -> K.toText obj_key =~ K.toText pattern_key) (KM.keys patterns)-              ]-            _ -> []-          unhandled_keys = filter (\k -> k `notElem` handled_by_props && k `notElem` handled_by_patterns) (KM.keys obj)--          -- Validate additional properties-          additional_errors = case additional_props_schema of-            Just (Bool False) ->-              [ ValidationError path $-                "Additional property not allowed: " <> K.toText key-              | key <- unhandled_keys-              ]-            Just schema ->-              concat-                [ case obj KM.!? key of-                  Just prop_value ->-                    validateValue (path <> [K.toText key]) root schema prop_value fuel-                  Nothing -> []-                | key <- unhandled_keys-                ]-            Nothing -> []--          -- Validate property names-          property_name_errors = case property_names_schema of-            Just schema ->-              concat-                [ let key_value = String (K.toText key)-                  in  validateValue (path <> ["propertyName:" <> K.toText key]) root schema key_value fuel-                | key <- KM.keys obj-                ]-            _ -> []--          -- Validate required properties-          required_errors =-            [ ValidationError path $-              "Missing required property: " <> prop-            | prop <- required_props-            , isNothing (obj KM.!? K.fromText prop)-            ]--          -- Handle dependent schemas and required-          dependent_errors = validateDependencies path root km obj fuel-      in  concat [prop_errors, pattern_prop_errors, additional_errors, property_name_errors, required_errors, dependent_errors]-    _ -> []---- | Validate dependentSchemas and dependentRequired.------ - dependentSchemas: if key K present, validate the entire object against a schema S--- - dependentRequired: if key K present, require listed properties to also be present-validateDependencies :: [Text] -> Value -> KM.KeyMap Value -> KM.KeyMap Value -> Int -> [ValidationError]-validateDependencies path root km obj fuel =-  let dep_schemas_errors = case km KM.!? "dependentSchemas" of-        Just (Object dep_schemas) ->-          concat-            [ if isJust (obj KM.!? dep_key)-              then validateValue path root dep_schema (Object obj) fuel-              else []-            | (dep_key, dep_schema) <- KM.toList dep_schemas-            ]-        _ -> []--      dep_required_errors = case km KM.!? "dependentRequired" of-        Just (Object dep_required) ->-          concat-            [ if isJust (obj KM.!? dep_key)-              then-                ( case dep_value of-                    Array required_arr ->-                      [ ValidationError path $-                        "Property '" <> K.toText dep_key <> "' requires property: " <> req_prop-                      | String req_prop <- V.toList required_arr-                      , isNothing (obj KM.!? K.fromText req_prop)-                      ]-                    _ -> []-                )-              else []-            | (dep_key, dep_value) <- KM.toList dep_required-            ]-        _ -> []-  in  dep_schemas_errors <> dep_required_errors---- | Validate array items--- Implements 2020-12 array applicators semantics:---  - "prefixItems": array of schemas applied positionally to the first N items---  - "items": schema applied to all items with index >= N (N = prefixItems length, or 0 if absent)---  - "items": false forbids any items beyond N; if N == 0, array must be empty--- | Validate the "items" applicator for arrays in 2020-12.------ Semantics:---  - prefixItems: schemas applied positionally to first N items---  - items: schema for items at index >= N; items=false forbids any items beyond N-validateItems :: [Text] -> Value -> KM.KeyMap Value -> Value -> Int -> [ValidationError]-validateItems path root km value fuel =-  case value of-    Array arr ->-      let prefix_len = case km KM.!? "prefixItems" of-            Just (Array prefixes) -> Just (V.length prefixes)-            _ -> Just 0-          n = fromMaybe 0 prefix_len-          arr_len = V.length arr-          rest_indices = [n .. arr_len - 1]-      in  case km KM.!? "items" of-            Just (Bool False) ->-              [ ValidationError path $-                "Array has "-                  <> T.pack (show arr_len)-                  <> " items but only "-                  <> T.pack (show n)-                  <> " allowed"-              | arr_len > n-              ]-            Just item_schema ->-              concat-                [ validateValue (path <> [T.pack $ show i]) root item_schema (arr V.! i) fuel-                | i <- rest_indices-                ]-            Nothing -> []-    _ -> []---- | Validate the "prefixItems" applicator for arrays (positionally for the first N items).-validatePrefixItems :: [Text] -> Value -> KM.KeyMap Value -> Value -> Int -> [ValidationError]-validatePrefixItems path root km value fuel =-  case value of-    Array arr ->-      case km KM.!? "prefixItems" of-        Just (Array prefixes) ->-          let limit = min (V.length prefixes) (V.length arr)-          in  concat-                [ validateValue (path <> [T.pack $ show i]) root (prefixes V.! i) (arr V.! i) fuel-                | i <- [0 .. limit - 1]-                ]-        _ -> []-    _ -> []---- | Validate string constraints: minLength, maxLength, pattern.--- Note: "format" is treated as an annotation and not asserted.-validateStringConstraints :: [Text] -> KM.KeyMap Value -> Value -> [ValidationError]-validateStringConstraints path km value =-  case value of-    String str ->-      let l = unicodeLength str-      in  concat-            [ case km KM.!? "minLength" of-                Just (Number n)-                  | Just min_len <- Sci.toBoundedInteger n ->-                      [ ValidationError path $-                        "String length "-                          <> T.pack (show l)-                          <> " is less than minimum "-                          <> T.pack (show min_len)-                      | l < (min_len :: Int)-                      ]-                _ -> []-            , case km KM.!? "maxLength" of-                Just (Number n)-                  | Just max_len <- Sci.toBoundedInteger n ->-                      ( [ ValidationError path $-                          "String length "-                            <> T.pack (show l)-                            <> " exceeds maximum "-                            <> T.pack (show max_len)-                        | l > (max_len :: Int)-                        ]-                      )-                _ -> []-            , case km KM.!? "pattern" of-                Just (String pattern) ->-                  [ValidationError path $ "String does not match pattern: " <> pattern | not (str =~ pattern)]-                _ -> []-                -- format validation is optional and can be added later-            ]-    _ -> []---- | Validate numeric constraints: minimum, maximum, exclusiveMinimum, exclusiveMaximum, multipleOf.--- multipleOf is checked exactly using rationals to avoid floating point error.-validateNumberConstraints :: [Text] -> KM.KeyMap Value -> Value -> [ValidationError]-validateNumberConstraints path km value =-  case value of-    Number num ->-      let fmt :: Scientific -> Text-          fmt = T.pack . Sci.formatScientific Sci.Generic Nothing-      in  concat-            [ case km KM.!? "minimum" of-                Just (Number min_val) ->-                  [ ValidationError path $-                    "Value " <> fmt num <> " is less than minimum " <> fmt min_val-                  | num < min_val-                  ]-                _ -> []-            , case km KM.!? "exclusiveMinimum" of-                Just (Number min_val) ->-                  [ ValidationError path $-                    "Value " <> fmt num <> " is not greater than exclusiveMinimum " <> fmt min_val-                  | num <= min_val-                  ]-                _ -> []-            , case km KM.!? "maximum" of-                Just (Number max_val) ->-                  [ ValidationError path $-                    "Value " <> fmt num <> " exceeds maximum " <> fmt max_val-                  | num > max_val-                  ]-                _ -> []-            , case km KM.!? "exclusiveMaximum" of-                Just (Number max_val) ->-                  [ ValidationError path $-                    "Value " <> fmt num <> " is not less than exclusiveMaximum " <> fmt max_val-                  | num >= max_val-                  ]-                _ -> []-            , case km KM.!? "multipleOf" of-                Just (Number divisor) ->-                  ( [ ValidationError path $-                      "Value " <> fmt num <> " is not a multiple of " <> fmt divisor-                    | not (isMultipleOf num divisor)-                    ]-                  )-                _ -> []-            ]-    _ -> []---- | Validate logical combinators: anyOf, oneOf, allOf, not.-validateCombinators :: [Text] -> Value -> KM.KeyMap Value -> Value -> Int -> [ValidationError]-validateCombinators path root km value fuel =-  concat-    [ validateAnyOf path root km value fuel-    , validateOneOf path root km value fuel-    , validateAllOf path root km value fuel-    , validateNot path root km value fuel-    ]---- | anyOf: valid if at least one subschema validates (merges annotations of matching subschemas).-validateAnyOf :: [Text] -> Value -> KM.KeyMap Value -> Value -> Int -> [ValidationError]-validateAnyOf path root km value fuel =-  case km KM.!? "anyOf" of-    Just (Array schemas) ->-      let results = [validateValue path root schema value fuel | schema <- V.toList schemas]-      in  [ValidationError path "Value does not match any of the schemas in 'anyOf'" | not (any null results)]-    _ -> []---- | oneOf: valid if exactly one subschema validates.-validateOneOf :: [Text] -> Value -> KM.KeyMap Value -> Value -> Int -> [ValidationError]-validateOneOf path root km value fuel =-  case km KM.!? "oneOf" of-    Just (Array schemas) ->-      let results = [validateValue path root schema value fuel | schema <- V.toList schemas]-          valid_count = length $ filter null results-      in  case valid_count of-            0 -> [ValidationError path "Value does not match any schema in 'oneOf'"]-            1 -> []-            _ ->-              [ ValidationError path $ "Value matches " <> T.pack (show valid_count) <> " schemas in 'oneOf' but must match exactly one"-              ]-    _ -> []---- | allOf: valid only if all subschemas validate; accumulates all errors.-validateAllOf :: [Text] -> Value -> KM.KeyMap Value -> Value -> Int -> [ValidationError]-validateAllOf path root km value fuel =-  case km KM.!? "allOf" of-    Just (Array schemas) ->-      concat [validateValue path root schema value fuel | schema <- V.toList schemas]-    _ -> []---- | not: valid only if the subschema does not validate.-validateNot :: [Text] -> Value -> KM.KeyMap Value -> Value -> Int -> [ValidationError]-validateNot path root km value fuel =-  case km KM.!? "not" of-    Just not_schema ->-      [ValidationError path "Value matches schema in 'not'" | null (validateValue path root not_schema value fuel)]-    _ -> []---- | Validate array count/membership constraints: minItems, maxItems, uniqueItems, contains.-validateArrayConstraints :: [Text] -> Value -> KM.KeyMap Value -> Value -> Int -> [ValidationError]-validateArrayConstraints path root km value fuel =-  case value of-    Array arr ->-      concat-        [ case km KM.!? "minItems" of-            Just (Number n)-              | Just min_items <- Sci.toBoundedInteger n ->-                  [ ValidationError path $-                    "Array has "-                      <> T.pack (show $ V.length arr)-                      <> " items but minimum is "-                      <> T.pack (show min_items)-                  | V.length arr < min_items-                  ]-            _ -> []-        , case km KM.!? "maxItems" of-            Just (Number n)-              | Just max_items <- Sci.toBoundedInteger n ->-                  [ ValidationError path $-                    "Array has "-                      <> T.pack (show $ V.length arr)-                      <> " items but maximum is "-                      <> T.pack (show max_items)-                  | V.length arr > max_items-                  ]-            _ -> []-        , case km KM.!? "uniqueItems" of-            Just (Bool True) ->-              let items = V.toList arr-              in  [ValidationError path "Array items are not unique" | length items /= length (nub items)]-            _ -> []-        , validateContains path root km arr fuel-        ]-    _ -> []---- | Validate contains constraints--- | Validate contains/minContains/maxContains: counts items matching the subschema.--- Note: when "contains" is false, minContains defaults to 1, making any array invalid.-validateContains :: [Text] -> Value -> KM.KeyMap Value -> V.Vector Value -> Int -> [ValidationError]-validateContains path root km arr fuel =-  case km KM.!? "contains" of-    Nothing -> []-    Just contains_schema ->-      let matches = V.filter (\item -> null $ validateValue path root contains_schema item fuel) arr-          match_count = V.length matches-          min_contains = case km KM.!? "minContains" of-            Just (Number n) -> fromMaybe 1 (Sci.toBoundedInteger n)-            _ -> 1-          max_contains = case km KM.!? "maxContains" of-            Just (Number n) -> Sci.toBoundedInteger n-            _ -> Nothing-      in  concat-            [ [ ValidationError path $-                "Array has "-                  <> T.pack (show match_count)-                  <> " matching items but minContains is "-                  <> T.pack (show min_contains)-              | match_count < min_contains-              ]-            , case max_contains of-                Just max_cont ->-                  [ ValidationError path $-                    "Array has "-                      <> T.pack (show match_count)-                      <> " matching items but maxContains is "-                      <> T.pack (show max_cont)-                  | match_count > max_cont-                  ]-                Nothing -> []-            ]---- | Validate object constraints (minProperties, maxProperties)--- | Validate object property count constraints: minProperties and maxProperties.-validateObjectConstraints :: [Text] -> KM.KeyMap Value -> Value -> [ValidationError]-validateObjectConstraints path km value =-  case value of-    Object obj ->-      let prop_count = KM.size obj-      in  concat-            [ case km KM.!? "minProperties" of-                Just (Number n)-                  | Just min_props <- Sci.toBoundedInteger n ->-                      [ ValidationError path $-                        "Object has "-                          <> T.pack (show prop_count)-                          <> " properties but minimum is "-                          <> T.pack (show min_props)-                      | prop_count < min_props-                      ]-                _ -> []-            , case km KM.!? "maxProperties" of-                Just (Number n)-                  | Just max_props <- Sci.toBoundedInteger n ->-                      [ ValidationError path $-                        "Object has "-                          <> T.pack (show prop_count)-                          <> " properties but maximum is "-                          <> T.pack (show max_props)-                      | prop_count > max_props-                      ]-                _ -> []-            ]-    _ -> []---- | Validate conditional logic (if/then/else)--- | Validate conditional keywords: if/then/else.-validateConditional :: [Text] -> Value -> KM.KeyMap Value -> Value -> Int -> [ValidationError]-validateConditional path root km value fuel =-  case km KM.!? "if" of-    Nothing -> []-    Just if_schema ->-      let if_matches = null $ validateValue path root if_schema value fuel-      in  if if_matches-            then case km KM.!? "then" of-              Just then_schema -> validateValue path root then_schema value fuel-              Nothing -> []-            else case km KM.!? "else" of-              Just else_schema -> validateValue path root else_schema value fuel-              Nothing -> []---- | Check if a number is a multiple of another--- Exact multipleOf using rationals (avoids floating point error).--- | Exact multipleOf check using rationals (denominator must be 1).-isMultipleOf :: Scientific -> Scientific -> Bool-isMultipleOf value divisor =-  let q = toRational value / toRational divisor-  in  denominator q == 1---- Count Unicode code points (not UTF-16 code units)---- | Count Unicode code points (not UTF-16 code units) for string length.-unicodeLength :: Text -> Int-unicodeLength = T.foldl' (\n _ -> n + 1) 0---- | Helper to show JSON values as text-showJSON :: Value -> Text-showJSON = T.take 100 . T.pack . show---- | Resolve a local $ref against the root schema using JSON Pointer--- Supports only fragment starting with '#'. External URIs and anchors are not resolved.--- | Resolve a local $ref against the root schema using JSON Pointer.--- Supports only fragments starting with '#'; external URIs/anchors are not resolved.-resolveRef :: Value -> Text -> Maybe Value-resolveRef root ref_path =-  case T.uncons ref_path of-    Just ('#', rest) ->-      -- empty fragment or pointer-      if T.null rest-        then Just root-        else-          if T.head rest == '/'-            then jsonPointer root (T.tail rest)-            else Nothing -- unsupported non-pointer fragment-    _ -> Nothing -- unsupported non-fragment refs---- | Evaluate a JSON Pointer (RFC 6901) against a JSON value.-jsonPointer :: Value -> Text -> Maybe Value-jsonPointer v ptr =-  let tokens = map unescapePointer $ T.splitOn "/" ptr-  in  go v tokens-  where-    go :: Value -> [Text] -> Maybe Value-    go cur [] = Just cur-    go (Object km) (t : ts) =-      case KM.lookup (K.fromText t) km of-        Just nxt -> go nxt ts-        Nothing -> Nothing-    go (Array arr) (t : ts)-      | T.all isDigit t && not (T.null t) =-          let i = read (T.unpack t) :: Int-          in  if i >= 0 && i < V.length arr then go (arr V.! i) ts else Nothing-      | otherwise = Nothing-    go _ _ = Nothing--    unescapePointer :: Text -> Text-    unescapePointer = T.replace "~1" "/" . T.replace "~0" "~"---- | Validate "unevaluatedProperties":--- Forbids or constrains properties that were not covered by properties,--- patternProperties, or additionalProperties at the current schema location.--- This implementation approximates evaluated sets locally and does not--- perform full annotation merging across nested applicators.-validateUnevaluatedProperties :: [Text] -> Value -> KM.KeyMap Value -> Value -> Int -> [ValidationError]-validateUnevaluatedProperties path root km value fuel =-  case (value, km KM.!? "unevaluatedProperties") of-    (Object obj, Just uneval_schema) ->-      let props_schema = km KM.!? "properties"-          pattern_props_schema = km KM.!? "patternProperties"--          props_handled = case props_schema of-            Just (Object schema_obj) -> [k | (k, _) <- KM.toList schema_obj, KM.member k obj]-            _ -> []-          patterns_handled = case pattern_props_schema of-            Just (Object patterns) ->-              [ obj_key-              | obj_key <- KM.keys obj-              , any (\pattern_key -> K.toText obj_key =~ K.toText pattern_key) (KM.keys patterns)-              ]-            _ -> []-          extras = [k | k <- KM.keys obj, k `notElem` props_handled, k `notElem` patterns_handled]--          -- If additionalProperties is present, consider extras handled by it-          extras_handled_by_additional = case km KM.!? "additionalProperties" of-            Just _ -> extras-            Nothing -> []--          unevaluated_keys = [k | k <- extras, k `notElem` extras_handled_by_additional]--          applyUnevaluated (Bool False) =-            [ ValidationError path $-              "Unevaluated property not allowed: " <> K.toText k-            | k <- unevaluated_keys-            ]-          applyUnevaluated sch =-            concat-              [ case obj KM.!? k of-                Just v -> validateValue (path <> [K.toText k]) root sch v fuel-                Nothing -> []-              | k <- unevaluated_keys-              ]-      in  applyUnevaluated uneval_schema-    _ -> []---- | Validate "unevaluatedItems":--- Applies to array indices not covered by prefixItems, items, or contains When false, any such index is prohibited; when a schema, each such item--- is validated against it. This uses local evaluated-set approximation.-validateUnevaluatedItems :: [Text] -> Value -> KM.KeyMap Value -> Value -> Int -> [ValidationError]-validateUnevaluatedItems path root km value fuel =-  case (value, km KM.!? "unevaluatedItems") of-    (Array arr, Just uneval_schema) ->-      let arr_len = V.length arr-          -- prefix items coverage-          prefix_count = case km KM.!? "prefixItems" of-            Just (Array prefixes) -> min (V.length prefixes) arr_len-            _ -> 0-          prefix_idx = [0 .. prefix_count - 1]-          -- items coverage for rest if present-          rest_idx = case km KM.!? "items" of-            Just _ -> [prefix_count .. arr_len - 1]-            Nothing -> []-          -- contains matched indices (optional)-          contains_idx = case km KM.!? "contains" of-            Nothing -> []-            Just sch ->-              [ i-              | i <- [0 .. arr_len - 1]-              , null $ validateValue path root sch (arr V.! i) fuel-              ]-          evaluated = nub (prefix_idx <> rest_idx <> contains_idx)-          unevaluated = [i | i <- [0 .. arr_len - 1], i `notElem` evaluated]-          applyItems (Bool False) =-            [ ValidationError path $-              "Unevaluated item not allowed at index " <> T.pack (show i)-            | i <- unevaluated-            ]-          applyItems sch =-            concat-              [ validateValue (path <> [T.pack (show i)]) root sch (arr V.! i) fuel-              | i <- unevaluated-              ]-      in  applyItems uneval_schema-    _ -> []+{- |+Module:      JSONSchema.Validation+Copyright:   (c) DPella AB 2025+License:     LicenseRef-AllRightsReserved+Maintainer:  <matti@dpella.io>, <lobo@dpella.io>++JSON Schema validation according to JSON Schema 2020-12.++This module provides validation functions that check if a JSON value+conforms to a given JSON Schema. It supports the core 2020-12+semantics for validation and applicator keywords ("type", "properties", "patternProperties",+"additionalProperties", "items", "prefixItems", numeric and string+constraints, combinators, and conditionals). Local $ref resolution to JSON Pointers+within the same schema document (including paths under "$defs") is supported.+The "unevaluatedProperties" and "unevaluatedItems" keywords are implemented+with pragmatic semantics at the current instance location (covering properties/items+handled by properties/patternProperties/prefixItems/items/contains). The full+annotation-merging behavior across nested applicators is not implemented.+The Format-Assertion vocabulary is not implemented; the "format" and "content*"+keywords are treated as annotations only, per the 2020-12 specification.++= Usage++@+let schema = toJSONSchema (Proxy :: Proxy Person)+let value = object ["name" .= "Alice", "age" .= 30]+validateJSONSchema schema value  -- Returns True if valid+@+-}+module JSONSchema.Validation (+    validateJSONSchema,+    ValidationError (..),+    validate,+    validateWithErrors,+) where++import Data.Aeson+import Data.Aeson.Key qualified as K+import Data.Aeson.KeyMap qualified as KM+import Data.Char (isDigit)+import Data.List (nub)+import Data.Maybe (fromMaybe, isJust, isNothing)+import Data.Ratio (denominator)+import Data.Scientific (Scientific)+import Data.Scientific qualified as Sci+import Data.Text (Text)+import Data.Text qualified as T+import Data.Vector qualified as V+import Text.Regex.TDFA ((=~))+import Text.Regex.TDFA.Text ()++-- | Validation error with context about what failed+data ValidationError = ValidationError+    { error_path :: [Text]+    -- ^ Path to the failing value (e.g. ["users", "0", "name"])+    , error_message :: Text+    -- ^ Description of the validation failure+    }+    deriving (Show, Eq)++-- | Simple validation that returns True if the value matches the schema+validateJSONSchema :: Value -> Value -> Bool+validateJSONSchema schema value = null $ validateWithErrors schema value++-- | Validate with error collection+validate :: Value -> Value -> Either [ValidationError] ()+validate schema value =+    case validateWithErrors schema value of+        [] -> Right ()+        errs -> Left errs++{- | Validate and return all errors found for an instance against a schema.+This walks the instance and applies the validation vocabulary for 2020-12,+returning every violation discovered (no short-circuit).+-}+validateWithErrors :: Value -> Value -> [ValidationError]+validateWithErrors schema value = validateValue [] schema schema value maxRefDepth++-- | Max recursion depth for $ref to avoid infinite loops+maxRefDepth :: Int+maxRefDepth = 256++{- | Internal validator with context.++Parameters:+ - path: JSON Pointer-like path to the current instance location+ - root: the root schema document (for resolving local $ref)+ - schema: the current subschema to apply+ - value: the current instance value+ - fuel: remaining recursion depth for $ref resolution+-}+validateValue :: [Text] -> Value -> Value -> Value -> Int -> [ValidationError]+validateValue path root schema value fuel =+    case schema of+        Bool True -> [] -- true schema always validates+        Bool False -> [ValidationError path "Schema is false (always fails)"]+        Object km ->+            -- \$ref short-circuit: if present, ignore other keywords (per spec)+            case km KM.!? "$ref" of+                Just (String ref_path) ->+                    if fuel <= 0+                        then [ValidationError path "Exceeded $ref resolution depth"]+                        else case resolveRef root ref_path of+                            Just ref_schema -> validateValue path root ref_schema value (fuel - 1)+                            Nothing -> [ValidationError path ("Unresolved $ref: " <> ref_path)]+                _ -> validateObject path root km value fuel+        _ -> [ValidationError path "Invalid schema: must be a boolean or object"]++{- | Apply object schema keywords and dispatch to other validators.++Covers:+ - type/const/enum+ - Object keywords: properties, patternProperties, additionalProperties,+   propertyNames, required, dependentSchemas, dependentRequired+ - Array keywords present at this location: prefixItems, items (when instance is array)+ - String/Number/Object count constraints+ - Combinators: anyOf, oneOf, allOf, not+ - Conditionals: if/then/else+ - unevaluatedProperties / unevaluatedItems (local, pragmatic semantics)+-}+validateObject :: [Text] -> Value -> KM.KeyMap Value -> Value -> Int -> [ValidationError]+validateObject path root km value fuel =+    concat+        [ validateType path km value+        , validateConst path km value+        , validateEnum path km value+        , validateProperties path root km value fuel+        , validateItems path root km value fuel+        , validatePrefixItems path root km value fuel+        , validateArrayConstraints path root km value fuel+        , validateStringConstraints path km value+        , validateNumberConstraints path km value+        , validateObjectConstraints path km value+        , validateCombinators path root km value fuel+        , validateConditional path root km value fuel+        , validateUnevaluatedProperties path root km value fuel+        , validateUnevaluatedItems path root km value fuel+        ]++{- | Validate the "type" keyword. Supports a single string or an array of types.+Recognized types: null, boolean, string, number, integer, array, object.+-}+validateType :: [Text] -> KM.KeyMap Value -> Value -> [ValidationError]+validateType path km value =+    case km KM.!? "type" of+        Nothing -> []+        Just (String type_str) ->+            [ ValidationError path $ "Expected type " <> type_str <> " but got " <> describeType value+            | not (checkType type_str value)+            ]+        Just (Array types) ->+            let type_strs = [t | String t <- V.toList types]+             in [ ValidationError path $ "Expected one of types " <> T.intercalate ", " type_strs <> " but got " <> describeType value+                | not (any (`checkType` value) type_strs)+                ]+        Just _ -> [ValidationError path "Invalid 'type' field in schema"]++-- | Check whether a JSON value is of the given JSON Schema type.+checkType :: Text -> Value -> Bool+checkType "null" Null = True+checkType "boolean" (Bool _) = True+checkType "string" (String _) = True+checkType "number" (Number _) = True+-- Per JSON Schema, "integer" means the instance is a number with an integral value,+-- not limited by machine-sized Int bounds.+checkType "integer" (Number n) = Sci.isInteger n+checkType "array" (Array _) = True+checkType "object" (Object _) = True+checkType _ _ = False++-- | Derive the schema type name corresponding to a value (e.g., "integer").+describeType :: Value -> Text+describeType Null = "null"+describeType (Bool _) = "boolean"+describeType (String _) = "string"+describeType (Number n)+    | Sci.isInteger n = "integer"+    | otherwise = "number"+describeType (Array _) = "array"+describeType (Object _) = "object"++-- | Validate the "const" keyword (instance must be exactly equal to the given value).+validateConst :: [Text] -> KM.KeyMap Value -> Value -> [ValidationError]+validateConst path km value =+    case km KM.!? "const" of+        Nothing -> []+        Just const_val ->+            [ ValidationError path $ "Expected constant value " <> showJSON const_val <> " but got " <> showJSON value+            | value /= const_val+            ]++-- | Validate the "enum" keyword (instance must be a member of the given array).+validateEnum :: [Text] -> KM.KeyMap Value -> Value -> [ValidationError]+validateEnum path km value =+    case km KM.!? "enum" of+        Nothing -> []+        Just (Array values) ->+            [ValidationError path $ "Value not in enum: " <> showJSON value | not (value `V.elem` values)]+        Just _ -> [ValidationError path "Invalid 'enum' field in schema"]++{- | Validate object-related keywords at this location.++Implements:+ - properties: validate each defined property if present+ - patternProperties: validate properties whose names match regex patterns+ - additionalProperties: validate and/or forbid properties not matched above+ - propertyNames: validate each property name as a string instance+ - required: ensure listed properties exist+ - dependentSchemas: when a property exists, apply another schema to the whole object+ - dependentRequired: when a property exists, require additional properties+-}+validateProperties :: [Text] -> Value -> KM.KeyMap Value -> Value -> Int -> [ValidationError]+validateProperties path root km value fuel =+    case value of+        Object obj ->+            let props_schema = km KM.!? "properties"+                pattern_props_schema = km KM.!? "patternProperties"+                additional_props_schema = km KM.!? "additionalProperties"+                property_names_schema = km KM.!? "propertyNames"+                required_props = case km KM.!? "required" of+                    Just (Array arr) -> [t | String t <- V.toList arr]+                    _ -> []++                -- Validate properties defined in 'properties'+                prop_errors = case props_schema of+                    Just (Object schema_obj) ->+                        concat+                            [ case obj KM.!? key of+                                Just prop_value ->+                                    validateValue (path <> [K.toText key]) root schema prop_value fuel+                                Nothing -> []+                            | (key, schema) <- KM.toList schema_obj+                            ]+                    _ -> []++                -- Validate properties matching patterns in 'patternProperties'+                pattern_prop_errors = case pattern_props_schema of+                    Just (Object patterns) ->+                        concat+                            [ concat+                                [ if K.toText obj_key =~ K.toText pattern_key+                                    then validateValue (path <> [K.toText obj_key]) root pattern_schema obj_value fuel+                                    else []+                                | (pattern_key, pattern_schema) <- KM.toList patterns+                                ]+                            | (obj_key, obj_value) <- KM.toList obj+                            ]+                    _ -> []++                -- Determine which properties are handled by properties or patternProperties+                handled_by_props = case props_schema of+                    Just (Object schema_obj) -> KM.keys schema_obj+                    _ -> []+                handled_by_patterns = case pattern_props_schema of+                    Just (Object patterns) ->+                        [ obj_key+                        | obj_key <- KM.keys obj+                        , any (\pattern_key -> K.toText obj_key =~ K.toText pattern_key) (KM.keys patterns)+                        ]+                    _ -> []+                unhandled_keys = filter (\k -> k `notElem` handled_by_props && k `notElem` handled_by_patterns) (KM.keys obj)++                -- Validate additional properties+                additional_errors = case additional_props_schema of+                    Just (Bool False) ->+                        [ ValidationError path $+                            "Additional property not allowed: " <> K.toText key+                        | key <- unhandled_keys+                        ]+                    Just schema ->+                        concat+                            [ case obj KM.!? key of+                                Just prop_value ->+                                    validateValue (path <> [K.toText key]) root schema prop_value fuel+                                Nothing -> []+                            | key <- unhandled_keys+                            ]+                    Nothing -> []++                -- Validate property names+                property_name_errors = case property_names_schema of+                    Just schema ->+                        concat+                            [ let key_value = String (K.toText key)+                               in validateValue (path <> ["propertyName:" <> K.toText key]) root schema key_value fuel+                            | key <- KM.keys obj+                            ]+                    _ -> []++                -- Validate required properties+                required_errors =+                    [ ValidationError path $+                        "Missing required property: " <> prop+                    | prop <- required_props+                    , isNothing (obj KM.!? K.fromText prop)+                    ]++                -- Handle dependent schemas and required+                dependent_errors = validateDependencies path root km obj fuel+             in concat [prop_errors, pattern_prop_errors, additional_errors, property_name_errors, required_errors, dependent_errors]+        _ -> []++{- | Validate dependentSchemas and dependentRequired.++- dependentSchemas: if key K present, validate the entire object against a schema S+- dependentRequired: if key K present, require listed properties to also be present+-}+validateDependencies :: [Text] -> Value -> KM.KeyMap Value -> KM.KeyMap Value -> Int -> [ValidationError]+validateDependencies path root km obj fuel =+    let dep_schemas_errors = case km KM.!? "dependentSchemas" of+            Just (Object dep_schemas) ->+                concat+                    [ if isJust (obj KM.!? dep_key)+                        then validateValue path root dep_schema (Object obj) fuel+                        else []+                    | (dep_key, dep_schema) <- KM.toList dep_schemas+                    ]+            _ -> []++        dep_required_errors = case km KM.!? "dependentRequired" of+            Just (Object dep_required) ->+                concat+                    [ if isJust (obj KM.!? dep_key)+                        then+                            ( case dep_value of+                                Array required_arr ->+                                    [ ValidationError path $+                                        "Property '" <> K.toText dep_key <> "' requires property: " <> req_prop+                                    | String req_prop <- V.toList required_arr+                                    , isNothing (obj KM.!? K.fromText req_prop)+                                    ]+                                _ -> []+                            )+                        else []+                    | (dep_key, dep_value) <- KM.toList dep_required+                    ]+            _ -> []+     in dep_schemas_errors <> dep_required_errors++{- | Validate array items+Implements 2020-12 array applicators semantics:+ - "prefixItems": array of schemas applied positionally to the first N items+ - "items": schema applied to all items with index >= N (N = prefixItems length, or 0 if absent)+ - "items": false forbids any items beyond N; if N == 0, array must be empty+| Validate the "items" applicator for arrays in 2020-12.++Semantics:+ - prefixItems: schemas applied positionally to first N items+ - items: schema for items at index >= N; items=false forbids any items beyond N+-}+validateItems :: [Text] -> Value -> KM.KeyMap Value -> Value -> Int -> [ValidationError]+validateItems path root km value fuel =+    case value of+        Array arr ->+            let prefix_len = case km KM.!? "prefixItems" of+                    Just (Array prefixes) -> Just (V.length prefixes)+                    _ -> Just 0+                n = fromMaybe 0 prefix_len+                arr_len = V.length arr+                rest_indices = [n .. arr_len - 1]+             in case km KM.!? "items" of+                    Just (Bool False) ->+                        [ ValidationError path $+                            "Array has "+                                <> T.pack (show arr_len)+                                <> " items but only "+                                <> T.pack (show n)+                                <> " allowed"+                        | arr_len > n+                        ]+                    Just item_schema ->+                        concat+                            [ validateValue (path <> [T.pack $ show i]) root item_schema (arr V.! i) fuel+                            | i <- rest_indices+                            ]+                    Nothing -> []+        _ -> []++-- | Validate the "prefixItems" applicator for arrays (positionally for the first N items).+validatePrefixItems :: [Text] -> Value -> KM.KeyMap Value -> Value -> Int -> [ValidationError]+validatePrefixItems path root km value fuel =+    case value of+        Array arr ->+            case km KM.!? "prefixItems" of+                Just (Array prefixes) ->+                    let limit = min (V.length prefixes) (V.length arr)+                     in concat+                            [ validateValue (path <> [T.pack $ show i]) root (prefixes V.! i) (arr V.! i) fuel+                            | i <- [0 .. limit - 1]+                            ]+                _ -> []+        _ -> []++{- | Validate string constraints: minLength, maxLength, pattern.+Note: "format" is treated as an annotation and not asserted.+-}+validateStringConstraints :: [Text] -> KM.KeyMap Value -> Value -> [ValidationError]+validateStringConstraints path km value =+    case value of+        String str ->+            let l = unicodeLength str+             in concat+                    [ case km KM.!? "minLength" of+                        Just (Number n)+                            | Just min_len <- Sci.toBoundedInteger n ->+                                [ ValidationError path $+                                    "String length "+                                        <> T.pack (show l)+                                        <> " is less than minimum "+                                        <> T.pack (show min_len)+                                | l < (min_len :: Int)+                                ]+                        _ -> []+                    , case km KM.!? "maxLength" of+                        Just (Number n)+                            | Just max_len <- Sci.toBoundedInteger n ->+                                ( [ ValidationError path $+                                        "String length "+                                            <> T.pack (show l)+                                            <> " exceeds maximum "+                                            <> T.pack (show max_len)+                                  | l > (max_len :: Int)+                                  ]+                                )+                        _ -> []+                    , case km KM.!? "pattern" of+                        Just (String pattern) ->+                            [ValidationError path $ "String does not match pattern: " <> pattern | not (str =~ pattern)]+                        _ -> []+                        -- format validation is optional and can be added later+                    ]+        _ -> []++{- | Validate numeric constraints: minimum, maximum, exclusiveMinimum, exclusiveMaximum, multipleOf.+multipleOf is checked exactly using rationals to avoid floating point error.+-}+validateNumberConstraints :: [Text] -> KM.KeyMap Value -> Value -> [ValidationError]+validateNumberConstraints path km value =+    case value of+        Number num ->+            let fmt :: Scientific -> Text+                fmt = T.pack . Sci.formatScientific Sci.Generic Nothing+             in concat+                    [ case km KM.!? "minimum" of+                        Just (Number min_val) ->+                            [ ValidationError path $+                                "Value " <> fmt num <> " is less than minimum " <> fmt min_val+                            | num < min_val+                            ]+                        _ -> []+                    , case km KM.!? "exclusiveMinimum" of+                        Just (Number min_val) ->+                            [ ValidationError path $+                                "Value " <> fmt num <> " is not greater than exclusiveMinimum " <> fmt min_val+                            | num <= min_val+                            ]+                        _ -> []+                    , case km KM.!? "maximum" of+                        Just (Number max_val) ->+                            [ ValidationError path $+                                "Value " <> fmt num <> " exceeds maximum " <> fmt max_val+                            | num > max_val+                            ]+                        _ -> []+                    , case km KM.!? "exclusiveMaximum" of+                        Just (Number max_val) ->+                            [ ValidationError path $+                                "Value " <> fmt num <> " is not less than exclusiveMaximum " <> fmt max_val+                            | num >= max_val+                            ]+                        _ -> []+                    , case km KM.!? "multipleOf" of+                        Just (Number divisor) ->+                            ( [ ValidationError path $+                                    "Value " <> fmt num <> " is not a multiple of " <> fmt divisor+                              | not (isMultipleOf num divisor)+                              ]+                            )+                        _ -> []+                    ]+        _ -> []++-- | Validate logical combinators: anyOf, oneOf, allOf, not.+validateCombinators :: [Text] -> Value -> KM.KeyMap Value -> Value -> Int -> [ValidationError]+validateCombinators path root km value fuel =+    concat+        [ validateAnyOf path root km value fuel+        , validateOneOf path root km value fuel+        , validateAllOf path root km value fuel+        , validateNot path root km value fuel+        ]++-- | anyOf: valid if at least one subschema validates (merges annotations of matching subschemas).+validateAnyOf :: [Text] -> Value -> KM.KeyMap Value -> Value -> Int -> [ValidationError]+validateAnyOf path root km value fuel =+    case km KM.!? "anyOf" of+        Just (Array schemas) ->+            let results = [validateValue path root schema value fuel | schema <- V.toList schemas]+             in [ValidationError path "Value does not match any of the schemas in 'anyOf'" | not (any null results)]+        _ -> []++-- | oneOf: valid if exactly one subschema validates.+validateOneOf :: [Text] -> Value -> KM.KeyMap Value -> Value -> Int -> [ValidationError]+validateOneOf path root km value fuel =+    case km KM.!? "oneOf" of+        Just (Array schemas) ->+            let results = [validateValue path root schema value fuel | schema <- V.toList schemas]+                valid_count = length $ filter null results+             in case valid_count of+                    0 -> [ValidationError path "Value does not match any schema in 'oneOf'"]+                    1 -> []+                    _ ->+                        [ ValidationError path $ "Value matches " <> T.pack (show valid_count) <> " schemas in 'oneOf' but must match exactly one"+                        ]+        _ -> []++-- | allOf: valid only if all subschemas validate; accumulates all errors.+validateAllOf :: [Text] -> Value -> KM.KeyMap Value -> Value -> Int -> [ValidationError]+validateAllOf path root km value fuel =+    case km KM.!? "allOf" of+        Just (Array schemas) ->+            concat [validateValue path root schema value fuel | schema <- V.toList schemas]+        _ -> []++-- | not: valid only if the subschema does not validate.+validateNot :: [Text] -> Value -> KM.KeyMap Value -> Value -> Int -> [ValidationError]+validateNot path root km value fuel =+    case km KM.!? "not" of+        Just not_schema ->+            [ValidationError path "Value matches schema in 'not'" | null (validateValue path root not_schema value fuel)]+        _ -> []++-- | Validate array count/membership constraints: minItems, maxItems, uniqueItems, contains.+validateArrayConstraints :: [Text] -> Value -> KM.KeyMap Value -> Value -> Int -> [ValidationError]+validateArrayConstraints path root km value fuel =+    case value of+        Array arr ->+            concat+                [ case km KM.!? "minItems" of+                    Just (Number n)+                        | Just min_items <- Sci.toBoundedInteger n ->+                            [ ValidationError path $+                                "Array has "+                                    <> T.pack (show $ V.length arr)+                                    <> " items but minimum is "+                                    <> T.pack (show min_items)+                            | V.length arr < min_items+                            ]+                    _ -> []+                , case km KM.!? "maxItems" of+                    Just (Number n)+                        | Just max_items <- Sci.toBoundedInteger n ->+                            [ ValidationError path $+                                "Array has "+                                    <> T.pack (show $ V.length arr)+                                    <> " items but maximum is "+                                    <> T.pack (show max_items)+                            | V.length arr > max_items+                            ]+                    _ -> []+                , case km KM.!? "uniqueItems" of+                    Just (Bool True) ->+                        let items = V.toList arr+                         in [ValidationError path "Array items are not unique" | length items /= length (nub items)]+                    _ -> []+                , validateContains path root km arr fuel+                ]+        _ -> []++{- | Validate contains constraints+| Validate contains/minContains/maxContains: counts items matching the subschema.+Note: when "contains" is false, minContains defaults to 1, making any array invalid.+-}+validateContains :: [Text] -> Value -> KM.KeyMap Value -> V.Vector Value -> Int -> [ValidationError]+validateContains path root km arr fuel =+    case km KM.!? "contains" of+        Nothing -> []+        Just contains_schema ->+            let matches = V.filter (\item -> null $ validateValue path root contains_schema item fuel) arr+                match_count = V.length matches+                min_contains = case km KM.!? "minContains" of+                    Just (Number n) -> fromMaybe 1 (Sci.toBoundedInteger n)+                    _ -> 1+                max_contains = case km KM.!? "maxContains" of+                    Just (Number n) -> Sci.toBoundedInteger n+                    _ -> Nothing+             in concat+                    [ [ ValidationError path $+                            "Array has "+                                <> T.pack (show match_count)+                                <> " matching items but minContains is "+                                <> T.pack (show min_contains)+                      | match_count < min_contains+                      ]+                    , case max_contains of+                        Just max_cont ->+                            [ ValidationError path $+                                "Array has "+                                    <> T.pack (show match_count)+                                    <> " matching items but maxContains is "+                                    <> T.pack (show max_cont)+                            | match_count > max_cont+                            ]+                        Nothing -> []+                    ]++{- | Validate object constraints (minProperties, maxProperties)+| Validate object property count constraints: minProperties and maxProperties.+-}+validateObjectConstraints :: [Text] -> KM.KeyMap Value -> Value -> [ValidationError]+validateObjectConstraints path km value =+    case value of+        Object obj ->+            let prop_count = KM.size obj+             in concat+                    [ case km KM.!? "minProperties" of+                        Just (Number n)+                            | Just min_props <- Sci.toBoundedInteger n ->+                                [ ValidationError path $+                                    "Object has "+                                        <> T.pack (show prop_count)+                                        <> " properties but minimum is "+                                        <> T.pack (show min_props)+                                | prop_count < min_props+                                ]+                        _ -> []+                    , case km KM.!? "maxProperties" of+                        Just (Number n)+                            | Just max_props <- Sci.toBoundedInteger n ->+                                [ ValidationError path $+                                    "Object has "+                                        <> T.pack (show prop_count)+                                        <> " properties but maximum is "+                                        <> T.pack (show max_props)+                                | prop_count > max_props+                                ]+                        _ -> []+                    ]+        _ -> []++{- | Validate conditional logic (if/then/else)+| Validate conditional keywords: if/then/else.+-}+validateConditional :: [Text] -> Value -> KM.KeyMap Value -> Value -> Int -> [ValidationError]+validateConditional path root km value fuel =+    case km KM.!? "if" of+        Nothing -> []+        Just if_schema ->+            let if_matches = null $ validateValue path root if_schema value fuel+             in if if_matches+                    then case km KM.!? "then" of+                        Just then_schema -> validateValue path root then_schema value fuel+                        Nothing -> []+                    else case km KM.!? "else" of+                        Just else_schema -> validateValue path root else_schema value fuel+                        Nothing -> []++{- | Check if a number is a multiple of another+Exact multipleOf using rationals (avoids floating point error).+| Exact multipleOf check using rationals (denominator must be 1).+-}+isMultipleOf :: Scientific -> Scientific -> Bool+isMultipleOf value divisor =+    let q = toRational value / toRational divisor+     in denominator q == 1++-- Count Unicode code points (not UTF-16 code units)++-- | Count Unicode code points (not UTF-16 code units) for string length.+unicodeLength :: Text -> Int+unicodeLength = T.foldl' (\n _ -> n + 1) 0++-- | Helper to show JSON values as text+showJSON :: Value -> Text+showJSON = T.take 100 . T.pack . show++{- | Resolve a local $ref against the root schema using JSON Pointer+Supports only fragment starting with @#@. External URIs and anchors are not resolved.+| Resolve a local $ref against the root schema using JSON Pointer.+Supports only fragments starting with @#@; external URIs/anchors are not resolved.+-}+resolveRef :: Value -> Text -> Maybe Value+resolveRef root ref_path =+    case T.uncons ref_path of+        Just ('#', rest) ->+            -- empty fragment or pointer+            if T.null rest+                then Just root+                else+                    if T.head rest == '/'+                        then jsonPointer root (T.tail rest)+                        else Nothing -- unsupported non-pointer fragment+        _ -> Nothing -- unsupported non-fragment refs++-- | Evaluate a JSON Pointer (RFC 6901) against a JSON value.+jsonPointer :: Value -> Text -> Maybe Value+jsonPointer v ptr =+    let tokens = map unescapePointer $ T.splitOn "/" ptr+     in go v tokens+  where+    go :: Value -> [Text] -> Maybe Value+    go cur [] = Just cur+    go (Object km) (t : ts) =+        case KM.lookup (K.fromText t) km of+            Just nxt -> go nxt ts+            Nothing -> Nothing+    go (Array arr) (t : ts)+        | T.all isDigit t && not (T.null t) =+            let i = read (T.unpack t) :: Int+             in if i >= 0 && i < V.length arr then go (arr V.! i) ts else Nothing+        | otherwise = Nothing+    go _ _ = Nothing++    unescapePointer :: Text -> Text+    unescapePointer = T.replace "~1" "/" . T.replace "~0" "~"++{- | Validate "unevaluatedProperties":+Forbids or constrains properties that were not covered by properties,+patternProperties, or additionalProperties at the current schema location.+This implementation approximates evaluated sets locally and does not+perform full annotation merging across nested applicators.+-}+validateUnevaluatedProperties :: [Text] -> Value -> KM.KeyMap Value -> Value -> Int -> [ValidationError]+validateUnevaluatedProperties path root km value fuel =+    case (value, km KM.!? "unevaluatedProperties") of+        (Object obj, Just uneval_schema) ->+            let props_schema = km KM.!? "properties"+                pattern_props_schema = km KM.!? "patternProperties"++                props_handled = case props_schema of+                    Just (Object schema_obj) -> [k | (k, _) <- KM.toList schema_obj, KM.member k obj]+                    _ -> []+                patterns_handled = case pattern_props_schema of+                    Just (Object patterns) ->+                        [ obj_key+                        | obj_key <- KM.keys obj+                        , any (\pattern_key -> K.toText obj_key =~ K.toText pattern_key) (KM.keys patterns)+                        ]+                    _ -> []+                extras = [k | k <- KM.keys obj, k `notElem` props_handled, k `notElem` patterns_handled]++                -- If additionalProperties is present, consider extras handled by it+                extras_handled_by_additional = case km KM.!? "additionalProperties" of+                    Just _ -> extras+                    Nothing -> []++                unevaluated_keys = [k | k <- extras, k `notElem` extras_handled_by_additional]++                applyUnevaluated (Bool False) =+                    [ ValidationError path $+                        "Unevaluated property not allowed: " <> K.toText k+                    | k <- unevaluated_keys+                    ]+                applyUnevaluated sch =+                    concat+                        [ case obj KM.!? k of+                            Just v -> validateValue (path <> [K.toText k]) root sch v fuel+                            Nothing -> []+                        | k <- unevaluated_keys+                        ]+             in applyUnevaluated uneval_schema+        _ -> []++{- | Validate "unevaluatedItems":+Applies to array indices not covered by prefixItems, items, or contains When false, any such index is prohibited; when a schema, each such item+is validated against it. This uses local evaluated-set approximation.+-}+validateUnevaluatedItems :: [Text] -> Value -> KM.KeyMap Value -> Value -> Int -> [ValidationError]+validateUnevaluatedItems path root km value fuel =+    case (value, km KM.!? "unevaluatedItems") of+        (Array arr, Just uneval_schema) ->+            let arr_len = V.length arr+                -- prefix items coverage+                prefix_count = case km KM.!? "prefixItems" of+                    Just (Array prefixes) -> min (V.length prefixes) arr_len+                    _ -> 0+                prefix_idx = [0 .. prefix_count - 1]+                -- items coverage for rest if present+                rest_idx = case km KM.!? "items" of+                    Just _ -> [prefix_count .. arr_len - 1]+                    Nothing -> []+                -- contains matched indices (optional)+                contains_idx = case km KM.!? "contains" of+                    Nothing -> []+                    Just sch ->+                        [ i+                        | i <- [0 .. arr_len - 1]+                        , null $ validateValue path root sch (arr V.! i) fuel+                        ]+                evaluated = nub (prefix_idx <> rest_idx <> contains_idx)+                unevaluated = [i | i <- [0 .. arr_len - 1], i `notElem` evaluated]+                applyItems (Bool False) =+                    [ ValidationError path $+                        "Unevaluated item not allowed at index " <> T.pack (show i)+                    | i <- unevaluated+                    ]+                applyItems sch =+                    concat+                        [ validateValue (path <> [T.pack (show i)]) root sch (arr V.! i) fuel+                        | i <- unevaluated+                        ]+             in applyItems uneval_schema+        _ -> []
test/JSONSchema/Spec.hs view
@@ -6,485 +6,708 @@  import Data.Aeson import Data.Aeson.KeyMap qualified as KM-import Data.String (fromString)-import Data.Text (Text)-import Data.Vector (fromList)-import Data.Vector qualified as V-import GHC.Generics (Generic)-import Data.JSON.JSONSchema-import Test.Tasty-import Test.Tasty.HUnit---- Simple product type (record)-data Person = Person-  { name :: Text-  , age :: Int-  }-  deriving (Show, Eq, Generic)--instance ToJSON Person--instance ToJSONSchema Person---- Sum type with non-record and product constructors-data Shape-  = Circle Double-  | Rectangle Double Double-  deriving (Show, Eq, Generic)--instance ToJSON Shape--instance ToJSONSchema Shape---- Sum type with record constructors (tests tagged record encoding)-data Animal-  = Dog {dogName :: Text, dogAge :: Int}-  | Cat {catName :: Text}-  deriving (Show, Eq, Generic)--instance ToJSON Animal--instance ToJSONSchema Animal---- Non-record product with 3 fields (tests prefixItems flattening)-data Triple = Triple Int Text Bool-  deriving (Show, Eq, Generic)--instance ToJSON Triple--instance ToJSONSchema Triple---- Recursive non-record sum type for ToJSON encoding test-data RTree-  = Leaf Int-  | Node RTree RTree-  deriving (Show, Eq, Generic)--instance ToJSON RTree--instance ToJSONSchema RTree--tests :: TestTree-tests =-  testGroup-    "JSON Schema"-    [ validationWorks-    , validationSpecCoverage-    , deriveWorks-    , schemaRecursiveWorks-    ]--validationWorks :: TestTree-validationWorks =-  testGroup-    "Validation Works"-    [ testCase "Valid object with required properties" $ do-        let schema =-              object-                [ "type" .= ("object" :: Text)-                , "properties"-                    .= object-                      [ "name" .= object ["type" .= ("string" :: Text)]-                      , "age" .= object ["type" .= ("integer" :: Text)]-                      ]-                , "required" .= (["name", "age"] :: [Text])-                , "additionalProperties" .= False-                ]-            value = object ["name" .= ("Alice" :: Text), "age" .= (30 :: Int)]-        assertBool "Expected valid JSON for schema" (validateJSONSchema schema value)-    , testCase "Missing required property fails" $ do-        let schema =-              object-                [ "type" .= ("object" :: Text)-                , "properties"-                    .= object-                      [ "name" .= object ["type" .= ("string" :: Text)]-                      , "age" .= object ["type" .= ("integer" :: Text)]-                      ]-                , "required" .= (["name", "age"] :: [Text])-                , "additionalProperties" .= False-                ]-            value = object ["name" .= ("Alice" :: Text)]-        assertBool "Expected invalid JSON for schema" (not $ validateJSONSchema schema value)-    , testCase "Wrong type fails" $ do-        let schema =-              object-                [ "type" .= ("object" :: Text)-                , "properties"-                    .= object-                      [ "name" .= object ["type" .= ("string" :: Text)]-                      , "age" .= object ["type" .= ("integer" :: Text)]-                      ]-                , "required" .= (["name", "age"] :: [Text])-                , "additionalProperties" .= False-                ]-            value = object ["name" .= ("Alice" :: Text), "age" .= ("30" :: Text)]-        assertBool "Expected invalid JSON for schema" (not $ validateJSONSchema schema value)-    , testCase "Additional property rejected when additionalProperties=false" $ do-        let schema =-              object-                [ "type" .= ("object" :: Text)-                , "properties"-                    .= object-                      [ "name" .= object ["type" .= ("string" :: Text)]-                      , "age" .= object ["type" .= ("integer" :: Text)]-                      ]-                , "required" .= (["name", "age"] :: [Text])-                , "additionalProperties" .= False-                ]-            value = object ["name" .= ("Alice" :: Text), "age" .= (30 :: Int), "extra" .= (True :: Bool)]-        assertBool "Expected invalid JSON for schema" (not $ validateJSONSchema schema value)-    , testCase "items=false with no prefixItems forbids any items" $ do-        let schema = object ["type" .= ("array" :: Text), "items" .= False]-        assertBool "[] should validate" (validateJSONSchema schema (Array mempty))-        assertBool "[1] should not validate" (not $ validateJSONSchema schema (toJSON ([1 :: Int] :: [Int])))-    , testCase "items applies only to items after prefixItems" $ do-        let schema =-              object-                [ "type" .= ("array" :: Text)-                , "prefixItems" .= [object ["type" .= ("integer" :: Text)]]-                , "items" .= object ["type" .= ("string" :: Text)]-                ]-            j1 = toJSON ([1 :: Int] :: [Int])-            j2 = Array (fromList [Number 1, String "x"])-            j3 = toJSON ([1 :: Int, 2] :: [Int])-        assertBool "[1] valid (no rest)" (validateJSONSchema schema j1)-        assertBool "[1, \"x\"] valid (rest string)" (validateJSONSchema schema j2)-        assertBool "[1, 2] invalid (rest not string)" (not $ validateJSONSchema schema j3)-    ]---- A comprehensive set of tests covering the 2020-12 validation vocabulary-validationSpecCoverage :: TestTree-validationSpecCoverage =-  testGroup-    "Validation Spec Coverage"-    [ -- type-      testCase "type: integer vs number" $ do-        let sInt = object ["type" .= ("integer" :: Text)]-        assertBool "3 integer" (validateJSONSchema sInt (Number 3))-        assertBool "3.5 not integer" (not $ validateJSONSchema sInt (Number 3.5))-    , testCase "type: array of types" $ do-        let s = object ["type" .= (["string", "null"] :: [Text])]-        assertBool "string ok" (validateJSONSchema s (String "x"))-        assertBool "null ok" (validateJSONSchema s Null)-        assertBool "bool not ok" (not $ validateJSONSchema s (Bool True))-    , -- const, enum-      testCase "const exact match" $ do-        let s = object ["const" .= object ["a" .= (1 :: Int)]]-        assertBool "match" (validateJSONSchema s (object ["a" .= (1 :: Int)]))-        assertBool "no match" (not $ validateJSONSchema s (object ["a" .= (2 :: Int)]))-    , testCase "enum membership" $ do-        let s = object ["enum" .= ([String "a", String "b"] :: [Value])]-        assertBool "a ok" (validateJSONSchema s (String "a"))-        assertBool "c not ok" (not $ validateJSONSchema s (String "c"))-    , -- string: minLength, maxLength, pattern-      testCase "string length counts code points" $ do-        let s = object ["type" .= ("string" :: Text), "minLength" .= (1 :: Int), "maxLength" .= (1 :: Int)]-        assertBool "emoji single code point ok" (validateJSONSchema s (String "😊"))-    , testCase "string pattern" $ do-        let s = object ["type" .= ("string" :: Text), "pattern" .= ("^[a-z]+$" :: Text)]-        assertBool "lowercase ok" (validateJSONSchema s (String "abc"))-        assertBool "uppercase not ok" (not $ validateJSONSchema s (String "Abc"))-    , -- number: minimum/maximum/exclusive*/multipleOf-      testCase "number bounds inclusive/exclusive" $ do-        let s =-              object-                [ "type" .= ("number" :: Text)-                , "minimum" .= (1 :: Int)-                , "exclusiveMaximum" .= (3 :: Int)-                ]-        assertBool "1 ok" (validateJSONSchema s (Number 1))-        assertBool "2.9 ok" (validateJSONSchema s (Number 2.9))-        assertBool "3 not ok" (not $ validateJSONSchema s (Number 3))-    , testCase "minimum respects large integers" $ do-        let s =-              object-                [ "type" .= ("integer" :: Text)-                , "minimum" .= Number 9007199254740993-                ]-        assertBool "just below fails" (not $ validateJSONSchema s (Number 9007199254740992))-        assertBool "threshold passes" (validateJSONSchema s (Number 9007199254740993))-    , testCase "exclusiveMaximum handles high precision decimals" $ do-        let bound = Number 0.12345678901234567890-            s =-              object-                [ "type" .= ("number" :: Text)-                , "exclusiveMaximum" .= bound-                ]-        assertBool "slightly smaller ok" (validateJSONSchema s (Number 0.12345678901234567889))-        assertBool "equal fails" (not $ validateJSONSchema s bound)-        assertBool "larger fails" (not $ validateJSONSchema s (Number 0.12345678901234567891))-    , testCase "multipleOf exact with rational" $ do-        let s = object ["type" .= ("number" :: Text), "multipleOf" .= (0.1 :: Double)]-        assertBool "0.3 ok" (validateJSONSchema s (Number 0.3))-        assertBool "0.31 not ok" (not $ validateJSONSchema s (Number 0.31))-    , -- array: minItems/maxItems/uniqueItems/contains/minContains/maxContains-      testCase "array min/max items" $ do-        let s = object ["type" .= ("array" :: Text), "minItems" .= (1 :: Int), "maxItems" .= (2 :: Int)]-        assertBool "[1] ok" (validateJSONSchema s (toJSON ([1 :: Int] :: [Int])))-        assertBool "[] not ok" (not $ validateJSONSchema s (toJSON ([] :: [Int])))-        assertBool "[1,2,3] not ok" (not $ validateJSONSchema s (toJSON ([1 :: Int, 2, 3] :: [Int])))-    , testCase "uniqueItems deep equality" $ do-        let s = object ["type" .= ("array" :: Text), "uniqueItems" .= True]-        assertBool-          "unique ok"-          (validateJSONSchema s (toJSON ([object ["a" .= (1 :: Int)], object ["a" .= (2 :: Int)]] :: [Value])))-        assertBool-          "duplicate not ok"-          (not $ validateJSONSchema s (toJSON ([object ["a" .= (1 :: Int)], object ["a" .= (1 :: Int)]] :: [Value])))-    , testCase "contains with min/maxContains" $ do-        let s =-              object-                [ "type" .= ("array" :: Text)-                , "contains" .= object ["type" .= ("integer" :: Text)]-                , "minContains" .= (2 :: Int)-                , "maxContains" .= (3 :: Int)-                ]-        assertBool "two ints ok" (validateJSONSchema s (toJSON ([Number 1, String "x", Number 2] :: [Value])))-        assertBool "one int not ok" (not $ validateJSONSchema s (toJSON ([Number 1, String "x"] :: [Value])))-        assertBool "four ints not ok" (not $ validateJSONSchema s (toJSON ([1 :: Int, 2, 3, 4] :: [Int])))-    , testCase "contains=false forbids any matches by minContains default" $ do-        let s = object ["type" .= ("array" :: Text), "contains" .= False]-        assertBool "[] not ok (minContains defaults to 1)" (not $ validateJSONSchema s (toJSON ([] :: [Int])))-        -- default minContains is 1 when contains is present, so any match is impossible-        assertBool "[1] not ok" (not $ validateJSONSchema s (toJSON ([1 :: Int] :: [Int])))-    , testCase "unevaluatedItems=false forbids trailing items not covered by prefix/items" $ do-        let s =-              object-                [ "type" .= ("array" :: Text)-                , "prefixItems" .= toJSON ([object ["type" .= ("integer" :: Text)]] :: [Value])-                , "unevaluatedItems" .= False-                ]-        assertBool "[1] ok" (validateJSONSchema s (toJSON ([1 :: Int] :: [Int])))-        assertBool "[1, \"x\"] not ok" (not $ validateJSONSchema s (toJSON ([Number 1, String "x"] :: [Value])))-    , -- object: required/properties/patternProperties/additionalProperties/propertyNames/min/max props-      testCase "required properties enforced" $ do-        let s =-              object-                [ "type" .= ("object" :: Text)-                , "properties" .= object ["a" .= object ["type" .= ("integer" :: Text)]]-                , "required" .= (["a"] :: [Text])-                ]-        assertBool "present ok" (validateJSONSchema s (object ["a" .= (1 :: Int)]))-        assertBool "missing not ok" (not $ validateJSONSchema s (object []))-    , testCase "patternProperties match" $ do-        let s =-              object-                [ "type" .= ("object" :: Text)-                , "patternProperties" .= object ["^x-" .= object ["type" .= ("string" :: Text)]]-                ]-        assertBool "x-foo ok" (validateJSONSchema s (object ["x-foo" .= ("bar" :: Text)]))-        assertBool "x-foo wrong type" (not $ validateJSONSchema s (object ["x-foo" .= (1 :: Int)]))-    , testCase "additionalProperties schema applies to extras" $ do-        let s =-              object-                [ "type" .= ("object" :: Text)-                , "properties" .= object ["a" .= object ["type" .= ("integer" :: Text)]]-                , "additionalProperties" .= object ["type" .= ("string" :: Text)]-                ]-        assertBool "extra string ok" (validateJSONSchema s (object ["a" .= (1 :: Int), "b" .= ("x" :: Text)]))-        assertBool "extra int not ok" (not $ validateJSONSchema s (object ["a" .= (1 :: Int), "b" .= (2 :: Int)]))-    , testCase "unevaluatedProperties=false forbids extras not covered by props/patterns" $ do-        let s =-              object-                [ "type" .= ("object" :: Text)-                , "properties" .= object ["a" .= object []]-                , "unevaluatedProperties" .= False-                ]-        assertBool "only a ok" (validateJSONSchema s (object ["a" .= (1 :: Int)]))-        assertBool "extra b not ok" (not $ validateJSONSchema s (object ["a" .= (1 :: Int), "b" .= (2 :: Int)]))-    , testCase "local $ref into $defs resolves and validates" $ do-        let s =-              object-                [ "$defs" .= object ["posInt" .= object ["type" .= ("integer" :: Text), "minimum" .= (0 :: Int)]]-                , "type" .= ("object" :: Text)-                , "properties" .= object ["age" .= object ["$ref" .= ("#/$defs/posInt" :: Text)]]-                , "required" .= (["age"] :: [Text])-                ]-        assertBool "age=5 ok" (validateJSONSchema s (object ["age" .= (5 :: Int)]))-        assertBool "age=-1 not ok" (not $ validateJSONSchema s (object ["age" .= (-1 :: Int)]))-    , testCase "unevaluatedItems with contains marks matches as evaluated; remainder must satisfy schema" $ do-        let s =-              object-                [ "type" .= ("array" :: Text)-                , "contains" .= object ["type" .= ("integer" :: Text)]-                , "minContains" .= (1 :: Int)-                , "unevaluatedItems" .= object ["type" .= ("string" :: Text)]-                ]-        assertBool "[1, \"x\", \"y\"] ok" (validateJSONSchema s (toJSON ([Number 1, String "x", String "y"] :: [Value])))-        assertBool-          "[1, false] not ok (false not string)"-          (not $ validateJSONSchema s (toJSON ([Number 1, Bool False] :: [Value])))-    , testCase "propertyNames constraint" $ do-        let s = object ["type" .= ("object" :: Text), "propertyNames" .= object ["pattern" .= ("^[a-z]+$" :: Text)]]-        assertBool "lowercase key ok" (validateJSONSchema s (object ["abc" .= (1 :: Int)]))-        assertBool "uppercase key not ok" (not $ validateJSONSchema s (object ["Abc" .= (1 :: Int)]))-    , testCase "min/max properties" $ do-        let s = object ["type" .= ("object" :: Text), "minProperties" .= (1 :: Int), "maxProperties" .= (2 :: Int)]-        assertBool "one ok" (validateJSONSchema s (object ["a" .= (1 :: Int)]))-        assertBool "none not ok" (not $ validateJSONSchema s (object []))-        assertBool-          "three not ok"-          (not $ validateJSONSchema s (object ["a" .= (1 :: Int), "b" .= (2 :: Int), "c" .= (3 :: Int)]))-    , -- dependentRequired / dependentSchemas-      testCase "dependentRequired enforces required when key present" $ do-        let s =-              object-                [ "type" .= ("object" :: Text)-                , "dependentRequired" .= object ["credit_card" .= (["billing_address"] :: [Text])]-                ]-        assertBool "missing credit_card ok" (validateJSONSchema s (object ["name" .= ("Bob" :: Text)]))-        assertBool "present without dependency not ok" (not $ validateJSONSchema s (object ["credit_card" .= ("123" :: Text)]))-        assertBool-          "present with dependency ok"-          (validateJSONSchema s (object ["credit_card" .= ("123" :: Text), "billing_address" .= ("A" :: Text)]))-    , testCase "dependentSchemas applies object schema when key present" $ do-        let dep = object ["properties" .= object ["a" .= object ["const" .= (1 :: Int)]], "required" .= (["a"] :: [Text])]-            s = object ["type" .= ("object" :: Text), "dependentSchemas" .= object ["flag" .= dep]]-        assertBool "no flag ok" (validateJSONSchema s (object ["a" .= (2 :: Int)]))-        assertBool "flag requires a=1" (validateJSONSchema s (object ["flag" .= True, "a" .= (1 :: Int)]))-        assertBool "flag with wrong a not ok" (not $ validateJSONSchema s (object ["flag" .= True, "a" .= (2 :: Int)]))-    , -- combinators-      testCase "anyOf passes if any schema matches" $ do-        let s = object ["anyOf" .= ([object ["type" .= ("string" :: Text)], object ["type" .= ("integer" :: Text)]] :: [Value])]-        assertBool "int ok" (validateJSONSchema s (Number 3))-    , testCase "oneOf exactly one" $ do-        let s = object ["oneOf" .= ([object ["type" .= ("number" :: Text)], object ["minimum" .= (0 :: Int)]] :: [Value])]-        -- number 3 matches both type=number and minimum=0; should fail-        assertBool "matches both not ok" (not $ validateJSONSchema s (Number 3))-    , testCase "allOf accumulates constraints" $ do-        let s = object ["allOf" .= ([object ["type" .= ("number" :: Text)], object ["maximum" .= (5 :: Int)]] :: [Value])]-        assertBool "6 not ok" (not $ validateJSONSchema s (Number 6))-    , testCase "not inverts" $ do-        let s = object ["not" .= object ["type" .= ("null" :: Text)]]-        assertBool "null not ok" (not $ validateJSONSchema s Null)-    , -- conditionals if/then/else-      testCase "if/then/else branching" $ do-        let s =-              object-                [ "if" .= object ["properties" .= object ["a" .= object ["const" .= True]]]-                , "then" .= object ["required" .= (["b"] :: [Text])]-                , "else" .= object ["required" .= (["c"] :: [Text])]-                ]-        assertBool "a=true requires b" (not $ validateJSONSchema s (object ["a" .= True]))-        assertBool "a=false requires c" (not $ validateJSONSchema s (object ["a" .= False]))-    , -- format and content keywords are annotations in 2020-12: do not assert by default-      testCase "format does not assert by default" $ do-        let s = object ["type" .= ("string" :: Text), "format" .= ("email" :: Text)]-        assertBool "non-email still valid (annotation only)" (validateJSONSchema s (String "not-an-email"))-    , testCase "content keywords are annotations only" $ do-        let s =-              object-                [ "type" .= ("string" :: Text)-                , "contentEncoding" .= ("base64" :: Text)-                , "contentMediaType" .= ("image/png" :: Text)-                ]-        -- we do not auto-decode/validate: annotation only-        assertBool "arbitrary string still valid" (validateJSONSchema s (String "@@not-base64@@"))-    ]--deriveWorks :: TestTree-deriveWorks =-  testGroup-    "Derive Works (Aeson JSON validates against derived schema)"-    [ testCase "Person record encodes and validates" $ do-        let schema = toJSONSchema (Proxy :: Proxy Person)-            jPerson = toJSON (Person "Bob" 42)-        assertBool "Person JSON should validate against derived schema" (validateJSONSchema schema jPerson)-        assertBool "Person schema should reject missing fields" (not $ validateJSONSchema schema (object []))-    , testCase "Shape sum (non-record) constructors validate" $ do-        let schema = toJSONSchema (Proxy :: Proxy Shape)-            j1 = toJSON (Circle 1.5)-            j2 = toJSON (Rectangle 2.3 4.5)-        assertBool "Circle JSON should validate" (validateJSONSchema schema j1)-        assertBool "Rectangle JSON should validate" (validateJSONSchema schema j2)-    , testCase "Animal sum (record) constructors validate" $ do-        let schema = toJSONSchema (Proxy :: Proxy Animal)-            j1 = toJSON (Dog "Fido" 5)-            j2 = toJSON (Cat "Whiskers")-        assertBool "Dog JSON should validate" (validateJSONSchema schema j1)-        assertBool "Cat JSON should validate" (validateJSONSchema schema j2)-        assertBool "Dog schema should reject missing required fields"-          (not $-            validateJSONSchema schema-              (object ["tag" .= ("Dog" :: Text)])-          )-        assertBool "Dog schema should reject missing tag"-          (not $-            validateJSONSchema schema-              (object ["dogName" .= ("Fido" :: Text), "dogAge" .= (5 :: Int)])-          )-    , testCase "Triple non-record product flattens and validates" $ do-        let schema = toJSONSchema (Proxy :: Proxy Triple)-            jTriple = toJSON (Triple 1 "a" True)-        assertBool "Triple JSON should validate" (validateJSONSchema schema jTriple)-    , testCase "Maybe and list instances validate via derived schema" $ do-        let maybeSchema = toJSONSchema (Proxy :: Proxy (Maybe Int))-            listSchema = toJSONSchema (Proxy :: Proxy [Text])-        assertBool "Just Int validates" (validateJSONSchema maybeSchema (toJSON (Just (3 :: Int))))-        assertBool "Nothing validates" (validateJSONSchema maybeSchema Null)-        assertBool "[Text] validates" (validateJSONSchema listSchema (toJSON (["x", "y"] :: [Text])))-    , testCase "Either schema enforces discriminator semantics" $ do-        let schema = toJSONSchema (Proxy :: Proxy (Either Int Text))-            leftVal = toJSON (Left (3 :: Int) :: Either Int Text)-            rightVal = toJSON (Right ("hi" :: Text) :: Either Int Text)-            emptyObj = object []-            bothObj =-              object-                [ "Left" .= (1 :: Int)-                , "Right" .= ("oops" :: Text)-                ]-        assertBool "Left JSON should validate" (validateJSONSchema schema leftVal)-        assertBool "Right JSON should validate" (validateJSONSchema schema rightVal)-        assertBool "Empty object should be rejected" (not $ validateJSONSchema schema emptyObj)-        assertBool "Object with both constructors should be rejected" (not $ validateJSONSchema schema bothObj)-    ]--schemaRecursiveWorks :: TestTree-schemaRecursiveWorks =-  testGroup-    "ToJSONSchema Works (recursive type emits $ref)"-    [ testCase "Derived schema uses $defs and $ref for recursion" $ do-        let s = toJSONSchema (Proxy :: Proxy RTree)-        -- Top-level must be an object with $defs (and ideally $ref)-        case s of-          Object km -> do-            -- \$defs contains RTree definition, and its Node constructor contains $ref back-            case km KM.!? "$defs" of-              Just (Object defs) -> case defs KM.!? fromString "RTree" of-                Just (Object rtreeDef) -> do-                  -- look for 'anyOf' containing Node/Leaf. In Node path, ensure children $ref-                  case rtreeDef KM.!? fromString "anyOf" of-                    Just (Array alts) -> do-                      -- At least one alternative should be the tagged Node object with contents array-                      let hasRefBack =-                            V.any-                              ( \v -> case v of-                                  Object km2 -> case km2 KM.!? fromString "properties" of-                                    Just (Object props) -> case props KM.!? fromString "contents" of-                                      Just (Object cont) -> case cont KM.!? fromString "prefixItems" of-                                        Just (Array pfi) ->-                                          V.all-                                            ( \piV -> case piV of-                                                Object o -> case o KM.!? fromString "$ref" of-                                                  Just (String r) -> r == "#/$defs/RTree"-                                                  _ -> False-                                                _ -> False-                                            )-                                            pfi-                                        _ -> False-                                      _ -> False-                                    _ -> False-                                  _ -> False-                              )-                              alts-                      assertBool "Node contents references back to RTree via $ref" hasRefBack-                    _ -> assertFailure "RTree def missing anyOf"-                _ -> assertFailure "$defs.RTree missing"-              _ -> assertFailure "$defs missing"-          _ -> assertFailure "Top-level schema should be object"-    , testCase "Recursive value validates against derived schema" $ do-        let s = toJSONSchema (Proxy :: Proxy RTree)-            j = toJSON (Node (Leaf 1) (Leaf 2))-        assertBool "recursive value should validate" (validateJSONSchema s j)+import Data.JSON.JSONSchema+import Data.String (fromString)+import Data.Text (Text)+import Data.Vector (fromList)+import Data.Vector qualified as V+import GHC.Generics (Generic)+import Test.Tasty+import Test.Tasty.HUnit++-- Simple product type (record)+data Person = Person+    { name :: Text+    , age :: Int+    }+    deriving (Show, Eq, Generic)++instance ToJSON Person++instance ToJSONSchema Person++-- Sum type with non-record and product constructors+data Shape+    = Circle Double+    | Rectangle Double Double+    deriving (Show, Eq, Generic)++instance ToJSON Shape++instance ToJSONSchema Shape++-- Sum type with record constructors (tests tagged record encoding)+data Animal+    = Dog {dogName :: Text, dogAge :: Int}+    | Cat {catName :: Text}+    deriving (Show, Eq, Generic)++instance ToJSON Animal++instance ToJSONSchema Animal++-- Non-record product with 3 fields (tests prefixItems flattening)+data Triple = Triple Int Text Bool+    deriving (Show, Eq, Generic)++instance ToJSON Triple++instance ToJSONSchema Triple++-- Recursive non-record sum type for ToJSON encoding test+data RTree+    = Leaf Int+    | Node RTree RTree+    deriving (Show, Eq, Generic)++instance ToJSON RTree++instance ToJSONSchema RTree++tests :: TestTree+tests =+    testGroup+        "JSON Schema"+        [ validationWorks+        , validationSpecCoverage+        , deriveWorks+        , schemaRecursiveWorks+        , valueDirectedWorks+        ]++validationWorks :: TestTree+validationWorks =+    testGroup+        "Validation Works"+        [ testCase "Valid object with required properties" $ do+            let schema =+                    object+                        [ "type" .= ("object" :: Text)+                        , "properties"+                            .= object+                                [ "name" .= object ["type" .= ("string" :: Text)]+                                , "age" .= object ["type" .= ("integer" :: Text)]+                                ]+                        , "required" .= (["name", "age"] :: [Text])+                        , "additionalProperties" .= False+                        ]+                value = object ["name" .= ("Alice" :: Text), "age" .= (30 :: Int)]+            assertBool "Expected valid JSON for schema" (validateJSONSchema schema value)+        , testCase "Missing required property fails" $ do+            let schema =+                    object+                        [ "type" .= ("object" :: Text)+                        , "properties"+                            .= object+                                [ "name" .= object ["type" .= ("string" :: Text)]+                                , "age" .= object ["type" .= ("integer" :: Text)]+                                ]+                        , "required" .= (["name", "age"] :: [Text])+                        , "additionalProperties" .= False+                        ]+                value = object ["name" .= ("Alice" :: Text)]+            assertBool "Expected invalid JSON for schema" (not $ validateJSONSchema schema value)+        , testCase "Wrong type fails" $ do+            let schema =+                    object+                        [ "type" .= ("object" :: Text)+                        , "properties"+                            .= object+                                [ "name" .= object ["type" .= ("string" :: Text)]+                                , "age" .= object ["type" .= ("integer" :: Text)]+                                ]+                        , "required" .= (["name", "age"] :: [Text])+                        , "additionalProperties" .= False+                        ]+                value = object ["name" .= ("Alice" :: Text), "age" .= ("30" :: Text)]+            assertBool "Expected invalid JSON for schema" (not $ validateJSONSchema schema value)+        , testCase "Additional property rejected when additionalProperties=false" $ do+            let schema =+                    object+                        [ "type" .= ("object" :: Text)+                        , "properties"+                            .= object+                                [ "name" .= object ["type" .= ("string" :: Text)]+                                , "age" .= object ["type" .= ("integer" :: Text)]+                                ]+                        , "required" .= (["name", "age"] :: [Text])+                        , "additionalProperties" .= False+                        ]+                value = object ["name" .= ("Alice" :: Text), "age" .= (30 :: Int), "extra" .= (True :: Bool)]+            assertBool "Expected invalid JSON for schema" (not $ validateJSONSchema schema value)+        , testCase "items=false with no prefixItems forbids any items" $ do+            let schema = object ["type" .= ("array" :: Text), "items" .= False]+            assertBool "[] should validate" (validateJSONSchema schema (Array mempty))+            assertBool "[1] should not validate" (not $ validateJSONSchema schema (toJSON ([1 :: Int] :: [Int])))+        , testCase "items applies only to items after prefixItems" $ do+            let schema =+                    object+                        [ "type" .= ("array" :: Text)+                        , "prefixItems" .= [object ["type" .= ("integer" :: Text)]]+                        , "items" .= object ["type" .= ("string" :: Text)]+                        ]+                j1 = toJSON ([1 :: Int] :: [Int])+                j2 = Array (fromList [Number 1, String "x"])+                j3 = toJSON ([1 :: Int, 2] :: [Int])+            assertBool "[1] valid (no rest)" (validateJSONSchema schema j1)+            assertBool "[1, \"x\"] valid (rest string)" (validateJSONSchema schema j2)+            assertBool "[1, 2] invalid (rest not string)" (not $ validateJSONSchema schema j3)+        , testCase "detailed-error API is reachable via the umbrella (#1)" $ do+            let schema = object ["type" .= ("integer" :: Text)]+            assertBool "validateWithErrors reports a violation" (not (null (validateWithErrors schema (String "x"))))+            case validate schema (String "x") of+                Left (ValidationError _ _ : _) -> pure ()+                Left [] -> assertFailure "expected at least one ValidationError"+                Right () -> assertFailure "expected validation to fail"+        ]++-- A comprehensive set of tests covering the 2020-12 validation vocabulary+validationSpecCoverage :: TestTree+validationSpecCoverage =+    testGroup+        "Validation Spec Coverage"+        [ -- type+          testCase "type: integer vs number" $ do+            let sInt = object ["type" .= ("integer" :: Text)]+            assertBool "3 integer" (validateJSONSchema sInt (Number 3))+            assertBool "3.5 not integer" (not $ validateJSONSchema sInt (Number 3.5))+        , testCase "type: array of types" $ do+            let s = object ["type" .= (["string", "null"] :: [Text])]+            assertBool "string ok" (validateJSONSchema s (String "x"))+            assertBool "null ok" (validateJSONSchema s Null)+            assertBool "bool not ok" (not $ validateJSONSchema s (Bool True))+        , -- const, enum+          testCase "const exact match" $ do+            let s = object ["const" .= object ["a" .= (1 :: Int)]]+            assertBool "match" (validateJSONSchema s (object ["a" .= (1 :: Int)]))+            assertBool "no match" (not $ validateJSONSchema s (object ["a" .= (2 :: Int)]))+        , testCase "enum membership" $ do+            let s = object ["enum" .= ([String "a", String "b"] :: [Value])]+            assertBool "a ok" (validateJSONSchema s (String "a"))+            assertBool "c not ok" (not $ validateJSONSchema s (String "c"))+        , -- string: minLength, maxLength, pattern+          testCase "string length counts code points" $ do+            let s = object ["type" .= ("string" :: Text), "minLength" .= (1 :: Int), "maxLength" .= (1 :: Int)]+            assertBool "emoji single code point ok" (validateJSONSchema s (String "😊"))+        , testCase "string pattern" $ do+            let s = object ["type" .= ("string" :: Text), "pattern" .= ("^[a-z]+$" :: Text)]+            assertBool "lowercase ok" (validateJSONSchema s (String "abc"))+            assertBool "uppercase not ok" (not $ validateJSONSchema s (String "Abc"))+        , -- number: minimum/maximum/exclusive*/multipleOf+          testCase "number bounds inclusive/exclusive" $ do+            let s =+                    object+                        [ "type" .= ("number" :: Text)+                        , "minimum" .= (1 :: Int)+                        , "exclusiveMaximum" .= (3 :: Int)+                        ]+            assertBool "1 ok" (validateJSONSchema s (Number 1))+            assertBool "2.9 ok" (validateJSONSchema s (Number 2.9))+            assertBool "3 not ok" (not $ validateJSONSchema s (Number 3))+        , testCase "minimum respects large integers" $ do+            let s =+                    object+                        [ "type" .= ("integer" :: Text)+                        , "minimum" .= Number 9007199254740993+                        ]+            assertBool "just below fails" (not $ validateJSONSchema s (Number 9007199254740992))+            assertBool "threshold passes" (validateJSONSchema s (Number 9007199254740993))+        , testCase "exclusiveMaximum handles high precision decimals" $ do+            let bound = Number 0.12345678901234567890+                s =+                    object+                        [ "type" .= ("number" :: Text)+                        , "exclusiveMaximum" .= bound+                        ]+            assertBool "slightly smaller ok" (validateJSONSchema s (Number 0.12345678901234567889))+            assertBool "equal fails" (not $ validateJSONSchema s bound)+            assertBool "larger fails" (not $ validateJSONSchema s (Number 0.12345678901234567891))+        , testCase "multipleOf exact with rational" $ do+            let s = object ["type" .= ("number" :: Text), "multipleOf" .= (0.1 :: Double)]+            assertBool "0.3 ok" (validateJSONSchema s (Number 0.3))+            assertBool "0.31 not ok" (not $ validateJSONSchema s (Number 0.31))+        , -- array: minItems/maxItems/uniqueItems/contains/minContains/maxContains+          testCase "array min/max items" $ do+            let s = object ["type" .= ("array" :: Text), "minItems" .= (1 :: Int), "maxItems" .= (2 :: Int)]+            assertBool "[1] ok" (validateJSONSchema s (toJSON ([1 :: Int] :: [Int])))+            assertBool "[] not ok" (not $ validateJSONSchema s (toJSON ([] :: [Int])))+            assertBool "[1,2,3] not ok" (not $ validateJSONSchema s (toJSON ([1 :: Int, 2, 3] :: [Int])))+        , testCase "uniqueItems deep equality" $ do+            let s = object ["type" .= ("array" :: Text), "uniqueItems" .= True]+            assertBool+                "unique ok"+                (validateJSONSchema s (toJSON ([object ["a" .= (1 :: Int)], object ["a" .= (2 :: Int)]] :: [Value])))+            assertBool+                "duplicate not ok"+                (not $ validateJSONSchema s (toJSON ([object ["a" .= (1 :: Int)], object ["a" .= (1 :: Int)]] :: [Value])))+        , testCase "contains with min/maxContains" $ do+            let s =+                    object+                        [ "type" .= ("array" :: Text)+                        , "contains" .= object ["type" .= ("integer" :: Text)]+                        , "minContains" .= (2 :: Int)+                        , "maxContains" .= (3 :: Int)+                        ]+            assertBool "two ints ok" (validateJSONSchema s (toJSON ([Number 1, String "x", Number 2] :: [Value])))+            assertBool "one int not ok" (not $ validateJSONSchema s (toJSON ([Number 1, String "x"] :: [Value])))+            assertBool "four ints not ok" (not $ validateJSONSchema s (toJSON ([1 :: Int, 2, 3, 4] :: [Int])))+        , testCase "contains=false forbids any matches by minContains default" $ do+            let s = object ["type" .= ("array" :: Text), "contains" .= False]+            assertBool "[] not ok (minContains defaults to 1)" (not $ validateJSONSchema s (toJSON ([] :: [Int])))+            -- default minContains is 1 when contains is present, so any match is impossible+            assertBool "[1] not ok" (not $ validateJSONSchema s (toJSON ([1 :: Int] :: [Int])))+        , testCase "unevaluatedItems=false forbids trailing items not covered by prefix/items" $ do+            let s =+                    object+                        [ "type" .= ("array" :: Text)+                        , "prefixItems" .= toJSON ([object ["type" .= ("integer" :: Text)]] :: [Value])+                        , "unevaluatedItems" .= False+                        ]+            assertBool "[1] ok" (validateJSONSchema s (toJSON ([1 :: Int] :: [Int])))+            assertBool "[1, \"x\"] not ok" (not $ validateJSONSchema s (toJSON ([Number 1, String "x"] :: [Value])))+        , -- object: required/properties/patternProperties/additionalProperties/propertyNames/min/max props+          testCase "required properties enforced" $ do+            let s =+                    object+                        [ "type" .= ("object" :: Text)+                        , "properties" .= object ["a" .= object ["type" .= ("integer" :: Text)]]+                        , "required" .= (["a"] :: [Text])+                        ]+            assertBool "present ok" (validateJSONSchema s (object ["a" .= (1 :: Int)]))+            assertBool "missing not ok" (not $ validateJSONSchema s (object []))+        , testCase "patternProperties match" $ do+            let s =+                    object+                        [ "type" .= ("object" :: Text)+                        , "patternProperties" .= object ["^x-" .= object ["type" .= ("string" :: Text)]]+                        ]+            assertBool "x-foo ok" (validateJSONSchema s (object ["x-foo" .= ("bar" :: Text)]))+            assertBool "x-foo wrong type" (not $ validateJSONSchema s (object ["x-foo" .= (1 :: Int)]))+        , testCase "additionalProperties schema applies to extras" $ do+            let s =+                    object+                        [ "type" .= ("object" :: Text)+                        , "properties" .= object ["a" .= object ["type" .= ("integer" :: Text)]]+                        , "additionalProperties" .= object ["type" .= ("string" :: Text)]+                        ]+            assertBool "extra string ok" (validateJSONSchema s (object ["a" .= (1 :: Int), "b" .= ("x" :: Text)]))+            assertBool "extra int not ok" (not $ validateJSONSchema s (object ["a" .= (1 :: Int), "b" .= (2 :: Int)]))+        , testCase "unevaluatedProperties=false forbids extras not covered by props/patterns" $ do+            let s =+                    object+                        [ "type" .= ("object" :: Text)+                        , "properties" .= object ["a" .= object []]+                        , "unevaluatedProperties" .= False+                        ]+            assertBool "only a ok" (validateJSONSchema s (object ["a" .= (1 :: Int)]))+            assertBool "extra b not ok" (not $ validateJSONSchema s (object ["a" .= (1 :: Int), "b" .= (2 :: Int)]))+        , testCase "local $ref into $defs resolves and validates" $ do+            let s =+                    object+                        [ "$defs" .= object ["posInt" .= object ["type" .= ("integer" :: Text), "minimum" .= (0 :: Int)]]+                        , "type" .= ("object" :: Text)+                        , "properties" .= object ["age" .= object ["$ref" .= ("#/$defs/posInt" :: Text)]]+                        , "required" .= (["age"] :: [Text])+                        ]+            assertBool "age=5 ok" (validateJSONSchema s (object ["age" .= (5 :: Int)]))+            assertBool "age=-1 not ok" (not $ validateJSONSchema s (object ["age" .= (-1 :: Int)]))+        , testCase "unevaluatedItems with contains marks matches as evaluated; remainder must satisfy schema" $ do+            let s =+                    object+                        [ "type" .= ("array" :: Text)+                        , "contains" .= object ["type" .= ("integer" :: Text)]+                        , "minContains" .= (1 :: Int)+                        , "unevaluatedItems" .= object ["type" .= ("string" :: Text)]+                        ]+            assertBool "[1, \"x\", \"y\"] ok" (validateJSONSchema s (toJSON ([Number 1, String "x", String "y"] :: [Value])))+            assertBool+                "[1, false] not ok (false not string)"+                (not $ validateJSONSchema s (toJSON ([Number 1, Bool False] :: [Value])))+        , testCase "propertyNames constraint" $ do+            let s = object ["type" .= ("object" :: Text), "propertyNames" .= object ["pattern" .= ("^[a-z]+$" :: Text)]]+            assertBool "lowercase key ok" (validateJSONSchema s (object ["abc" .= (1 :: Int)]))+            assertBool "uppercase key not ok" (not $ validateJSONSchema s (object ["Abc" .= (1 :: Int)]))+        , testCase "min/max properties" $ do+            let s = object ["type" .= ("object" :: Text), "minProperties" .= (1 :: Int), "maxProperties" .= (2 :: Int)]+            assertBool "one ok" (validateJSONSchema s (object ["a" .= (1 :: Int)]))+            assertBool "none not ok" (not $ validateJSONSchema s (object []))+            assertBool+                "three not ok"+                (not $ validateJSONSchema s (object ["a" .= (1 :: Int), "b" .= (2 :: Int), "c" .= (3 :: Int)]))+        , -- dependentRequired / dependentSchemas+          testCase "dependentRequired enforces required when key present" $ do+            let s =+                    object+                        [ "type" .= ("object" :: Text)+                        , "dependentRequired" .= object ["credit_card" .= (["billing_address"] :: [Text])]+                        ]+            assertBool "missing credit_card ok" (validateJSONSchema s (object ["name" .= ("Bob" :: Text)]))+            assertBool "present without dependency not ok" (not $ validateJSONSchema s (object ["credit_card" .= ("123" :: Text)]))+            assertBool+                "present with dependency ok"+                (validateJSONSchema s (object ["credit_card" .= ("123" :: Text), "billing_address" .= ("A" :: Text)]))+        , testCase "dependentSchemas applies object schema when key present" $ do+            let dep = object ["properties" .= object ["a" .= object ["const" .= (1 :: Int)]], "required" .= (["a"] :: [Text])]+                s = object ["type" .= ("object" :: Text), "dependentSchemas" .= object ["flag" .= dep]]+            assertBool "no flag ok" (validateJSONSchema s (object ["a" .= (2 :: Int)]))+            assertBool "flag requires a=1" (validateJSONSchema s (object ["flag" .= True, "a" .= (1 :: Int)]))+            assertBool "flag with wrong a not ok" (not $ validateJSONSchema s (object ["flag" .= True, "a" .= (2 :: Int)]))+        , -- combinators+          testCase "anyOf passes if any schema matches" $ do+            let s = object ["anyOf" .= ([object ["type" .= ("string" :: Text)], object ["type" .= ("integer" :: Text)]] :: [Value])]+            assertBool "int ok" (validateJSONSchema s (Number 3))+        , testCase "oneOf exactly one" $ do+            let s = object ["oneOf" .= ([object ["type" .= ("number" :: Text)], object ["minimum" .= (0 :: Int)]] :: [Value])]+            -- number 3 matches both type=number and minimum=0; should fail+            assertBool "matches both not ok" (not $ validateJSONSchema s (Number 3))+        , testCase "allOf accumulates constraints" $ do+            let s = object ["allOf" .= ([object ["type" .= ("number" :: Text)], object ["maximum" .= (5 :: Int)]] :: [Value])]+            assertBool "6 not ok" (not $ validateJSONSchema s (Number 6))+        , testCase "not inverts" $ do+            let s = object ["not" .= object ["type" .= ("null" :: Text)]]+            assertBool "null not ok" (not $ validateJSONSchema s Null)+        , -- conditionals if/then/else+          testCase "if/then/else branching" $ do+            let s =+                    object+                        [ "if" .= object ["properties" .= object ["a" .= object ["const" .= True]]]+                        , "then" .= object ["required" .= (["b"] :: [Text])]+                        , "else" .= object ["required" .= (["c"] :: [Text])]+                        ]+            assertBool "a=true requires b" (not $ validateJSONSchema s (object ["a" .= True]))+            assertBool "a=false requires c" (not $ validateJSONSchema s (object ["a" .= False]))+        , -- format and content keywords are annotations in 2020-12: do not assert by default+          testCase "format does not assert by default" $ do+            let s = object ["type" .= ("string" :: Text), "format" .= ("email" :: Text)]+            assertBool "non-email still valid (annotation only)" (validateJSONSchema s (String "not-an-email"))+        , testCase "content keywords are annotations only" $ do+            let s =+                    object+                        [ "type" .= ("string" :: Text)+                        , "contentEncoding" .= ("base64" :: Text)+                        , "contentMediaType" .= ("image/png" :: Text)+                        ]+            -- we do not auto-decode/validate: annotation only+            assertBool "arbitrary string still valid" (validateJSONSchema s (String "@@not-base64@@"))+        ]++deriveWorks :: TestTree+deriveWorks =+    testGroup+        "Derive Works (Aeson JSON validates against derived schema)"+        [ testCase "Person record encodes and validates" $ do+            let schema = toJSONSchema (Proxy :: Proxy Person)+                jPerson = toJSON (Person "Bob" 42)+            assertBool "Person JSON should validate against derived schema" (validateJSONSchema schema jPerson)+            assertBool "Person schema should reject missing fields" (not $ validateJSONSchema schema (object []))+        , testCase "Shape sum (non-record) constructors validate" $ do+            let schema = toJSONSchema (Proxy :: Proxy Shape)+                j1 = toJSON (Circle 1.5)+                j2 = toJSON (Rectangle 2.3 4.5)+            assertBool "Circle JSON should validate" (validateJSONSchema schema j1)+            assertBool "Rectangle JSON should validate" (validateJSONSchema schema j2)+        , testCase "Animal sum (record) constructors validate" $ do+            let schema = toJSONSchema (Proxy :: Proxy Animal)+                j1 = toJSON (Dog "Fido" 5)+                j2 = toJSON (Cat "Whiskers")+            assertBool "Dog JSON should validate" (validateJSONSchema schema j1)+            assertBool "Cat JSON should validate" (validateJSONSchema schema j2)+            assertBool+                "Dog schema should reject missing required fields"+                ( not $+                    validateJSONSchema+                        schema+                        (object ["tag" .= ("Dog" :: Text)])+                )+            assertBool+                "Dog schema should reject missing tag"+                ( not $+                    validateJSONSchema+                        schema+                        (object ["dogName" .= ("Fido" :: Text), "dogAge" .= (5 :: Int)])+                )+        , testCase "Triple non-record product flattens and validates" $ do+            let schema = toJSONSchema (Proxy :: Proxy Triple)+                jTriple = toJSON (Triple 1 "a" True)+            assertBool "Triple JSON should validate" (validateJSONSchema schema jTriple)+            -- The tuple length is pinned: a short array must be rejected.+            assertBool+                "short array rejected"+                (not (validateJSONSchema schema (Array (fromList [Number 1, String "a"]))))+        , testCase "Maybe and list instances validate via derived schema" $ do+            let maybeSchema = toJSONSchema (Proxy :: Proxy (Maybe Int))+                listSchema = toJSONSchema (Proxy :: Proxy [Text])+            assertBool "Just Int validates" (validateJSONSchema maybeSchema (toJSON (Just (3 :: Int))))+            assertBool "Nothing validates" (validateJSONSchema maybeSchema Null)+            assertBool "[Text] validates" (validateJSONSchema listSchema (toJSON (["x", "y"] :: [Text])))+        , testCase "Either schema enforces discriminator semantics" $ do+            let schema = toJSONSchema (Proxy :: Proxy (Either Int Text))+                leftVal = toJSON (Left (3 :: Int) :: Either Int Text)+                rightVal = toJSON (Right ("hi" :: Text) :: Either Int Text)+                emptyObj = object []+                bothObj =+                    object+                        [ "Left" .= (1 :: Int)+                        , "Right" .= ("oops" :: Text)+                        ]+            assertBool "Left JSON should validate" (validateJSONSchema schema leftVal)+            assertBool "Right JSON should validate" (validateJSONSchema schema rightVal)+            assertBool "Empty object should be rejected" (not $ validateJSONSchema schema emptyObj)+            assertBool "Object with both constructors should be rejected" (not $ validateJSONSchema schema bothObj)+        ]++schemaRecursiveWorks :: TestTree+schemaRecursiveWorks =+    testGroup+        "ToJSONSchema Works (recursive type emits $ref)"+        [ testCase "Derived schema uses $defs and $ref for recursion" $ do+            let s = toJSONSchema (Proxy :: Proxy RTree)+            -- Top-level must be an object with $defs (and ideally $ref)+            case s of+                Object km -> do+                    -- \$defs contains RTree definition, and its Node constructor contains $ref back+                    case km KM.!? "$defs" of+                        Just (Object defs) -> case defs KM.!? fromString "RTree" of+                            Just (Object rtreeDef) -> do+                                -- look for 'anyOf' containing Node/Leaf. In Node path, ensure children $ref+                                case rtreeDef KM.!? fromString "anyOf" of+                                    Just (Array alts) -> do+                                        -- At least one alternative should be the tagged Node object with contents array+                                        let hasRefBack =+                                                V.any+                                                    ( \v -> case v of+                                                        Object km2 -> case km2 KM.!? fromString "properties" of+                                                            Just (Object props) -> case props KM.!? fromString "contents" of+                                                                Just (Object cont) -> case cont KM.!? fromString "prefixItems" of+                                                                    Just (Array pfi) ->+                                                                        V.all+                                                                            ( \piV -> case piV of+                                                                                Object o -> case o KM.!? fromString "$ref" of+                                                                                    Just (String r) -> r == "#/$defs/RTree"+                                                                                    _ -> False+                                                                                _ -> False+                                                                            )+                                                                            pfi+                                                                    _ -> False+                                                                _ -> False+                                                            _ -> False+                                                        _ -> False+                                                    )+                                                    alts+                                        assertBool "Node contents references back to RTree via $ref" hasRefBack+                                    _ -> assertFailure "RTree def missing anyOf"+                            _ -> assertFailure "$defs.RTree missing"+                        _ -> assertFailure "$defs missing"+                _ -> assertFailure "Top-level schema should be object"+        , testCase "Recursive value validates against derived schema" $ do+            let s = toJSONSchema (Proxy :: Proxy RTree)+                j = toJSON (Node (Leaf 1) (Leaf 2))+            assertBool "recursive value should validate" (validateJSONSchema s j)+        ]++-- A document dogfooded by the generate-then-validate tests: a root object that+-- references a named, patterned $def and carries a required + an optional field.+sampleDoc :: SchemaDocument+sampleDoc =+    SchemaDocument+        { schemaDefs = [("Code", SString (Just "^[a-z]+$"))]+        , schemaRoot =+            SObject+                [ Field "code" True (SRef "Code")+                , Field "count" False SInteger+                ]+                APForbidden+        }++sampleSchema :: Value+sampleSchema = schemaDocumentToValue sampleDoc++{- | Value-directed construction: render with 'schemaToValue' /+'schemaDocumentToValue' and dogfood the result through the validator.+-}+valueDirectedWorks :: TestTree+valueDirectedWorks =+    testGroup+        "Value-directed construction"+        [ testGroup "Per-constructor rendering" perConstructorRendering+        , testGroup "Generate-then-validate" generateThenValidate+        , testGroup "Edge cases" valueDirectedEdgeCases+        ]++perConstructorRendering :: [TestTree]+perConstructorRendering =+    [ testCase "SNull" $ schemaToValue SNull @?= object ["type" .= ("null" :: Text)]+    , testCase "SBoolean" $ schemaToValue SBoolean @?= object ["type" .= ("boolean" :: Text)]+    , testCase "SInteger" $ schemaToValue SInteger @?= object ["type" .= ("integer" :: Text)]+    , testCase "SNumber" $ schemaToValue SNumber @?= object ["type" .= ("number" :: Text)]+    , testCase "SString Nothing" $+        schemaToValue (SString Nothing) @?= object ["type" .= ("string" :: Text)]+    , testCase "SString (Just pattern)" $+        schemaToValue (SString (Just "^[a-z]+$"))+            @?= object ["type" .= ("string" :: Text), "pattern" .= ("^[a-z]+$" :: Text)]+    , testCase "SConst" $+        schemaToValue (SConst (String "x")) @?= object ["const" .= ("x" :: Text)]+    , testCase "SEnum" $+        schemaToValue (SEnum [Number 1, Number 2]) @?= object ["enum" .= [Number 1, Number 2]]+    , testCase "SArray" $+        schemaToValue (SArray SInteger)+            @?= object ["type" .= ("array" :: Text), "items" .= integerSchema]+    , testCase "SArrayUnique" $+        schemaToValue (SArrayUnique SInteger)+            @?= object+                [ "type" .= ("array" :: Text)+                , "items" .= integerSchema+                , "uniqueItems" .= True+                ]+    , testCase "STuple pins length with minItems/maxItems" $+        schemaToValue (STuple [SInteger, SString Nothing])+            @?= object+                [ "type" .= ("array" :: Text)+                , "prefixItems" .= [integerSchema, object ["type" .= ("string" :: Text)]]+                , "items" .= False+                , "minItems" .= (2 :: Int)+                , "maxItems" .= (2 :: Int)+                ]+    , testCase "SObject omits required when none required" $+        schemaToValue (SObject [Field "x" False SInteger] APForbidden)+            @?= object+                [ "type" .= ("object" :: Text)+                , "properties" .= object ["x" .= integerSchema]+                , "additionalProperties" .= False+                ]+    , testCase "SObject lists required fields" $+        schemaToValue (SObject [Field "x" True SInteger] APForbidden)+            @?= object+                [ "type" .= ("object" :: Text)+                , "properties" .= object ["x" .= integerSchema]+                , "additionalProperties" .= False+                , "required" .= (["x"] :: [Text])+                ]+    , testCase "SObject APAllowed" $+        schemaToValue (SObject [] APAllowed)+            @?= object+                [ "type" .= ("object" :: Text)+                , "properties" .= object []+                , "additionalProperties" .= True+                ]+    , testCase "SObject APSchema (open map)" $+        schemaToValue (SObject [] (APSchema SInteger))+            @?= object+                [ "type" .= ("object" :: Text)+                , "properties" .= object []+                , "additionalProperties" .= integerSchema+                ]+    , testCase "SNullable" $+        schemaToValue (SNullable SInteger)+            @?= object ["anyOf" .= [integerSchema, object ["type" .= ("null" :: Text)]]]+    , testCase "SAnyOf" $+        schemaToValue (SAnyOf [SInteger, SBoolean])+            @?= object ["anyOf" .= [integerSchema, object ["type" .= ("boolean" :: Text)]]]+    , testCase "SAllOf" $+        schemaToValue (SAllOf [SInteger]) @?= object ["allOf" .= [integerSchema]]+    , testCase "SRef simple name" $+        schemaToValue (SRef "Foo") @?= object ["$ref" .= ("#/$defs/Foo" :: Text)]+    , testCase "SRef escapes JSON Pointer characters" $+        schemaToValue (SRef "a/b~c") @?= object ["$ref" .= ("#/$defs/a~1b~0c" :: Text)]+    , testCase "SAny" $ schemaToValue SAny @?= object []+    , testCase "SRaw renders the Value verbatim" $+        schemaToValue (SRaw (object ["type" .= ("string" :: Text), "minLength" .= (3 :: Int)]))+            @?= object ["type" .= ("string" :: Text), "minLength" .= (3 :: Int)]+    ]+  where+    integerSchema = object ["type" .= ("integer" :: Text)]++generateThenValidate :: [TestTree]+generateThenValidate =+    [ testCase "conforming instance validates" $+        assertBool "should be valid" $+            validateJSONSchema sampleSchema (object ["code" .= ("abc" :: Text), "count" .= (3 :: Int)])+    , testCase "optional field may be omitted" $+        assertBool "should be valid" $+            validateJSONSchema sampleSchema (object ["code" .= ("abc" :: Text)])+    , testCase "missing required field rejected" $+        assertBool "should be invalid" $+            not (validateJSONSchema sampleSchema (object ["count" .= (3 :: Int)]))+    , testCase "wrong type rejected" $+        assertBool "should be invalid" $+            not (validateJSONSchema sampleSchema (object ["code" .= (1 :: Int)]))+    , testCase "extra field rejected under additionalProperties:false" $+        assertBool "should be invalid" $+            not (validateJSONSchema sampleSchema (object ["code" .= ("abc" :: Text), "extra" .= True]))+    , testCase "off-pattern string rejected" $+        assertBool "should be invalid" $+            not (validateJSONSchema sampleSchema (object ["code" .= ("ABC" :: Text)]))+    , testCase "root-is-$ref document resolves" $ do+        let doc = SchemaDocument [("Code", SString (Just "^[a-z]+$"))] (SRef "Code")+            v = schemaDocumentToValue doc+        assertBool "conforming valid" (validateJSONSchema v (String "abc"))+        assertBool "off-pattern invalid" (not (validateJSONSchema v (String "ABC")))+    ]++valueDirectedEdgeCases :: [TestTree]+valueDirectedEdgeCases =+    [ testCase "SAnyOf [] rejects everything" $+        assertBool "should be invalid" $+            not (validateJSONSchema (schemaToValue (SAnyOf [])) (Number 1))+    , testCase "SAllOf [] accepts everything" $+        assertBool "should be valid" $+            validateJSONSchema (schemaToValue (SAllOf [])) (Number 1)+    , testCase "SEnum [] rejects everything" $+        assertBool "should be invalid" $+            not (validateJSONSchema (schemaToValue (SEnum [])) (Number 1))+    , testCase "STuple rejects short and long arrays" $ do+        let s = schemaToValue (STuple [SInteger, SString Nothing])+        assertBool "empty invalid" (not (validateJSONSchema s (Array (fromList []))))+        assertBool "short invalid" (not (validateJSONSchema s (Array (fromList [Number 1]))))+        assertBool "exact valid" (validateJSONSchema s (Array (fromList [Number 1, String "x"])))+        assertBool "long invalid" (not (validateJSONSchema s (Array (fromList [Number 1, String "x", Bool True]))))+    , testCase "SObject APSchema validates open-map values" $ do+        let s = schemaToValue (SObject [] (APSchema SInteger))+        assertBool "homogeneous ints valid" (validateJSONSchema s (object ["a" .= (1 :: Int), "b" .= (2 :: Int)]))+        assertBool "wrong value type invalid" (not (validateJSONSchema s (object ["a" .= ("x" :: Text)])))+    , testCase "SConst carrying a non-primitive Value" $ do+        let s = schemaToValue (SConst (object ["k" .= (1 :: Int)]))+        assertBool "structural match valid" (validateJSONSchema s (object ["k" .= (1 :: Int)]))+        assertBool "different object invalid" (not (validateJSONSchema s (object ["k" .= (2 :: Int)])))+    , testCase "SRef with escaped $defs name resolves" $ do+        let doc = SchemaDocument [("a/b~c", SInteger)] (SRef "a/b~c")+            v = schemaDocumentToValue doc+        assertBool "integer valid" (validateJSONSchema v (Number 1))+        assertBool "string invalid" (not (validateJSONSchema v (String "x")))+    , testCase "SRaw embeds an out-of-subset keyword inside a Schema" $ do+        -- minLength is not in the Schema vocabulary; SRaw splices it into a field.+        let s =+                schemaToValue+                    ( SObject+                        [Field "name" True (SRaw (object ["type" .= ("string" :: Text), "minLength" .= (3 :: Int)]))]+                        APForbidden+                    )+        assertBool "long enough valid" (validateJSONSchema s (object ["name" .= ("abcd" :: Text)]))+        assertBool "too short invalid" (not (validateJSONSchema s (object ["name" .= ("ab" :: Text)])))+    , testCase "SchemaDocument merges the header into an SRaw object root" $ do+        let v = schemaDocumentToValue (SchemaDocument [] (SRaw (object ["type" .= ("string" :: Text)])))+        case v of+            Object km -> do+                assertBool "$schema present" (KM.member "$schema" km)+                assertBool "root keys preserved" (KM.lookup "type" km == Just (String "string"))+            _ -> assertFailure "expected an object document"+    , testCase "SchemaDocument wraps a non-object SRaw root under allOf" $ do+        let v = schemaDocumentToValue (SchemaDocument [("X", SInteger)] (SRaw (Bool True)))+        case v of+            Object km -> do+                assertBool "$schema present" (KM.member "$schema" km)+                assertBool "$defs present" (KM.member "$defs" km)+                assertBool "allOf wrapper present" (KM.member "allOf" km)+            _ -> assertFailure "expected an object document"+        assertBool "allOf:[true] accepts anything" (validateJSONSchema v (Number 5))+    , testCase "SchemaDocument $defs win over an SRaw root's own $defs" $ do+        let rootWithDefs = SRaw (object ["$defs" .= object ["Y" .= object ["type" .= ("boolean" :: Text)]]])+            v = schemaDocumentToValue (SchemaDocument [("X", SInteger)] rootWithDefs)+        case v of+            Object km -> case KM.lookup "$defs" km of+                Just (Object defsKm) -> do+                    assertBool "document def X kept" (KM.member "X" defsKm)+                    assertBool "root def Y dropped" (not (KM.member "Y" defsKm))+                _ -> assertFailure "expected a $defs object"+            _ -> assertFailure "expected an object document"     ]
test/Main.hs view
@@ -8,7 +8,7 @@  tests :: TestTree tests =-  testGroup-    "JSONSchema Tests"-    [ JSONSchema.tests-    ]+    testGroup+        "JSONSchema Tests"+        [ JSONSchema.tests+        ]