packages feed

conferer-aeson-1.1.0.2: src/Conferer/Source/Aeson.hs

-- |
-- Copyright: (c) 2019 Lucas David Traverso
-- License: MPL-2.0
-- Maintainer: Lucas David Traverso <lucas6246@gmail.com>
-- Stability: stable
-- Portability: portable
--
-- Source for json config files using Aeson
{-# LANGUAGE CPP #-}
{-# LANGUAGE RecordWildCards #-}
module Conferer.Source.Aeson where

#if MIN_VERSION_aeson(2,0,0)
import Data.Aeson hiding (Key)
import qualified Data.Aeson.KeyMap as KeyMap
import qualified Data.Aeson.Key as Key
#else
import Data.Aeson
import qualified Data.HashMap.Strict as KeyMap
#endif
import Control.Applicative
import Data.Text (Text)
import qualified Data.Text as Text
import qualified Data.Text.Encoding as Text
import Data.Vector ((!?))
import qualified Data.Vector as Vector
import Text.Read (readMaybe)
import qualified Data.ByteString as B
import qualified Data.ByteString.Lazy as L
import Data.List (intersperse, sort)
import System.Directory (doesFileExist)
import Control.Exception
import Control.Monad (guard)

import Conferer.Source.Files
import qualified Conferer.Source.Null as Null
import Conferer.Source

-- | 'Source' that read a config file as json and uses that value in a way that
-- makes sense for Conferer but doesn't respect json perfectly.
data JsonSource = JsonSource
  { value :: Value
  } deriving (Show, Eq)

instance IsSource JsonSource where
  getKeyInSource JsonSource {..} key = do
    return $ valueToText =<< traverseJSON key value
  getSubkeysInSource JsonSource {..} key = do
    return $ fmap (key /.) $ maybe [] listKeysInJSON $ traverseJSON key value

-- | Create a 'SourceCreator' which uses files with @config/{env}.json@
-- template and then uses 'fromFilePath'
fromConfig :: Key -> SourceCreator
fromConfig key config = do
  fileToParse <- getFilePathFromEnv key "json" config
  fromFilePath' fileToParse

-- | Create a 'SourceCreator' from a filepath
--
-- If the file is not present it will behave as if it had no keys.
--
-- If the file doesn't have valid json it will throw an error.
fromFilePath :: FilePath -> SourceCreator
fromFilePath fileToParse _config =
  fromFilePath' fileToParse

-- | Create a 'Source' from a filepath
--
-- If the file is not present it will behave as if it had no keys.
--
-- If the file doesn't have valid json it will throw an error.
fromFilePath' :: FilePath -> IO Source
fromFilePath' fileToParse = do
  fileExists <- doesFileExist fileToParse
  if fileExists
    then do
      value <- decodeStrict' <$> B.readFile fileToParse
      case value of
        Nothing ->
          error $ "Failed to decode json file '" ++ fileToParse ++ "'"
        Just v -> do
          case invalidJsonKeys v of
            [] ->
              return $ fromValue v
            errors ->
              throwIO $ JsonHasInvalidKeysError fileToParse errors
    else do
      return $ Null.empty

-- | Exception thrown from 'fromFilePath' when the json in the
-- parsed file has incorrect keys
data JsonHasInvalidKeysError =
  JsonHasInvalidKeysError FilePath [RawKey] deriving (Eq, Show)

instance Exception JsonHasInvalidKeysError

-- | Create a 'Source' from a json value, never fails.
fromValue :: Value -> Source
fromValue value =
  Source JsonSource {..}

-- | Traverse a 'Value' using a 'Key' to get a 'Value'.
--
-- This function can nest objects and arrays when keys are nested
--
-- @
-- 'traverseJSON' "a.b" {a: {b: 12}} == Just "12"
-- 'traverseJSON' "a.b" {a: {b: false}} == Just "false"
-- 'traverseJSON' "a" {a: {b: false}} == Nothing
-- 'traverseJSON' "1" [false, true] == Just "true"
-- 'traverseJSON' "0.a" [{a: "hi"}] == Just "hi"
-- 'traverseJSON' "0" [] == Nothing
-- @
traverseJSON :: Key -> Value -> Maybe Value
traverseJSON key value =
 case (unconsKey key, value) of
   (Nothing, v) ->
     Just v
   (Just ("keys", ""), Object o) ->
      KeyMap.lookup "keys" o
        <|> pure (
              String $
#if MIN_VERSION_aeson(2,0,0)
              Key.toText $
#endif
              mconcat $
              intersperse "," $
              sort $
              KeyMap.keys o)
   (Just (c, ks), Object o) ->
     KeyMap.lookup
#if MIN_VERSION_aeson(2,0,0)
      (Key.fromText c)
#else
      c
#endif
      o >>= traverseJSON ks
   (Just ("keys", ""), Array vs) ->
      Just $
        String $
        mconcat $
        intersperse "," $
        fmap (Text.pack . show)
        [0..length vs - 1]
   (Just (c, ks), Array vs) -> do
     n :: Int <- readMaybe $ Text.unpack c
     v <- vs !? n
     traverseJSON ks v
   (Just _, _) ->
     Nothing

-- | Get the list of available keys inside a json value
listKeysInJSON :: Value -> [Key]
listKeysInJSON = go ""
  where
  go :: Key -> Value -> [Key]
  go key value =
    case (unconsKey key, value) of
      (_, Object o) ->
        let
          self =
            case valueToText <$> KeyMap.lookup "_self" o of
              Just _ -> [key]
              Nothing -> []
        in self ++ do
          (k, v) <- KeyMap.toList o
          let textK =
#if MIN_VERSION_aeson(2,0,0)
                Key.toText k
#else
                k
#endif
          guard $ isValidKeyFragment textK
          go (key /. fromText textK) v
      (_, Array as) -> do
        (index :: Integer, v) <- zip [0..] $ Vector.toList as
        go (key /. mkKey (show index)) v
      (Nothing, _) -> []
      (_, _) -> [key]

-- | Turn json 'Value' into 'Text' to return that key
valueToText :: Value -> Maybe Text
valueToText (String t) = Just t
valueToText (Object o) = do
  selfValue <- KeyMap.lookup "_self" o
  valueToText selfValue
valueToText (Array _as) = Nothing
valueToText (Number n) = Just $ Text.decodeUtf8 $ L.toStrict $ encode $ Number n
valueToText (Bool b) = Just $ boolToString b
valueToText (Null) = Nothing

-- | Turn a 'GHC.Types.Bool' into a 'Text'
boolToString :: Bool -> Text
boolToString True = "true"
boolToString False = "false"

-- | Because we use an old version of aeson
resultToMaybe :: Result a -> Maybe a
resultToMaybe (Error _) = Nothing
resultToMaybe (Success a) = Just a

type RawKey = [Text]

-- | Validates that a json has the correct format for keys,
-- since Conferer 'Key's are pretty restricted.
--
-- The Source will work with incorrect keys but they will
-- be ignored.
invalidJsonKeys :: Value -> [RawKey]
invalidJsonKeys = filter (not . validKey) . allKeys
  where
    validFragmentForJSON :: Text -> Bool
    validFragmentForJSON fragment = isValidKeyFragment fragment || fragment == "_self"
    validKey :: RawKey -> Bool
    validKey fragments = all validFragmentForJSON fragments

-- | Returns all keys in a json object
allKeys :: Value -> [RawKey]
allKeys = go mempty
  where
    go :: RawKey -> Value -> [RawKey]
    go rawkey value =
      case value of
        Object o ->
          let
            keys =
              fmap (\t -> rawkey ++ [t])
#if MIN_VERSION_aeson(2,0,0)
              . fmap Key.toText
#endif
              . KeyMap.keys
              $ o
          in keys ++ do
          (k, v) <- KeyMap.toList o
          let textK =
#if MIN_VERSION_aeson(2,0,0)
                Key.toText k
#else
                k
#endif
          let subkey = rawkey ++ [textK]
          go subkey v
        Array as -> do
          (index :: Integer, v) <- zip [0..] $ Vector.toList as
          let subkey = rawkey ++ [Text.pack $ show index]
          go subkey v
        _ -> []