packages feed

compaREST-0.1.0.0: src/Data/OpenApi/Compare/Validate/Schema.hs

{-# OPTIONS_GHC -Wno-orphans #-}

module Data.OpenApi.Compare.Validate.Schema
  (
  )
where

import Control.Monad.Writer
import qualified Data.Aeson as A
import Data.Coerce
import Data.Foldable (for_, toList)
import Data.Functor
import Data.HList
import Data.List (genericIndex, genericLength, group)
import qualified Data.List.NonEmpty as NE
import qualified Data.Map as M
import Data.Maybe
import Data.OpenApi
import Data.OpenApi.Compare.Behavior
import Data.OpenApi.Compare.Paths
import qualified Data.OpenApi.Compare.PathsPrefixTree as P
import Data.OpenApi.Compare.Subtree
import Data.OpenApi.Compare.Validate.Schema.DNF
import Data.OpenApi.Compare.Validate.Schema.Issues
import Data.OpenApi.Compare.Validate.Schema.JsonFormula
import Data.OpenApi.Compare.Validate.Schema.Partition
import Data.OpenApi.Compare.Validate.Schema.Process
import Data.OpenApi.Compare.Validate.Schema.Traced
import Data.OpenApi.Compare.Validate.Schema.TypedJson
import Data.Ord
import Data.Ratio
import Data.Semigroup
import qualified Data.Set as S
import Data.Text (Text)

checkFormulas ::
  (ReassembleHList xs (CheckEnv (Referenced Schema))) =>
  HList xs ->
  Behavior 'SchemaLevel ->
  ProdCons (Trace Schema) ->
  ProdCons (Traced (Definitions Schema)) ->
  ProdCons (ForeachType JsonFormula, P.PathsPrefixTree Behave AnIssue 'SchemaLevel) ->
  SemanticCompatFormula ()
checkFormulas env beh trs defs (ProdCons (fp, ep) (fc, ec)) =
  case P.toList ep ++ P.toList ec of
    issues@(_ : _) -> for_ issues $ embedFormula beh . anItem
    [] -> do
      -- We have the following isomorphisms:
      --   (A ⊂ X ∩ Y) = (A ⊂ X) /\ (A ⊂ Y)
      --   (A ⊂ ⊤) = 1
      --   (X ∪ Y ⊂ B) = (X ⊂ B) /\ (Y ⊂ B)
      --   (∅ ⊂ B) = 1
      -- The remaining cases are, notably, not isomorphisms:
      --   1) (A ⊂ X ∪ Y) <= (A ⊂ X) \/ (A ⊂ Y)
      --   2) (A ⊂ ∅) <= 0
      --   3) (X ∩ Y ⊂ B) <= (X ⊂ B) \/ (Y ⊂ B)
      --   4) (⊤ ⊂ B) <= 0
      -- Therefore we have the implications with (∃ and ∀ being the N-ary
      -- versions of \/ and /\ respectively):
      --   (⋃_i ⋂_j A[i,j]) ⊂ (⋃_k ⋂_l B[k,l])
      --   <= ∃k ∀l ∀i, (⋂_j A[i,j]) ⊂ B[k,l]
      --   = ∀i ∃k ∀l, (⋂_j A[i,j]) ⊂ B[k,l]
      -- with the caveat that the the set over which k ranges is nonempty.
      -- (because 2) is not an isomorphism), and that this is a sufficient
      -- but not necessary condition (because 1) is not an isomorphism).
      -- Our disjunction loses information, so it makes sense to nest it as
      -- deeply as possible, hence we choose the latter representation.
      --
      -- We delegate the verification of (⋂_j A[j]) ⊂ B to a separate heuristic
      -- function, with the understanding that ∃j, A[j] ⊂ B is a sufficient,
      -- but not necessary condition (because of 3) and 4)).
      --
      -- If k ranges over an empty set, we have the isomorphism:
      --   (⋃_i ⋂_j A[i,j]) ⊂ ∅ = ∀i, (⋂_j A[i,j]) ⊂ ∅
      -- where we again delegate (⋂_j A[j]) ⊂ ∅ to a heuristic, though here the
      -- shortcut of ∃j, A[j] ⊂ ∅ hardly helps.
      --
      -- Disjunctions tend to erase informative error messages, so we may want
      -- to avoid them. This can be formally done as follows: if we can
      -- partition the universal set into a disjoint union of some parts:
      --   ⊤ = ⊔_α P[α]
      -- such that the conjuncts in our disjunctive normal form are subordinate
      -- to the partition:
      --   ∀i ∃α, (⋂_j A[i,j]) ⊂ P[α]
      --   ∀k ∃α, (⋂_l B[k,l]) ⊂ P[α]
      -- then we can partition the sets over which i and k range into partitions
      -- I[α] and K[α], and then in each "bucket" verify the inclusion in the
      -- aforementioned way:
      --   ∀α, (⋃_i∈I[α] ⋂_j A[i,j]) ⊂ (⋃_k∈K[α] ⋂_l B[k,l])
      --   = ∀α ∀i∈I[α] ∃k∈K[α] ∀l, (⋂_j A[i,j]) ⊂ B[k,l]
      -- We already somewhat do this by partitioning JSON into types, but we can
      -- additionally partition e.g. "enum" fields or existence of particular
      -- properties. This works especially well if we manage to ensure K[α] are
      -- 1-element sets.
      --
      -- Since the set:
      --   (⋃_i∈I[α] ⋂_j A[i,j]) = (⋃_i ⋂_j A[i,j]) ∩ P[α]
      -- does not actually appear in the source schema, we need to construct it
      -- ourselves and come up with a name for it.
      let typesRestricted = not (anyBottomTypes fp) && anyBottomTypes fc
      -- Specifically handle the case when a schema's type has been
      -- restricted from "all" to specific types: if all types were allowed
      -- in the producer and not all types are allowed in the consumer, it's
      -- usually easier to say what's left than what's removed
      when typesRestricted $ issueAt beh $ TypesRestricted $ nonBottomTypes fc
      forType_ $ \tyName ty -> do
        let beh' = beh >>> step (OfType tyName)
        case (getJsonFormula $ ty fp, getJsonFormula $ ty fc) of
          (DNF pss, BottomDNF) -> unless typesRestricted $ do
            -- don't repeat the TypesRestricted issue
            for_ pss $ \(Disjunct ps) -> checkContradiction beh' Nothing ps
          (DNF pss, SingleDisjunct (Disjunct cs)) -> for_ pss $ \(Disjunct ps) -> do
            for_ cs $ checkImplication env beh' trs ps -- avoid disjunction if there's only one conjunct
          (TopDNF, DNF css) ->
            -- producer is "open" (allows any value), but consumer has restrictions.
            -- In this case we want to show which restrictions were added. (instead
            -- of showing an empty list restrictions that couldn't be satisfied.)
            for_ css $ \(Disjunct cs) -> for_ cs $ checkImplication env beh' trs S.empty
          (pss', css') -> for_ (tryPartition defs $ ProdCons (JsonFormula pss') (JsonFormula css')) $ \case
            (mPart, ProdCons pf cf) -> do
              let beh'' = foldr ((<<<) . step . InPartition) beh' mPart
              case (getJsonFormula pf, getJsonFormula cf) of
                (DNF pss, BottomDNF) -> for_ pss $ \(Disjunct ps) -> checkContradiction beh' mPart ps
                (DNF pss, SingleDisjunct (Disjunct cs)) -> for_ pss $ \(Disjunct ps) -> do
                  for_ cs $ checkImplication env beh'' trs ps
                -- unlucky:
                (DNF pss, DNF css) -> for_ pss $ \(Disjunct ps) -> do
                  anyOfAt
                    beh'
                    (issueFromDisjunct Nothing ps)
                    [for_ cs $ checkImplication env beh' trs ps | Disjunct cs <- S.toList css]
      pure ()
  where
    anyBottomTypes f = getAny $
      foldType $ \_ ty -> case getJsonFormula $ ty f of
        BottomDNF -> Any True
        _ -> mempty
    nonBottomTypes f = foldType $ \tyName ty -> case getJsonFormula $ ty f of
      BottomDNF -> mempty
      _ -> [tyName]
    issueFromDisjunct :: Typeable t => Maybe Partition -> S.Set (Condition t) -> Issue 'TypedSchemaLevel
    issueFromDisjunct _ ps
      | Just e <- findExactly ps
        , all (satisfiesTyped e) ps =
        EnumDoesntSatisfy $ untypeValue e -- what does this look like when partitioned?
    issueFromDisjunct mPart ps = NoMatchingCondition mPart $ SomeCondition <$> S.toList ps

checkContradiction ::
  Behavior 'TypedSchemaLevel ->
  Maybe Partition ->
  S.Set (Condition t) ->
  SemanticCompatFormula ()
checkContradiction beh mPart _ = issueAt beh $ maybe TypeBecomesEmpty PartitionBecomesEmpty mPart -- TODO #70

checkImplication ::
  (ReassembleHList xs (CheckEnv (Referenced Schema))) =>
  HList xs ->
  Behavior 'TypedSchemaLevel ->
  ProdCons (Trace Schema) -> -- the traces of the root schemas used in this comparison
  S.Set (Condition t) ->
  Condition t ->
  SemanticCompatFormula ()
checkImplication env beh trs prods cons = case findExactly prods of
  Just e
    | all (satisfiesTyped e) prods ->
      if satisfiesTyped e cons
        then pure ()
        else issueAt beh (EnumDoesntSatisfy $ untypeValue e)
    | otherwise -> pure () -- vacuously true
  Nothing -> case cons of
    -- the above code didn't catch it, so there's no Exactly condition on the lhs
    Exactly e -> issueAt beh (NoMatchingEnum $ untypeValue e)
    Maximum m -> foldCheck min m NoMatchingMaximum MatchingMaximumWeak $ \case
      Maximum m' -> Just m'
      _ -> Nothing
    Minimum m -> foldCheck max m (NoMatchingMinimum . coerce) (MatchingMinimumWeak . coerce) $ \case
      Minimum m' -> Just m'
      _ -> Nothing
    MultipleOf m -> foldCheck lcmScientific m NoMatchingMultipleOf MatchingMultipleOfWeak $ \case
      MultipleOf m' -> Just m'
      _ -> Nothing
    NumberFormat f -> case flip any prods $ \case
      NumberFormat f' -> f == f'
      _ -> False of
      True -> pure ()
      False -> issueAt beh (NoMatchingFormat f)
    MaxLength m -> foldCheck min m NoMatchingMaxLength MatchingMaxLengthWeak $ \case
      MaxLength m' -> Just m'
      _ -> Nothing
    MinLength m -> foldCheck max m NoMatchingMinLength MatchingMinLengthWeak $ \case
      MinLength m' -> Just m'
      _ -> Nothing
    Pattern p -> case flip any prods $ \case
      Pattern p' -> p == p'
      _ -> False of
      True -> pure ()
      False -> issueAt beh (NoMatchingPattern p) -- TODO: regex comparison #32
    StringFormat f -> case flip any prods $ \case
      StringFormat f' -> f == f'
      _ -> False of
      True -> pure ()
      False -> issueAt beh (NoMatchingFormat f)
    Items _ cons' -> case foldSome (<>) prods $ \case
      Items _ rs -> Just (Just (rs NE.:| []), mempty)
      TupleItems (map snd -> fs) -> Just (mempty, Just (fs NE.:| []))
      _ -> Nothing of
      Just (mItems, Just pfs)
        | not $ allSame (length <$> pfs) -> pure () -- vacuously
        | let plen = genericLength (NE.head pfs) ->
          clarifyIssue (AnItem beh (anIssue TupleToArray)) $
            for_ [0 .. plen - 1] $ \i -> do
              let prod' = tracedConjunct $ case mItems of
                    Just prods' -> ((`genericIndex` i) <$> pfs) <> prods'
                    Nothing -> (`genericIndex` i) <$> pfs
              checkCompatibility (beh >>> step (InItem i)) env $ ProdCons prod' cons'
      Just (Just prods', Nothing) -> do
        let prod' = tracedConjunct prods'
        checkCompatibility (beh >>> step InItems) env $ ProdCons prod' cons'
      _ -> clarifyIssue (AnItem beh (anIssue NoMatchingItems)) $ do
        checkCompatibility (beh >>> step InItems) env $ ProdCons prodTopSchema cons'
    TupleItems (map snd -> fs) -> case foldSome (<>) prods $ \case
      TupleItems (map snd -> fs') -> Just (Just $ fs' NE.:| [], Just . Max $ genericLength fs', Just . Min $ genericLength fs', mempty)
      MinItems m' -> Just (mempty, Just . Max $ m', mempty, mempty)
      MaxItems m' -> Just (mempty, mempty, Just . Min $ m', mempty)
      Items _ rs -> Just (mempty, mempty, mempty, Just (rs NE.:| []))
      _ -> Nothing of
      -- if the length constraints in the producer are contradictory:
      Just (_, Just (Max lowest), Just (Min highest), _) | lowest > highest -> pure ()
      -- We have an explicit tuple items clause...
      Just (Just pfs, Just (Max plen), _, _)
        | plen /= genericLength fs -> -- ...of wrong length
          issueAt beh (TupleItemsLengthChanged ProdCons {producer = plen, consumer = genericLength fs})
        | otherwise ->
          for_ [0 .. plen - 1] $ \i -> do
            checkCompatibility (beh >>> step (InItem i)) env $ ProdCons (tracedConjunct $ (`genericIndex` i) <$> pfs) (fs `genericIndex` i)
      -- We have a fixed length array in the producer...
      Just (Nothing, Just (Max plen), Just (Min plen'), mProd)
        | plen == plen' ->
          clarifyIssue (AnItem beh (anIssue ArrayToTuple)) $ case mProd of
            _
              | plen /= genericLength fs -> -- ...of wrong length
                issueAt beh (TupleItemsLengthChanged ProdCons {producer = plen, consumer = genericLength fs})
            Just rs -> for_ [0 .. plen - 1] $ \i -> do
              checkCompatibility (beh >>> step (InItem i)) env $ ProdCons (tracedConjunct rs) (fs `genericIndex` i)
            -- ...and no "items" schema
            Nothing -> clarifyIssue (AnItem beh (anIssue NoMatchingTupleItems)) $ do
              for_ [0 .. plen - 1] $ \i -> do
                checkCompatibility (beh >>> step (InItem i)) env $ ProdCons prodTopSchema (fs `genericIndex` i)
      _ -> issueAt beh NoMatchingTupleItems
    MaxItems m -> foldCheck min m NoMatchingMaxItems MatchingMaxItemsWeak $ \case
      MaxItems m' -> Just m'
      TupleItems fs -> Just $ toInteger $ length fs
      _ -> Nothing
    MinItems m -> foldCheck max m NoMatchingMinItems MatchingMinItemsWeak $ \case
      MinItems m' -> Just m'
      TupleItems fs -> Just $ toInteger $ length fs
      _ -> Nothing
    UniqueItems -> case flip any prods $ \case
      UniqueItems -> True
      MaxItems 1 -> True
      TupleItems fs | length fs == 1 -> True
      _ -> False of
      True -> pure ()
      False -> issueAt beh NoMatchingUniqueItems
    Properties props _ madd -> case foldSome (<>) prods $ \case
      Properties props' _ madd' -> Just $ (props', madd') NE.:| []
      _ -> Nothing of
      Just pm ->
        anyOfAt beh NoMatchingProperties $ -- TODO: could first "concat" the lists
          NE.toList pm <&> \(props', madd') -> do
            for_ (S.fromList $ M.keys props <> M.keys props') $ \k -> do
              let beh' = beh >>> step (InProperty k)
                  go sch sch' = checkCompatibility beh' env (ProdCons sch sch')
              case (maybe False propRequired $ M.lookup k props', maybe False propRequired $ M.lookup k props) of
                -- producer does not require field, but consumer does (can fail)
                (False, True) -> issueAt beh' PropertyNowRequired
                _ -> pure ()
              case (M.lookup k props', madd', M.lookup k props, madd) of
                -- (producer, additional producer, consumer, additional consumer)
                (Nothing, Nothing, _, _) -> pure () -- vacuously: the producer asserts that this field cannot exist,
                -- and the consumer either doesn't require it, or it does and we've already raised an error about it.
                (_, _, Nothing, Nothing) -> issueAt beh' UnexpectedProperty
                (Just p', _, Just p, _) -> go (propRefSchema p') (propRefSchema p)
                (Nothing, Just add', Just p, _) ->
                  clarifyIssue (AnItem beh' (anIssue AdditionalToProperty)) $
                    go add' (propRefSchema p)
                (Just p', _, Nothing, Just add) ->
                  clarifyIssue (AnItem beh' (anIssue PropertyToAdditional)) $
                    go (propRefSchema p') add
                (Nothing, Just _, Nothing, Just _) -> pure ()
              pure ()
            case (madd', madd) of
              (Nothing, _) -> pure () -- vacuously
              (_, Nothing) -> issueAt beh NoAdditionalProperties
              (Just add', Just add) -> checkCompatibility (beh >>> step InAdditionalProperty) env (ProdCons add' add)
            pure ()
      Nothing -> issueAt beh NoMatchingProperties
    MaxProperties m -> foldCheck min m NoMatchingMaxProperties MatchingMaxPropertiesWeak $ \case
      MaxProperties m' -> Just m'
      _ -> Nothing
    MinProperties m -> foldCheck max m NoMatchingMinProperties MatchingMinPropertiesWeak $ \case
      MinProperties m' -> Just m'
      _ -> Nothing
  where
    lcmScientific (toRational -> a) (toRational -> b) =
      fromRational $ lcm (numerator a) (numerator b) % gcd (denominator a) (denominator b)

    foldCheck ::
      Eq a =>
      (a -> a -> a) ->
      a ->
      (a -> Issue 'TypedSchemaLevel) ->
      (ProdCons a -> Issue 'TypedSchemaLevel) ->
      (forall t. Condition t -> Maybe a) ->
      SemanticCompatFormula ()
    foldCheck f m missing weak extr = case foldSome f prods extr of
      Just m'
        | f m' m == m' -> pure ()
        | otherwise -> issueAt beh (weak ProdCons {producer = m', consumer = m})
      Nothing -> issueAt beh (missing m)

    prodTopSchema = traced (producer trs >>> step ImplicitTopSchema) $ Inline mempty

allSame :: (Foldable f, Eq a) => f a -> Bool
allSame xs = case group (toList xs) of
  [] -> True
  [_] -> True
  _ -> False

foldSome :: (b -> b -> b) -> S.Set a -> (a -> Maybe b) -> Maybe b
foldSome combine xs extr =
  fmap (foldr1 combine) . NE.nonEmpty . mapMaybe extr . S.toList $ xs

findExactly :: S.Set (Condition t) -> Maybe (TypedValue t)
findExactly xs = foldSome const xs $ \case
  Exactly x -> Just x
  _ -> Nothing

instance Subtree Schema where
  type SubtreeLevel Schema = 'SchemaLevel
  type CheckEnv Schema = '[ProdCons (Traced (Definitions Schema))]
  checkStructuralCompatibility env pc = do
    structuralEq $ fmap _schemaRequired <$> pc
    structuralEq $ fmap _schemaNullable <$> pc
    structuralMaybeWith (structuralList env) $ tracedAllOf <$> pc
    structuralMaybeWith (structuralList env) $ tracedOneOf <$> pc
    structuralMaybe env $ sequence . stepTraced NotStep . fmap _schemaNot <$> pc
    structuralMaybeWith (structuralList env) $ tracedAnyOf <$> pc
    iohmStructural env $ stepTraced PropertiesStep . fmap _schemaProperties <$> pc
    structuralMaybeWith structuralAdditionalProperties $ tracedAdditionalProperties <$> pc
    structuralMaybeWith structuralDiscriminator $ tracedDiscriminator <$> pc
    structuralEq $ fmap _schemaReadOnly <$> pc
    structuralEq $ fmap _schemaWriteOnly <$> pc
    structuralEq $ fmap _schemaXml <$> pc
    structuralEq $ fmap _schemaMaxProperties <$> pc
    structuralEq $ fmap _schemaMinProperties <$> pc
    structuralEq $ fmap _schemaDefault <$> pc
    structuralEq $ fmap _schemaType <$> pc
    structuralEq $ fmap _schemaFormat <$> pc
    structuralMaybeWith structuralItems $ tracedItems <$> pc
    structuralEq $ fmap _schemaMaximum <$> pc
    structuralEq $ fmap _schemaExclusiveMaximum <$> pc
    structuralEq $ fmap _schemaMinimum <$> pc
    structuralEq $ fmap _schemaExclusiveMinimum <$> pc
    structuralEq $ fmap _schemaMaxLength <$> pc
    structuralEq $ fmap _schemaMinLength <$> pc
    structuralEq $ fmap _schemaPattern <$> pc
    structuralEq $ fmap _schemaMaxItems <$> pc
    structuralEq $ fmap _schemaMinItems <$> pc
    structuralEq $ fmap _schemaUniqueItems <$> pc
    structuralEq $ fmap _schemaEnum <$> pc
    structuralEq $ fmap _schemaMultipleOf <$> pc
    pure ()
    where
      structuralAdditionalProperties
        (ProdCons (Left x) (Left y)) = unless (x == y) structuralIssue
      structuralAdditionalProperties
        (ProdCons (Right x) (Right y)) =
          checkSubstructure env $ ProdCons x y
      structuralAdditionalProperties _ = structuralIssue
      structuralDiscriminator pc' = do
        structuralEq $ fmap _discriminatorPropertyName <$> pc'
        iohmStructural env $
          stepTraced DiscriminatorMapping . fmap (fmap parseDiscriminatorValue . _discriminatorMapping) <$> pc'
        pure ()
      structuralItems (ProdCons (Left a) (Left b)) =
        checkSubstructure env $ ProdCons a b
      structuralItems (ProdCons (Right a) (Right b)) =
        structuralList env $ ProdCons a b
      structuralItems _ = structuralIssue
  checkSemanticCompatibility env beh schs = do
    let defs = getH env
    checkFormulas env beh (ask <$> schs) defs $ schemaToFormula <$> defs <*> schs

parseDiscriminatorValue :: Text -> Referenced Schema
parseDiscriminatorValue v = case A.fromJSON @(Referenced Schema) $ A.object ["$ref" A..= v] of
  A.Success x -> x
  A.Error _ -> Ref $ Reference v