packages feed

openapi-hs-4.0.0: src/Data/OpenApi/Internal/Schema/Validation.hs

{-# LANGUAGE CPP #-}
{-# OPTIONS_GHC -Wall #-}

-- |
-- Module:      Data.OpenApi.Internal.Schema.Validation
-- Copyright:   (c) 2015 GetShopTV
-- License:     BSD3
-- Maintainer:  Nadeem Bitar <nadeem@gmail.com>
-- Stability:   experimental
--
-- Validate JSON values with OpenAPI Schema.
module Data.OpenApi.Internal.Schema.Validation where

import Control.Applicative
import Control.Lens hiding (allOf)
import Control.Monad (forM, forM_, when)
import Data.Aeson hiding (Result)
import Prelude.Compat
import Prelude ()
#if MIN_VERSION_aeson(2,0,0)
import qualified Data.Aeson.KeyMap as KeyMap
#endif
import Data.Foldable
  ( for_,
    sequenceA_,
    traverse_,
  )
#if !MIN_VERSION_aeson(2,0,0)
import           Data.HashMap.Strict                 (HashMap)
#endif
import Data.HashMap.Strict.InsOrd.Compat qualified as InsOrdHashMap
import Data.Maybe (fromMaybe)
import Data.OpenApi.Aeson.Compat (hasKey, keyToText, lookupKey, objectToList)
import Data.OpenApi.Declare
import Data.OpenApi.Internal
import Data.OpenApi.Internal.Schema
import Data.OpenApi.Internal.Utils
import Data.OpenApi.Lens
import Data.Proxy
import Data.Scientific (Scientific, isInteger)
import Data.Text (Text)
import Data.Text qualified as Text
import Data.Text.Lazy qualified as TL
import Data.Text.Lazy.Encoding qualified as TL
import Data.Vector (Vector)
import Data.Vector qualified as Vector
import "unordered-containers" Data.HashSet qualified as HashSet

-- $setup
-- >>> import Data.OpenApi.Internal.Schema.Validation

-- | Validate @ToJSON@ instance matches @ToSchema@ for a given value.
-- This can be used with QuickCheck to ensure those instances are coherent:
--
-- prop> validateToJSON (x :: Int) == []
--
-- /NOTE:/ @'validateToJSON'@ does not perform string pattern validation.
-- See @'validateToJSONWithPatternChecker'@.
--
-- See 'renderValidationErrors' on how the output is structured.
validatePrettyToJSON :: forall a. (ToJSON a, ToSchema a) => a -> Maybe String
validatePrettyToJSON = renderValidationErrors validateToJSON

-- | Variant of 'validatePrettyToJSON' with typed output.
validateToJSON :: forall a. (ToJSON a, ToSchema a) => a -> [ValidationError]
validateToJSON = validateToJSONWithPatternChecker (\_pattern _str -> True)

-- | Validate @ToJSON@ instance matches @ToSchema@ for a given value and pattern checker.
-- This can be used with QuickCheck to ensure those instances are coherent.
--
-- For validation without patterns see @'validateToJSON'@.  See also:
-- 'renderValidationErrors'.
validateToJSONWithPatternChecker :: forall a. (ToJSON a, ToSchema a) => (Pattern -> Text -> Bool) -> a -> [ValidationError]
validateToJSONWithPatternChecker checker = validateJSONWithPatternChecker checker defs sch . toJSON
  where
    (defs, sch) = runDeclare (declareSchema (Proxy :: Proxy a)) mempty

-- | Pretty print validation errors
-- together with actual JSON and OpenAPI Schema
-- (using 'encodePretty').
--
-- >>> import Data.Aeson as Aeson
-- >>> import Data.Foldable (traverse_)
-- >>> import GHC.Generics
-- >>> data Phone = Phone { value :: String } deriving (Generic)
-- >>> data Person = Person { name :: String, phone :: Phone } deriving (Generic)
-- >>> instance ToJSON Person where toJSON p = object [ "name" Aeson..= name p ]
-- >>> instance ToSchema Phone
-- >>> instance ToSchema Person
-- >>> let person = Person { name = "John", phone = Phone "123456" }
-- >>> traverse_ putStrLn $ renderValidationErrors validateToJSON person
-- Validation against the schema fails:
--   * property "phone" is required, but not found in "{\"name\":\"John\"}"
-- <BLANKLINE>
-- JSON value:
-- {
--     "name": "John"
-- }
-- <BLANKLINE>
-- OpenAPI Schema:
-- {
--     "properties": {
--         "name": {
--             "type": "string"
--         },
--         "phone": {
--             "$ref": "#/components/schemas/Phone"
--         }
--     },
--     "required": [
--         "name",
--         "phone"
--     ],
--     "type": "object"
-- }
-- <BLANKLINE>
-- OpenAPI Description Context:
-- {
--     "Phone": {
--         "properties": {
--             "value": {
--                 "type": "string"
--             }
--         },
--         "required": [
--             "value"
--         ],
--         "type": "object"
--     }
-- }
-- <BLANKLINE>
renderValidationErrors ::
  forall a.
  (ToJSON a, ToSchema a) =>
  (a -> [ValidationError]) -> a -> Maybe String
renderValidationErrors f x =
  case f x of
    [] -> Nothing
    errors ->
      Just
        $ unlines
          [ "Validation against the schema fails:",
            unlines (map ("  * " ++) errors),
            "JSON value:",
            ppJSONString (toJSON x),
            "",
            "OpenAPI Schema:",
            ppJSONString (toJSON schema_),
            "",
            "OpenAPI Description Context:",
            ppJSONString (toJSON refs_)
          ]
  where
    ppJSONString = TL.unpack . TL.decodeUtf8 . encodePretty
    (refs_, schema_) = runDeclare (declareSchema (Proxy :: Proxy a)) mempty

-- | Validate JSON @Value@ against OpenAPI @Schema@.
--
-- prop> validateJSON mempty (toSchema (Proxy :: Proxy Int)) (toJSON (x :: Int)) == []
--
-- /NOTE:/ @'validateJSON'@ does not perform string pattern validation.
-- See @'validateJSONWithPatternChecker'@.
validateJSON :: Definitions Schema -> Schema -> Value -> [ValidationError]
validateJSON = validateJSONWithPatternChecker (\_pattern _str -> True)

-- | Validate JSON @Value@ against OpenAPI @ToSchema@ for a given value and pattern checker.
--
-- For validation without patterns see @'validateJSON'@.
validateJSONWithPatternChecker :: (Pattern -> Text -> Bool) -> Definitions Schema -> Schema -> Value -> [ValidationError]
validateJSONWithPatternChecker checker defs sch js =
  case runValidation (validateWithSchema js) cfg sch of
    Failed xs -> xs
    Passed _ -> mempty
  where
    cfg =
      defaultConfig
        { configPatternChecker = checker,
          configDefinitions = defs
        }

-- | Validation error message.
type ValidationError = String

-- | Validation result type.
data Result a
  = -- | Validation failed with a list of error messages.
    Failed [ValidationError]
  | -- | Validation passed.
    Passed a
  deriving stock (Eq, Show, Functor)

instance Applicative Result where
  pure = Passed
  Passed f <*> Passed x = Passed (f x)
  Failed xs <*> Failed ys = Failed (xs <> ys)
  Failed xs <*> _ = Failed xs
  _ <*> Failed ys = Failed ys

instance Alternative Result where
  empty = Failed mempty
  Passed x <|> _ = Passed x
  _ <|> y = y

instance Monad Result where
  return = pure
  Passed x >>= f = f x
  Failed xs >>= _ = Failed xs

-- | Validation configuration.
data Config = Config
  { -- | Pattern checker for @'_schemaPattern'@ validation.
    configPatternChecker :: Pattern -> Text -> Bool,
    -- | Schema definitions in scope to resolve references.
    configDefinitions :: Definitions Schema
  }

-- | Default @'Config'@:
--
-- @
-- defaultConfig = 'Config'
--   { 'configPatternChecker' = \\_pattern _str -> True
--   , 'configDefinitions'    = mempty
--   }
-- @
defaultConfig :: Config
defaultConfig =
  Config
    { configPatternChecker = \_pattern _str -> True,
      configDefinitions = mempty
    }

-- | Value validation.
newtype Validation s a = Validation {runValidation :: Config -> s -> Result a}
  deriving stock (Functor)

instance Applicative (Validation schema) where
  pure x = Validation (\_ _ -> pure x)
  Validation f <*> Validation x = Validation (\c s -> f c s <*> x c s)

instance Alternative (Validation schema) where
  empty = Validation (\_ _ -> empty)
  Validation x <|> Validation y = Validation (\c s -> x c s <|> y c s)

instance Profunctor Validation where
  dimap f g (Validation k) = Validation (\c s -> fmap g (k c (f s)))

instance Choice Validation where
  left' (Validation g) = Validation (\c -> either (fmap Left . g c) (pure . Right))
  right' (Validation g) = Validation (\c -> either (pure . Left) (fmap Right . g c))

instance Monad (Validation s) where
  return = pure
  Validation x >>= f = Validation (\c s -> x c s >>= \y -> runValidation (f y) c s)
  (>>) = (*>)

withConfig :: (Config -> Validation s a) -> Validation s a
withConfig f = Validation (\c -> runValidation (f c) c)

withSchema :: (s -> Validation s a) -> Validation s a
withSchema f = Validation (\c s -> runValidation (f s) c s)

-- | Issue an error message.
invalid :: String -> Validation schema a
invalid msg = Validation (\_ _ -> Failed [msg])

-- | Validation passed.
valid :: Validation schema ()
valid = pure ()

-- | Validate schema's property given a lens into that property
-- and property checker.
checkMissing :: Validation s () -> Lens' s (Maybe a) -> (a -> Validation s ()) -> Validation s ()
checkMissing missing l g = withSchema $ \sch ->
  case sch ^. l of
    Nothing -> missing
    Just x -> g x

-- | Validate schema's property given a lens into that property
-- and property checker.
-- If property is missing in schema, consider it valid.
check :: Lens' s (Maybe a) -> (a -> Validation s ()) -> Validation s ()
check = checkMissing valid

-- | Validate same value with different schema.
sub :: t -> Validation t a -> Validation s a
sub = lmap . const

-- | Validate same value with a part of the original schema.
sub_ :: Getting a s a -> Validation a r -> Validation s r
sub_ = lmap . view

-- | Validate value against a schema given schema reference and validation function.
withRef :: Reference -> (Schema -> Validation s a) -> Validation s a
withRef (Reference ref) f = withConfig $ \cfg ->
  case InsOrdHashMap.lookup ref (configDefinitions cfg) of
    Nothing -> invalid $ "unknown schema " ++ show ref
    Just s -> f s

validateWithSchemaRef :: Referenced Schema -> Value -> Validation s ()
validateWithSchemaRef (Ref ref) js = withRef ref $ \sch -> sub sch (validateWithSchema js)
validateWithSchemaRef (Inline s) js = sub s (validateWithSchema js)

-- | Validate JSON @Value@ with OpenAPI @Schema@.
validateWithSchema :: Value -> Validation Schema ()
validateWithSchema val = do
  validateSchemaType val
  validateEnum val
  validateConst val -- 3.1: const
  validateConditional val -- 3.1: if/then/else
  validateUnevaluated val -- 3.1: best-effort unevaluatedProperties/unevaluatedItems

-- | @const@: the instance must equal the schema's @const@ value exactly
-- (JSON value equality).
validateConst :: Value -> Validation Schema ()
validateConst val = check const_ $ \expected ->
  when (val /= expected)
    $ invalid ("value " ++ show val ++ " does not equal const " ++ show expected)

-- | @if@/@then@/@else@. Validating against @if@ is only a switch: if it passes,
-- @then@ must pass; otherwise @else@ must pass. The @if@ check itself never
-- contributes an error.
validateConditional :: Value -> Validation Schema ()
validateConditional val = check if_ $ \ifSchema -> withConfig $ \cfg -> withSchema $ \sch ->
  let ifPasses = case runValidation (validateWithSchemaRef ifSchema val) cfg sch of
        Failed _ -> False
        Passed _ -> True
   in if ifPasses
        then maybe valid (\t -> validateWithSchemaRef t val) (sch ^. then_)
        else maybe valid (\e -> validateWithSchemaRef e val) (sch ^. else_)

-- | Best-effort @unevaluatedProperties@ / @unevaluatedItems@.
--
-- LIMITATION (documented, intentional): "evaluated" is approximated as
-- "evaluated by THIS schema object's own local @properties@/@additionalProperties@
-- (for objects) or @prefixItems@/@items@ (for arrays)". Properties or items that a
-- full JSON Schema 2020-12 validator would consider evaluated via in-place
-- applicators (@allOf@/@anyOf@/@oneOf@/@if@/@then@/@else@/@$ref@) are NOT counted
-- here, so this check can be STRICTER than the spec when those applicators are
-- combined with @unevaluated*@. Full annotation-aware evaluation is future work.
-- TODO(annotations).
validateUnevaluated :: Value -> Validation Schema ()
validateUnevaluated (Object o) = check unevaluatedProperties $ \ap -> withSchema $ \sch ->
  let propKeys = InsOrdHashMap.keys (sch ^. properties)
      addlCoversAll = case sch ^. additionalProperties of
        Just (AdditionalPropertiesAllowed True) -> True
        Just (AdditionalPropertiesSchema _) -> True
        _ -> False
      leftover =
        [ (k, v)
        | (keyToText -> k, v) <- objectToList o,
          not addlCoversAll,
          k `notElem` propKeys
        ]
   in for_ leftover $ \(k, v) -> case ap of
        AdditionalPropertiesAllowed True -> valid
        AdditionalPropertiesAllowed False ->
          invalid ("unevaluatedProperties=false but property " ++ show k ++ " was not evaluated")
        AdditionalPropertiesSchema s -> validateWithSchemaRef s v
validateUnevaluated (Array xs) = check unevaluatedItems $ \uSchema -> withSchema $ \sch ->
  let prefixLen = maybe 0 length (sch ^. prefixItems)
      itemsCoversAll = case sch ^. items of
        Just (OpenApiItemsObject _) -> True
        Just (OpenApiItemsBoolean True) -> True
        _ -> False
      leftover = if itemsCoversAll then [] else drop prefixLen (Vector.toList xs)
   in traverse_ (validateWithSchemaRef uSchema) leftover
validateUnevaluated _ = valid

validateInteger :: Scientific -> Validation Schema ()
validateInteger n = do
  when (not (isInteger n))
    $ invalid ("not an integer")
  validateNumber n

validateNumber :: Scientific -> Validation Schema ()
validateNumber n = withConfig $ \_cfg -> withSchema $ \_sch -> do
  -- 3.1 / JSON Schema 2020-12: maximum/minimum are always non-strict, and
  -- exclusiveMaximum/exclusiveMinimum are independent numeric keywords.
  check maximum_ $ \m ->
    when (n > m)
      $ invalid ("value " ++ show n ++ " exceeds maximum (should be <=" ++ show m ++ ")")

  check minimum_ $ \m ->
    when (n < m)
      $ invalid ("value " ++ show n ++ " falls below minimum (should be >=" ++ show m ++ ")")

  check exclusiveMaximum $ \m ->
    when (n >= m)
      $ invalid ("value " ++ show n ++ " is not below exclusiveMaximum (should be <" ++ show m ++ ")")

  check exclusiveMinimum $ \m ->
    when (n <= m)
      $ invalid ("value " ++ show n ++ " is not above exclusiveMinimum (should be >" ++ show m ++ ")")

  check multipleOf $ \k ->
    when (not (isInteger (n / k)))
      $ invalid ("expected a multiple of " ++ show k ++ " but got " ++ show n)

validateString :: Text -> Validation Schema ()
validateString s = do
  check maxLength $ \n ->
    when (len > fromInteger n)
      $ invalid ("string is too long (length should be <=" ++ show n ++ ")")

  check minLength $ \n ->
    when (len < fromInteger n)
      $ invalid ("string is too short (length should be >=" ++ show n ++ ")")

  check pattern $ \regex -> do
    withConfig $ \cfg -> do
      when (not (configPatternChecker cfg regex s))
        $ invalid ("string does not match pattern " ++ show regex)
  where
    len = Text.length s

validateArray :: Vector Value -> Validation Schema ()
validateArray xs = do
  check maxItems $ \n ->
    when (len > fromInteger n)
      $ invalid ("array exceeds maximum size (should be <=" ++ show n ++ ")")

  check minItems $ \n ->
    when (len < fromInteger n)
      $ invalid ("array is too short (size should be >=" ++ show n ++ ")")

  withSchema $ \sch -> case sch ^. prefixItems of
    -- 3.1 tuple validation: each leading element validates positionally against
    -- its prefix schema; elements beyond the prefix are governed by `items`.
    Just prefixSchemas -> do
      let prefixLen = length prefixSchemas
      sequenceA_
        [ validateWithSchemaRef ps x
        | (ps, x) <- zip prefixSchemas (Vector.toList xs)
        ]
      case sch ^. items of
        Just (OpenApiItemsObject itemSchema) ->
          traverse_ (validateWithSchemaRef itemSchema) (drop prefixLen (Vector.toList xs))
        Just (OpenApiItemsBoolean False)
          | len > prefixLen ->
              invalid
                ( "array has "
                    ++ show (len - prefixLen)
                    ++ " item(s) beyond prefixItems but items:false forbids them"
                )
        _ -> valid -- items:true or absent: trailing elements unconstrained here
        -- No prefixItems: legacy whole-array `items` behavior.
    Nothing -> check items $ \case
      OpenApiItemsObject itemSchema -> traverse_ (validateWithSchemaRef itemSchema) xs
      -- items: false forbids any array elements; items: true allows anything.
      OpenApiItemsBoolean b ->
        when (not b && not (Vector.null xs))
          $ invalid "array must be empty (items: false)"

  -- 3.1 contains / minContains / maxContains: count elements matching `contains`.
  check contains_ $ \containsSchema -> withConfig $ \cfg -> withSchema $ \sch -> do
    let matchesElem x = case runValidation (validateWithSchemaRef containsSchema x) cfg sch of
          Failed _ -> False
          Passed _ -> True
        matches = length (filter matchesElem (Vector.toList xs))
        minC = maybe 1 fromInteger (sch ^. minContains)
        maxC = fmap fromInteger (sch ^. maxContains)
    when (matches < minC)
      $ invalid
        ( "array must contain at least "
            ++ show minC
            ++ " matching element(s), found "
            ++ show matches
        )
    for_ maxC $ \hi ->
      when (matches > hi)
        $ invalid
          ( "array must contain at most "
              ++ show hi
              ++ " matching element(s), found "
              ++ show matches
          )

  check uniqueItems $ \unique ->
    when (unique && not allUnique)
      $ invalid ("array is expected to contain unique items, but it does not")
  where
    len = Vector.length xs
    allUnique = len == HashSet.size (HashSet.fromList (Vector.toList xs))

-- | The aeson object representation, which differs across aeson versions.
-- Aliased so 'validateObject' keeps a single-line signature: the formatter
-- cannot parse a type signature split across CPP @#if@ branches.
#if MIN_VERSION_aeson(2,0,0)
type AesonObject = KeyMap.KeyMap Value
#else
type AesonObject = HashMap Text Value
#endif

validateObject :: AesonObject -> Validation Schema ()
validateObject o = withSchema $ \sch ->
  case sch ^. discriminator of
    Just (Discriminator pname types) -> case fromJSON <$> lookupKey pname o of
      Just (Success pvalue) ->
        let ref = fromMaybe pvalue $ InsOrdHashMap.lookup pvalue types
         in -- TODO ref may be name or reference
            validateWithSchemaRef (Ref (Reference ref)) (Object o)
      Just (Error msg) -> invalid ("failed to parse discriminator property " ++ show pname ++ ": " ++ show msg)
      Nothing -> invalid ("discriminator property " ++ show pname ++ "is missing")
    Nothing -> do
      check maxProperties $ \n ->
        when (size > n)
          $ invalid ("object size exceeds maximum (total number of properties should be <=" ++ show n ++ ")")

      check minProperties $ \n ->
        when (size < n)
          $ invalid ("object size is too small (total number of properties should be >=" ++ show n ++ ")")

      validateRequired
      validateProps
  where
    size = fromIntegral (length o)

    validateRequired = withSchema $ \sch -> traverse_ validateReq (sch ^. required)
    validateReq n =
      when (not (hasKey n o))
        $ invalid ("property " ++ show n ++ " is required, but not found in " ++ show (encode o))

    validateProps = withSchema $ \sch -> do
      for_ (objectToList o) $ \(keyToText -> k, v) ->
        case v of
          Null | not (k `elem` (sch ^. required)) -> valid -- null is fine for non-required property
          _ ->
            case InsOrdHashMap.lookup k (sch ^. properties) of
              Nothing -> checkMissing (unknownProperty k) additionalProperties $ validateAdditional k v
              Just s -> validateWithSchemaRef s v

    validateAdditional _ _ (AdditionalPropertiesAllowed True) = valid
    validateAdditional k _ (AdditionalPropertiesAllowed False) = invalid $ "additionalProperties=false but extra property " <> show k <> " found"
    validateAdditional _ v (AdditionalPropertiesSchema s) = validateWithSchemaRef s v

    unknownProperty :: Text -> Validation s a
    unknownProperty pname =
      invalid
        $ "property "
        <> show pname
        <> " is found in JSON value, but it is not mentioned in OpenAPI schema"

validateEnum :: Value -> Validation Schema ()
validateEnum val = do
  check enum_ $ \xs ->
    when (val `notElem` xs)
      $ invalid ("expected one of " ++ show (encode xs) ++ " but got " ++ show val)

-- | Infer schema type based on used properties.
--
-- This is like 'inferParamSchemaTypes', but also works for objects:
--
-- >>> inferSchemaTypes <$> decode "{\"minProperties\": 1}"
-- Just [OpenApiObject]
inferSchemaTypes :: Schema -> [OpenApiType]
inferSchemaTypes sch =
  inferParamSchemaTypes sch
    ++ [ OpenApiObject
       | any
           ($ sch)
           [ has (additionalProperties . _Just),
             has (maxProperties . _Just),
             has (minProperties . _Just),
             has (properties . folded),
             has (required . folded)
           ]
       ]

-- | Infer schema type based on used properties.
--
-- >>> inferSchemaTypes <$> decode "{\"minLength\": 2}"
-- Just [OpenApiString]
--
-- >>> inferSchemaTypes <$> decode "{\"maxItems\": 0}"
-- Just [OpenApiArray]
--
-- From numeric properties 'OpenApiInteger' type is inferred.
-- If you want 'OpenApiNumber' instead, you must specify it explicitly.
--
-- >>> inferSchemaTypes <$> decode "{\"minimum\": 1}"
-- Just [OpenApiInteger]
inferParamSchemaTypes :: Schema -> [OpenApiType]
inferParamSchemaTypes sch =
  concat
    [ [ OpenApiArray
      | any
          ($ sch)
          [ has (items . _Just),
            has (maxItems . _Just),
            has (minItems . _Just),
            has (uniqueItems . _Just)
          ]
      ],
      [ OpenApiInteger
      | any
          ($ sch)
          [ has (exclusiveMaximum . _Just),
            has (exclusiveMinimum . _Just),
            has (maximum_ . _Just),
            has (minimum_ . _Just),
            has (multipleOf . _Just)
          ]
      ],
      [ OpenApiString
      | any
          ($ sch)
          [ has (maxLength . _Just),
            has (minLength . _Just),
            has (pattern . _Just)
          ]
      ]
    ]

-- | The list of primitive types a schema's @type@ keyword permits:
-- a single type becomes a singleton list, a type array becomes its list,
-- and an absent @type@ becomes the empty list (callers fall back to the
-- value's natural shape).
schemaTypes :: Schema -> [OpenApiType]
schemaTypes sch = case sch ^. type_ of
  Nothing -> []
  Just (OpenApiTypeSingle t) -> [t]
  Just (OpenApiTypeArray ts) -> ts

-- | Validate a value assuming the schema's @type@ is exactly the given single type.
validateOfType :: OpenApiType -> Value -> Validation Schema ()
validateOfType ty val = case (ty, val) of
  (OpenApiNull, Null) -> valid
  (OpenApiBoolean, Bool _) -> valid
  (OpenApiInteger, Number n) -> validateInteger n
  (OpenApiNumber, Number n) -> validateNumber n
  (OpenApiString, String s) -> validateString s
  (OpenApiArray, Array xs) -> validateArray xs
  (OpenApiObject, Object o) -> validateObject o
  _ -> invalid $ "expected JSON value of type " ++ showType (Just ty, val)

-- | Validate a value when the schema declares no @type@ (fall back to the value's shape).
validateByValueShape :: Value -> Validation Schema ()
validateByValueShape val = case val of
  Null -> valid
  Bool _ -> valid
  Number n -> validateNumber n
  String s -> validateString s
  Array xs -> validateArray xs
  Object o -> validateObject o

validateSchemaType :: Value -> Validation Schema ()
validateSchemaType val = withSchema $ \sch ->
  case sch of
    (view oneOf -> Just variants) -> do
      res <- forM variants $ \var ->
        (True <$ validateWithSchemaRef var val) <|> (return False)
      case length $ filter id res of
        0 -> invalid $ "Value not valid under any of 'oneOf' schemas: " ++ show val
        1 -> valid
        _ -> invalid $ "Value matches more than one of 'oneOf' schemas: " ++ show val
    (view allOf -> Just variants) -> do
      -- Default semantics for Validation Monad will abort when at least one
      -- variant does not match.
      forM_ variants $ \var ->
        validateWithSchemaRef var val
    _ ->
      case schemaTypes sch of
        [] -> validateByValueShape val
        [ty] -> validateOfType ty val
        -- 3.1 type array: the value is valid if it matches any listed type.
        tys -> do
          oks <- forM tys $ \ty -> (True <$ validateOfType ty val) <|> return False
          when (not (or oks))
            $ invalid
            $ "expected JSON value of one of types "
            ++ show tys
            ++ " but got "
            ++ showType (Nothing, val)

validateParamSchemaType :: Value -> Validation Schema ()
validateParamSchemaType val = withSchema $ \sch ->
  case schemaTypes sch of
    [] -> validateParamByValueShape val
    [ty] -> validateParamOfType ty val
    tys -> do
      oks <- forM tys $ \ty -> (True <$ validateParamOfType ty val) <|> return False
      when (not (or oks))
        $ invalid
        $ "expected JSON value of one of types "
        ++ show tys
        ++ " but got "
        ++ showType (Nothing, val)

-- | Validate a value assuming a single @type@, restricted to the kinds a
-- parameter schema may have (no @null@ or @object@).
validateParamOfType :: OpenApiType -> Value -> Validation Schema ()
validateParamOfType ty val = case (ty, val) of
  (OpenApiBoolean, Bool _) -> valid
  (OpenApiInteger, Number n) -> validateInteger n
  (OpenApiNumber, Number n) -> validateNumber n
  (OpenApiString, String s) -> validateString s
  (OpenApiArray, Array xs) -> validateArray xs
  _ -> invalid $ "expected JSON value of type " ++ showType (Just ty, val)

validateParamByValueShape :: Value -> Validation Schema ()
validateParamByValueShape val = case val of
  Bool _ -> valid
  Number n -> validateNumber n
  String s -> validateString s
  Array xs -> validateArray xs
  _ -> invalid $ "expected JSON value of type " ++ showType (Nothing, val)

showType :: (Maybe OpenApiType, Value) -> String
showType (Just ty, _) = show ty
showType (Nothing, Null) = "OpenApiNull"
showType (Nothing, Bool _) = "OpenApiBoolean"
showType (Nothing, Number _) = "OpenApiNumber"
showType (Nothing, String _) = "OpenApiString"
showType (Nothing, Array _) = "OpenApiArray"
showType (Nothing, Object _) = "OpenApiObject"