packages feed

keiro-ops-http-0.22.0.0: src/Keiro/Ops/Http/Route.hs

module Keiro.Ops.Http.Route
  ( RouteError (..),
    renderRouteError,
    flagNames,
    translateRequest,
    ParseOutcome (..),
    parseArguments,
    parseArgumentsWithParser,
  )
where

import Data.Char (isAsciiLower, isDigit)
import Data.Map.Strict (Map)
import Data.Map.Strict qualified as Map
import Data.Set (Set)
import Data.Set qualified as Set
import Data.Text (Text)
import Data.Text qualified as Text
import Keiro.Ops (AppHooks, Command, commandParser)
import Options.Applicative qualified as Options
import Options.Applicative.Common (mapParser)
import Options.Applicative.Types qualified as Types

data RouteError
  = EmptyPathSegment
  | OptionLikePathSegment !Text
  | InvalidQueryKey !Text
  | InvalidFlagValue !Text !Text
  | ReservedQueryKey !Text
  deriving stock (Eq, Show)

renderRouteError :: RouteError -> Text
renderRouteError = Text.pack . show

data Grammar = Grammar !(Set Text) !(Map Text Grammar)

-- | Partially apply at startup: metadata is shared across requests. Sibling
-- commands may use the same long option with different arities.
flagNames :: Options.Parser a -> [Text] -> Set Text
flagNames parser = select Set.empty (grammar parser)
  where
    select inherited (Grammar flags commands) path =
      let current = inherited <> flags
       in case path of
            word : rest | Just child <- Map.lookup word commands -> select current child rest
            _ -> current

grammar :: Options.Parser a -> Grammar
grammar parser = foldr combine (Grammar Set.empty Map.empty) (mapParser optionGrammar parser)
  where
    combine (Grammar a b) (Grammar c d) = Grammar (a <> c) (b <> d)
    optionGrammar :: Types.ArgumentReachability -> Types.Option x -> Grammar
    optionGrammar _ option = case Types.optMain option of
      Types.FlagReader names _ -> Grammar (Set.fromList [Text.pack name | Types.OptLong name <- names]) Map.empty
      Types.CmdReader _ commands ->
        Grammar Set.empty (Map.fromList [(Text.pack name, grammar (Types.infoParser child)) | (name, child) <- commands])
      _ -> Grammar Set.empty Map.empty

translateRequest :: Set Text -> [Text] -> [(Text, Maybe Text)] -> Either RouteError [Text]
translateRequest flags path query = do
  traverse_ checkSegment path
  options <- concat <$> traverse translate query
  pure (path <> options)
  where
    traverse_ f = foldr ((>>) . f) (pure ())
    checkSegment segment
      | Text.null segment = Left EmptyPathSegment
      | "-" `Text.isPrefixOf` segment = Left (OptionLikePathSegment segment)
      | otherwise = Right ()
    translate (key, maybeValue)
      | key `elem` ["json", "force", "database_url", "allow_schema_drift", "help"] = Left (ReservedQueryKey key)
      | Text.null key || not (Text.all (\c -> isAsciiLower c || isDigit c || c == '_') key) = Left (InvalidQueryKey key)
      | Set.member name flags = case value of
          "true" -> Right ["--" <> name]
          "false" -> Right []
          _ -> Left (InvalidFlagValue key value)
      | otherwise = Right ["--" <> name <> "=" <> value]
      where
        name = Text.replace "_" "-" key
        value = maybe "" id maybeValue

data ParseOutcome = Parsed !Command | Rejected !Text

parseArguments :: AppHooks -> [Text] -> ParseOutcome
parseArguments hooks = parseArgumentsWithParser (commandParser hooks)

-- | Share the immutable command grammar across requests at one mount. This
-- does not cache command results or bypass any owning-library validation.
parseArgumentsWithParser :: Options.Parser Command -> [Text] -> ParseOutcome
parseArgumentsWithParser parser arguments =
  case Options.execParserPure Options.defaultPrefs (Options.info parser Options.fullDesc) (map Text.unpack arguments) of
    Options.Success command -> Parsed command
    Options.Failure failure -> Rejected (Text.pack (fst (Options.renderFailure failure "keiro-ops")))
    Options.CompletionInvoked _ -> Rejected "shell completion is not available over HTTP"