packages feed

aeson-commit-1.5: src/Data/Aeson/Commit.hs

{-# LANGUAGE CPP #-}
{-# LANGUAGE GeneralizedNewtypeDeriving #-}

-- |
--   Commit mechanism for aeson's 'Parser'.
--   To commit means that if some initial parsing succeeds, subsequent failures are unrecoverable.
--
--   In the following example, we use '.:>' to look for a key @.nested.value@, and if that does not exist, @.value@.
--
--   > parse o = (o .:> "nested") (withObject "nestedObj" (.: "value"))
--   >         <|> tryParser (o .: "value")
--
--   Not having the key @nested@ is a normal, recoverable failure, and parsing will continue looking for @value@.
--   However, if @nested@ is present but malformed, parsing fails.
--
--   > { value: "foo", otherField: "bar" }
--   > -> Right "foo"
--   >
--   > { value: "foo", nested: { value: "bar" } }
--   > -> Right "bar"
--   >
--   > { value: "foo", nested: { bar: 9 } }
--   > -> Left "Error in $.nested: key \"value\" not found"
--   >
--   > { value: "foo", nested: 9 }
--   > -> Left "Error in $.nested: parsing nestedObj failed, expected Object, but encountered Number"
--   >
--   > {}
--   > -> Left
--   >   "Error in $: No match,
--   >    - key \"value\" not found"
--   >    - key \"nested\" not found"
module Data.Aeson.Commit
  ( commit,
    runCommit,
    (.:>),
    tryParser,
    liftParser,
    Commit (..),
  )
where

import Control.Applicative (Alternative (..))
import Control.Monad.Trans.Class (lift)
import Control.Monad.Trans.Except
import Data.Aeson.Types
import Data.Void (Void, absurd)

#if MIN_VERSION_aeson(2,0,0)
import qualified Data.Aeson.Key as Key
#else
import Data.Text (Text)
#endif

-- | A 'Commit' is a 'Parser' that has two failure modes; recoverable and non-recoverable.
--
--   > tryParser empty <|> p = p
--   > liftParser empty <|> p = empty
--
--   'Commit' is typically constructed using 'commit', and consumed using 'runCommit', which captures its result in a 'Parser'.
--
--   The implementation works by wrapping 'Parser' in an 'ExceptT'.
--   The derived 'Alternative' instance will then only recover from failures in the 'ExceptT'.
--   This means that as soon as we successfully construct a 'Right' value, the 'Alternative' considers the 'Commit' a success, even though the underlying parser might have failed.
--   The 'Void' represents the guarantee that we only collect error values.
newtype Commit a = Commit {unCommit :: ExceptT [Parser Void] Parser a}
  deriving (Monad, Functor, Applicative, Alternative)

-- | Construct a commit.
--   If the first parser fails, the failure is recoverable through 'Alternative'.
--   If the first parser succeeds, the 'Commit' is a success, and any failures in the inner action will be preserved.
commit :: Parser a -> (a -> Parser b) -> Commit b
commit pre post = Commit $ do
  a <- ExceptT $ captureError pre
  lift $ post a
  where
    -- TODO maybe there's a better way to prove something is an error
    captureError :: Parser b -> Parser (Either [Parser Void] b)
    captureError p = Right <$> p <|> pure (Left [fmap (const undefined) p])

-- | Run a 'Commit', capturing its result in a 'Parser'.
runCommit :: Commit a -> Parser a
runCommit (Commit f) = runExceptT f >>= either handleErrors pure
  where
    handleErrors :: [Parser Void] -> Parser a
    handleErrors [] = fail "No parsers tried"
    handleErrors (p : ps) = fmap absurd (go (p : ps) [] [])
      where
        -- TODO: how do we handle the multiple JSONPaths?
        -- Right now the rightmost failure's path is used when presenting
        -- the error message. Ideally one path per error would be preferable but
        -- `aeson` doesn't support such a thing. When errors are reported in `aeson`
        -- a single JSONPath defines how the error message is presented.
        go [] path errors = parserThrowError path ("No match,\n" <> unlines (fmap ("- " <>) errors))
        go (y : ys) _ msgs = parserCatchError y $ \path msg ->
          go ys path (msg : msgs)

-- | Convenience wrapper around 'commit' for when the commit is checking whether a key is present in some object.
--   If it is, it will commit and append the key to the JSONPath of the inner context through '<?>', which will give nicer error messages.

#if MIN_VERSION_aeson(2,0,0)
(.:>)  :: FromJSON a => Object -> Key.Key -> (a -> Parser b) -> Commit b
#else
(.:>)  :: FromJSON a => Object -> Text -> (a -> Parser b) -> Commit b
#endif
(o .:> k) cont = commit (o .: k) (\v -> cont v <?> Key k)

-- | Turn a 'Parser' into a 'Commit'
--   Unlike 'liftParser', the parser's failure is recoverable.
--
-- > tryParser empty <|> p = p
-- > tryParser p = commit p pure
tryParser :: Parser a -> Commit a
tryParser p = commit p pure

-- | Turn a 'Parser' into a 'Commit'.
--   Unlike 'tryParser', the parser's failure is _not_ recoverable, i.e. the parse is always committed.
--
-- > liftParser empty <|> p = empty
-- > liftParser p = commit (pure ()) (const p)
liftParser :: Parser a -> Commit a
liftParser p = commit (pure ()) (const p)