compaREST-0.1.0.0: src/Data/OpenApi/Compare/Validate/Schema/JsonFormula.hs
module Data.OpenApi.Compare.Validate.Schema.JsonFormula
( Bound (..),
showBound,
Property (..),
Condition (..),
showCondition,
satisfiesTyped,
checkStringFormat,
checkNumberFormat,
SomeCondition (..),
JsonFormula (..),
satisfiesFormula,
satisfies,
showJSONValue,
showJSONValueInline,
)
where
import Algebra.Lattice
import qualified Data.Aeson as A
import qualified Data.ByteString.Lazy as BSL
import qualified Data.Foldable as F
import Data.Functor
import qualified Data.HashMap.Strict as HM
import Data.Int
import Data.Kind
import qualified Data.Map as M
import Data.Maybe
import Data.OpenApi
import Data.OpenApi.Compare.Orphans ()
import Data.OpenApi.Compare.Subtree
import Data.OpenApi.Compare.Validate.Schema.DNF
import Data.OpenApi.Compare.Validate.Schema.TypedJson
import Data.Ord
import Data.Ratio
import Data.Scientific
import qualified Data.Set as S
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.Encoding as T
import Data.Typeable
import Text.Pandoc.Builder hiding (Format, Null)
import Text.Regex.Pcre2
data Bound a = Exclusive !a | Inclusive !a
deriving stock (Eq, Show, Functor)
-- | The order is lexicographical on @a * Bool@.
instance Ord a => Ord (Bound a) where
Exclusive a `compare` Exclusive b = compare a b
Exclusive a `compare` Inclusive b = if a <= b then LT else GT
Inclusive a `compare` Exclusive b = if a < b then LT else GT
Inclusive a `compare` Inclusive b = compare a b
showBound :: Show a => Bound a -> Inlines
showBound (Inclusive x) = show' x <> " inclusive"
showBound (Exclusive x) = show' x <> " exclusive"
data Property = Property
{ propRequired :: Bool
, propFormula :: ForeachType JsonFormula
, propRefSchema :: Traced (Referenced Schema)
}
deriving stock (Eq, Ord, Show)
-- | A primitive structural condition for the "top level" of a JSON value (of a specific type)
data Condition :: JsonType -> Type where
Exactly :: TypedValue t -> Condition t
Maximum :: !(Bound Scientific) -> Condition 'Number
Minimum ::
!(Down (Bound (Down Scientific))) ->
-- | this has the right Ord
Condition 'Number
MultipleOf :: !Scientific -> Condition 'Number
NumberFormat :: !Format -> Condition 'Number
MaxLength :: !Integer -> Condition 'String
MinLength :: !Integer -> Condition 'String
Pattern :: !Pattern -> Condition 'String
StringFormat :: !Format -> Condition 'String
Items ::
!(ForeachType JsonFormula) ->
!(Traced (Referenced Schema)) ->
Condition 'Array
TupleItems ::
![(ForeachType JsonFormula, Traced (Referenced Schema))] ->
Condition 'Array
MaxItems :: !Integer -> Condition 'Array
MinItems :: !Integer -> Condition 'Array
UniqueItems :: Condition 'Array
Properties ::
!(M.Map Text Property) ->
-- | formula for additional properties
!(ForeachType JsonFormula) ->
-- | schema for additional properties, Nothing means bottom
!(Maybe (Traced (Referenced Schema))) ->
Condition 'Object
MaxProperties :: !Integer -> Condition 'Object
MinProperties :: !Integer -> Condition 'Object
deriving stock instance Eq (Condition t)
deriving stock instance Ord (Condition t)
deriving stock instance Show (Condition t)
showCondition :: Condition a -> Blocks
showCondition = \case
(Exactly v) -> para "The value should be:" <> showJSONValue (untypeValue v)
(Maximum b) -> para $ "The value should be less than " <> showBound b <> "."
(Minimum (Down b)) -> para $ "The value should be more than " <> showBound (getDown <$> b) <> "."
(MultipleOf n) -> para $ "The value should be a multiple of " <> show' n <> "."
(NumberFormat p) -> para $ "The number should have the following format:" <> code p <> "."
(Pattern p) -> para "The value should satisfy the following pattern (regular expression):" <> codeBlock p
(StringFormat p) -> para $ "The string should have the following format:" <> code p <> "."
(MaxLength p) -> para $ "The length of the string should be less than or equal to " <> show' p <> "."
(MinLength p) -> para $ "The length of the string should be more than or equal to " <> show' p <> "."
(Items i _) -> para "The items of the array should satisfy:" <> showForEachJsonFormula i
(TupleItems is) -> para ("There should be " <> show' (length is) <> " items in the array:") <> bulletList (showForEachJsonFormula . fst <$> is)
(MaxItems n) -> para $ "The length of the array should be less than or equal to " <> show' n <> "."
(MinItems n) -> para $ "The length of the array should be more than or equal to " <> show' n <> "."
UniqueItems -> para "The elements in the array should be unique."
(Properties props additional _) ->
bulletList $
( M.toList props
<&> ( \(k, p) ->
para (code k)
<> para (strong $ if propRequired p then "Required" else "Optional")
<> showForEachJsonFormula (propFormula p)
)
)
<> [ para (emph "Additional properties")
<> showForEachJsonFormula additional
]
(MaxProperties n) -> para $ "The maximum number of fields should be " <> show' n <> "."
(MinProperties n) -> para $ "The minimum number of fields should be " <> show' n <> "."
where
showForEachJsonFormula :: ForeachType JsonFormula -> Blocks
showForEachJsonFormula i =
bulletList $
foldType
( \t f -> case getJsonFormula $ f i of
BottomDNF -> mempty
(DNF conds) ->
[ para (describeJSONType t)
<> bulletList
( S.toList conds <&> \case
Disjunct (S.toList -> []) -> para "Empty"
Disjunct (S.toList -> cond) -> bulletList (showCondition <$> cond)
)
]
)
showJSONValue :: A.Value -> Blocks
showJSONValue v = codeBlockWith ("", ["json"], mempty) (T.decodeUtf8 . BSL.toStrict . A.encode $ v)
showJSONValueInline :: A.Value -> Inlines
showJSONValueInline v = code (T.decodeUtf8 . BSL.toStrict . A.encode $ v)
show' :: Show x => x -> Inlines
show' = str . T.pack . show
satisfiesTyped :: TypedValue t -> Condition t -> Bool
satisfiesTyped e (Exactly e') = e == e'
satisfiesTyped (TNumber n) (Maximum (Exclusive m)) = n < m
satisfiesTyped (TNumber n) (Maximum (Inclusive m)) = n <= m
satisfiesTyped (TNumber n) (Minimum (Down (Exclusive (Down m)))) = n > m
satisfiesTyped (TNumber n) (Minimum (Down (Inclusive (Down m)))) = n >= m
satisfiesTyped (TNumber n) (MultipleOf m) = denominator (toRational n / toRational m) == 1 -- TODO: could be better #36
satisfiesTyped (TNumber n) (NumberFormat f) = checkNumberFormat f n
satisfiesTyped (TString s) (MaxLength m) = fromIntegral (T.length s) <= m
satisfiesTyped (TString s) (MinLength m) = fromIntegral (T.length s) >= m
satisfiesTyped (TString s) (Pattern p) = isJust $ match p s -- TODO: regex stuff #32
satisfiesTyped (TString s) (StringFormat f) = checkStringFormat f s
satisfiesTyped (TArray a) (Items f _) = all (`satisfies` f) a
satisfiesTyped (TArray a) (TupleItems fs) = length fs == F.length a && and (zipWith satisfies (F.toList a) (fst <$> fs))
satisfiesTyped (TArray a) (MaxItems m) = fromIntegral (F.length a) <= m
satisfiesTyped (TArray a) (MinItems m) = fromIntegral (F.length a) >= m
satisfiesTyped (TArray a) UniqueItems = S.size (S.fromList $ F.toList a) == F.length a -- TODO: could be better #36
satisfiesTyped (TObject o) (Properties props additional _) =
all (`HM.member` o) (M.keys (M.filter propRequired props))
&& all (\(k, v) -> satisfies v $ maybe additional propFormula $ M.lookup k props) (HM.toList o)
satisfiesTyped (TObject o) (MaxProperties m) = fromIntegral (HM.size o) <= m
satisfiesTyped (TObject o) (MinProperties m) = fromIntegral (HM.size o) >= m
checkNumberFormat :: Format -> Scientific -> Bool
checkNumberFormat "int32" (toRational -> n) =
denominator n == 1
&& n >= toRational (minBound :: Int32)
&& n <= toRational (maxBound :: Int32)
checkNumberFormat "int64" (toRational -> n) =
denominator n == 1
&& n >= toRational (minBound :: Int64)
&& n <= toRational (maxBound :: Int64)
checkNumberFormat "float" _n = True
checkNumberFormat "double" _n = True
checkNumberFormat f _n = error $ "Invalid number format: " <> T.unpack f
checkStringFormat :: Format -> Text -> Bool
checkStringFormat "byte" _s = True -- TODO: regex stuff #32
checkStringFormat "binary" _s = True
checkStringFormat "date" _s = True
checkStringFormat "date-time" _s = True
checkStringFormat "password" _s = True
checkStringFormat "uuid" _s = True
checkStringFormat f _s = error $ "Invalid string format: " <> T.unpack f
data SomeCondition where
SomeCondition :: Typeable t => Condition t -> SomeCondition
instance Eq SomeCondition where
SomeCondition x == SomeCondition y = case cast x of
Just x' -> x' == y
Nothing -> False
instance Ord SomeCondition where
compare (SomeCondition x) (SomeCondition y) = case cast x of
Just x' -> compare x' y
Nothing -> compare (typeRep x) (typeRep y)
deriving stock instance Show SomeCondition
newtype JsonFormula t = JsonFormula {getJsonFormula :: DNF (Condition t)}
deriving stock (Eq, Ord, Show)
deriving newtype (Lattice, BoundedJoinSemiLattice, BoundedMeetSemiLattice)
satisfiesFormula :: TypedValue t -> JsonFormula t -> Bool
satisfiesFormula val = foldDNF (satisfiesTyped val) . getJsonFormula
satisfies :: A.Value -> ForeachType JsonFormula -> Bool
satisfies val p = case val of
A.Null -> satisfiesFormula TNull $ forNull p
A.Bool b -> satisfiesFormula (TBool b) $ forBoolean p
A.Number n -> satisfiesFormula (TNumber n) $ forNumber p
A.String s -> satisfiesFormula (TString s) $ forString p
A.Array a -> satisfiesFormula (TArray a) $ forArray p
A.Object o -> satisfiesFormula (TObject o) $ forObject p