{-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE DeriveDataTypeable #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE DerivingStrategies #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE Trustworthy #-}
{-# LANGUAGE TupleSections #-}
module Data.Medea
(
Schema,
LoaderError (..),
ParseError (..),
buildSchema,
loadSchemaFromFile,
loadSchemaFromHandle,
JSONType (..),
SchemaInformation (..),
ValidationError (..),
ValidatedJSON,
toValue,
validAgainst,
validate,
validateFromFile,
validateFromHandle,
)
where
import Control.Applicative ((<|>), Alternative)
import Control.Comonad.Cofree (Cofree (..))
import Control.DeepSeq (NFData (..))
import Control.Monad (MonadPlus, unless, when)
import Control.Monad.Except (MonadError (..))
import Control.Monad.IO.Class (MonadIO (..))
import Control.Monad.Reader (MonadReader, asks, runReaderT)
import Control.Monad.State.Strict (MonadState (..), evalStateT, gets)
import Data.Aeson (Array, Object, Value (..), decode)
import qualified Data.ByteString.Lazy as BS
import Data.ByteString.Lazy (ByteString)
import Data.Coerce (coerce)
import Data.Data (Data)
import Data.Foldable (asum, traverse_)
import Data.Functor (($>))
import qualified Data.HashMap.Strict as HM
import Data.Hashable (Hashable (..))
import qualified Data.Map.Strict as M
import Data.Maybe (isNothing)
import Data.Medea.Analysis (ArrayType (..), CompiledSchema (..), TypeNode (..))
import Data.Medea.JSONType (JSONType (..), typeOf)
import Data.Medea.Loader
( LoaderError (..),
buildSchema,
loadSchemaFromFile,
loadSchemaFromHandle,
)
import Data.Medea.Parser.Primitive (Identifier (..), ReservedIdentifier (..), identFromReserved)
import Data.Medea.Parser.Types (ParseError(..))
import Data.Medea.Schema (Schema (..))
import Data.Medea.ValidJSON (ValidJSONF (..))
import qualified Data.Set as S
import Data.Set.NonEmpty
( NESet,
dropWhileAntitone,
findMin,
member,
singleton,
)
import Data.Text (Text)
import qualified Data.Vector as V
import GHC.Generics (Generic)
import System.IO (Handle, hSetBinaryMode)
data SchemaInformation
=
AnySchema
|
NullSchema
|
BooleanSchema
|
NumberSchema
|
StringSchema
|
ArraySchema
|
ObjectSchema
|
StartSchema
|
UserDefined {-# UNPACK #-} !Text
deriving stock (Eq, Data, Show, Generic)
deriving anyclass (Hashable, NFData)
newtype ValidatedJSON = ValidatedJSON (Cofree ValidJSONF SchemaInformation)
deriving stock (Data)
deriving newtype (Eq, Show)
instance NFData ValidatedJSON where
{-# INLINE rnf #-}
rnf (ValidatedJSON (x :< f)) =
rnf x `seq` (rnf . fmap ValidatedJSON $ f)
instance Hashable ValidatedJSON where
{-# INLINE hashWithSalt #-}
hashWithSalt salt (ValidatedJSON (x :< f)) =
salt `hashWithSalt` x `hashWithSalt` fmap ValidatedJSON f
toValue :: ValidatedJSON -> Value
toValue (ValidatedJSON (_ :< f)) = case f of
AnythingF v -> v
NullF -> Null
BooleanF b -> Bool b
NumberF n -> Number n
StringF s -> String s
ArrayF v -> Array . fmap (toValue . coerce) $ v
ObjectF hm -> Object . fmap (toValue . coerce) $ hm
validAgainst :: ValidatedJSON -> SchemaInformation
validAgainst (ValidatedJSON (label :< _)) = label
data ValidationError
= EmptyError
|
NotJSON
|
WrongType
!Value
!JSONType
|
NotOneOfOptions !Value
|
AdditionalPropFoundButBanned
{-# UNPACK #-} !Text
{-# UNPACK #-} !Text
|
RequiredPropertyIsMissing
{-# UNPACK #-} !Text
{-# UNPACK #-} !Text
|
OutOfBoundsArrayLength
{-# UNPACK #-} !Text
!Value
|
ImplementationError
{-# UNPACK #-} !Text
deriving stock (Eq, Show, Generic)
deriving anyclass (Hashable)
instance Semigroup ValidationError where
EmptyError <> x = x
x <> _ = x
instance Monoid ValidationError where
mempty = EmptyError
validate ::
(MonadPlus m, MonadError ValidationError m) =>
Schema ->
ByteString ->
m ValidatedJSON
validate scm bs = case decode bs of
Nothing -> throwError NotJSON
Just v -> ValidatedJSON <$> go v
where
go v = runReaderT (evalStateT (checkTypes v) (initialSet, Nothing)) scm
initialSet = singleton . CustomNode . identFromReserved $ RStart
validateFromFile ::
(MonadPlus m, MonadError ValidationError m, MonadIO m) =>
Schema ->
FilePath ->
m ValidatedJSON
validateFromFile scm fp = do
bs <- liftIO (BS.readFile fp)
validate scm bs
validateFromHandle ::
(MonadPlus m, MonadError ValidationError m, MonadIO m) =>
Schema ->
Handle ->
m ValidatedJSON
validateFromHandle scm h = do
liftIO (hSetBinaryMode h True)
bs <- liftIO (BS.hGetContents h)
validate scm bs
checkTypes ::
(Alternative m, MonadReader Schema m, MonadState (NESet TypeNode, Maybe Identifier) m, MonadError ValidationError m) =>
Value ->
m (Cofree ValidJSONF SchemaInformation)
checkTypes v = checkAny v <|> checkPrim v <|> checkCustoms v
checkAny ::
(Alternative m, MonadState (NESet TypeNode, Maybe Identifier) m, MonadError ValidationError m) =>
Value ->
m (Cofree ValidJSONF SchemaInformation)
checkAny v = do
minNode <- gets $ findMin . fst
case minNode of
AnyNode -> pure $ AnySchema :< AnythingF v
_ -> throwError EmptyError
checkPrim ::
(Alternative m, MonadReader Schema m, MonadState (NESet TypeNode, Maybe Identifier) m, MonadError ValidationError m) =>
Value ->
m (Cofree ValidJSONF SchemaInformation)
checkPrim v = do
(nodes, par) <- gets id
unless (member (PrimitiveNode . typeOf $ v) nodes) $ throwError . NotOneOfOptions $ v
case v of
Null -> pure $ NullSchema :< NullF
Bool b -> pure $ BooleanSchema :< BooleanF b
Number n -> pure $ NumberSchema :< NumberF n
String s -> case par of
Nothing -> pure $ StringSchema :< StringF s
Just parIdent -> do
scm <- lookupSchema parIdent
let validVals = stringVals scm
if s `V.elem` validVals || null validVals
then pure $ StringSchema :< StringF s
else throwError $ NotOneOfOptions v
Array arr -> case par of
Nothing -> put (anySet, Nothing) >> (ArraySchema :<) . ArrayF <$> traverse checkTypes arr
Just parIdent -> checkArray arr parIdent
Object obj -> case par of
Nothing -> put (anySet, Nothing) >> (ObjectSchema :<) . ObjectF <$> traverse checkTypes obj
Just parIdent -> checkObject obj parIdent
checkArray ::
(Alternative m, MonadReader Schema m, MonadState (NESet TypeNode, Maybe Identifier) m, MonadError ValidationError m) =>
Array ->
Identifier ->
m (Cofree ValidJSONF SchemaInformation)
checkArray arr parIdent = do
scm <- lookupSchema parIdent
let arrLen = fromIntegral $ V.length arr
when
( maybe False (arrLen <) (minArrayLen scm)
|| maybe False (arrLen >) (maxArrayLen scm)
)
$ throwError . OutOfBoundsArrayLength (textify parIdent) . Array
$ arr
let valsAndTypes = pairValsWithTypes $ arrayTypes scm
checkedArray <- traverse (\(val, typeNode) -> put (singleton typeNode, Nothing) >> checkTypes val) valsAndTypes
pure $ ArraySchema :< ArrayF checkedArray
where
pairValsWithTypes Nothing = fmap (,AnyNode) arr
pairValsWithTypes (Just (ListType node)) = fmap (,node) arr
pairValsWithTypes (Just (TupleType nodes)) = V.zip arr nodes
checkObject ::
(Alternative m, MonadReader Schema m, MonadState (NESet TypeNode, Maybe Identifier) m, MonadError ValidationError m) =>
Object ->
Identifier ->
m (Cofree ValidJSONF SchemaInformation)
checkObject obj parIdent = do
valsAndTypes <- pairPropertySchemaAndVal obj parIdent
checkedObj <- traverse (\(val, typeNode) -> put (singleton typeNode, Nothing) >> checkTypes val) valsAndTypes
pure $ ObjectSchema :< ObjectF checkedObj
pairPropertySchemaAndVal ::
(Alternative m, MonadReader Schema m, MonadError ValidationError m) =>
HM.HashMap Text Value ->
Identifier ->
m (HM.HashMap Text (Value, TypeNode))
pairPropertySchemaAndVal obj parIdent = do
scm <- lookupSchema parIdent
mappedObj <- traverse (pairProperty scm) $ HM.mapWithKey (,) obj
traverse_ isMatched . HM.mapWithKey (,) $ props scm
pure mappedObj
where
pairProperty scm (propName, v) = case HM.lookup propName $ props scm of
Just (typeNode, _) -> pure (v, typeNode)
Nothing
| additionalProps scm -> pure (v, additionalPropSchema scm)
| otherwise -> throwError . AdditionalPropFoundButBanned (textify parIdent) $ propName
isMatched (propName, (_, optional)) =
when (isNothing (HM.lookup propName obj) && not optional)
$ throwError . RequiredPropertyIsMissing (textify parIdent)
$ propName
checkCustoms ::
(Alternative m, MonadReader Schema m, MonadState (NESet TypeNode, Maybe Identifier) m, MonadError ValidationError m) =>
Value ->
m (Cofree ValidJSONF SchemaInformation)
checkCustoms v = do
customNodes <- gets $ dropWhileAntitone (not . isCustom) . fst
asum . fmap checkCustom . S.toList $ customNodes
where
checkCustom (CustomNode ident) = do
neighbourhood <- typesAs <$> lookupSchema ident
put (neighbourhood, Just ident)
($> (UserDefined . textify $ ident)) <$> checkTypes v
checkCustom _ = throwError $ ImplementationError "Unreachable code: All these nodes MUST be custom."
lookupSchema ::
(MonadReader Schema m, MonadError ValidationError m) => Identifier -> m CompiledSchema
lookupSchema ident = do
x <- asks $ M.lookup ident . compiledSchemata
case x of
Just scm -> pure scm
Nothing -> throwError . ImplementationError $ "Unreachable state: We should be able to find this schema"
anySet :: NESet TypeNode
anySet = singleton AnyNode
textify :: Identifier -> Text
textify (Identifier t) = t
isCustom :: TypeNode -> Bool
isCustom (CustomNode _) = True
isCustom _ = False