packages feed

hstratus-auth-0.1.0.0: src-cli/Network/HStratus/Http/Cli.hs

{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE TypeApplications #-}

module Network.HStratus.Http.Cli
  ( -- * Common CLI options
    CommonOpts (..)
  , commonOptsParser

    -- * Log target resolution
  , resolveLogTarget
  , defaultLogFile

    -- * Logger selection
  , mkLoggerFor

    -- * Authenticated API runner
  , runWithApi

    -- * Error handler
  , onServiceError
  )
where

import Control.Exception (catch, displayException)
import Network.HStratus.Http
  ( Api
  , ApiLogger
  , AuthError
  , AuthState (..)
  , HStratusError
  , fileLogger
  , login
  , mkApiWith
  , redactingLogger
  , verboseLogger
  , withLogger
  )
import Network.HStratus.Http.Endpoints (Realm (..), realmEndpoints)
import Network.HStratus.Session (AccountData, Session, loadSession)
import Network.HTTP.Client.TLS (newTlsManager)
import Options.Applicative
import System.Directory (createDirectoryIfMissing)
import System.Environment.XDG.BaseDir (getUserCacheDir)
import System.Exit (exitFailure)
import System.FilePath ((</>))
import System.IO (Handle, IOMode (..), stdout, withFile)


-- | Options shared by all icloud CLI commands.
data CommonOpts = CommonOpts
  { optChina :: Bool
  -- ^ Use mainland China endpoints instead of the worldwide endpoints.
  , optLog :: Bool
  -- ^ Append HTTP exchanges to the default log file.
  , optLogFile :: Maybe FilePath
  -- ^ Append HTTP exchanges to this file instead of the default.
  , optLogBodies :: Bool
  -- ^ Include request bodies in the HTTP exchange log.
  , optRedact :: Bool
  -- ^ Redact sensitive headers (tokens, cookies) in the log.
  }
  deriving (Eq, Show)


-- | Parser for 'CommonOpts'.
commonOptsParser :: Parser CommonOpts
commonOptsParser =
  CommonOpts
    <$> switch (long "china" <> help "Use mainland China endpoints")
    <*> switch (long "log" <> help "Append HTTP exchanges to the default log file")
    <*> optional
      (strOption (long "log-file" <> metavar "FILE" <> help "Append HTTP exchanges to FILE"))
    <*> switch (long "log-bodies" <> help "Include request bodies in the HTTP exchange log")
    <*> switch (long "redact" <> help "Redact sensitive headers (tokens, cookies) in the log")


-- | Resolve the log file path from 'CommonOpts', or 'Nothing' if logging is disabled.
resolveLogTarget :: CommonOpts -> IO (Maybe FilePath)
resolveLogTarget CommonOpts{optLogFile = Just fp} = pure (Just fp)
resolveLogTarget CommonOpts{optLog = True} = Just <$> defaultLogFile
resolveLogTarget _ = pure Nothing


-- | Default log file path: @~\/.cache\/hs-icloud\/requests.log@.
defaultLogFile :: IO FilePath
defaultLogFile = do
  dir <- getUserCacheDir "hs-icloud"
  createDirectoryIfMissing True dir
  pure (dir </> "requests.log")


-- | Select the appropriate logger constructor from 'CommonOpts'.
mkLoggerFor :: CommonOpts -> Handle -> ApiLogger
mkLoggerFor CommonOpts{optRedact = True} = redactingLogger
mkLoggerFor CommonOpts{optLogBodies = True} = verboseLogger
mkLoggerFor _ = fileLogger


{- | Authenticate and run an action with the resulting 'Api'.

Handles session loading, TLS manager creation, logger wiring, and catches
'AuthError'. Additional error types should be caught by the caller.
-}
runWithApi
  :: CommonOpts
  -> (AccountData -> Session -> Api -> IO ())
  -> IO ()
runWithApi opts runAction = do
  session <- loadSession
  mgr <- newTlsManager
  let realm = if optChina opts then China else Usual
  api0 <- mkApiWith session (realmEndpoints realm) mgr
  mbLogPath <- resolveLogTarget opts
  let mkLogger' = mkLoggerFor opts
      run api = do
        result <- login api
        case result of
          Authenticated sess ad -> runAction ad sess api
          _ -> putStrLn "Not authenticated — run 'hstratus-auth login' first." >> exitFailure
      go = case mbLogPath of
        Just fp -> withFile fp AppendMode $ \h -> run (withLogger (mkLogger' h) api0)
        Nothing
          | optLogBodies opts && not (optRedact opts) -> run (withLogger (mkLogger' stdout) api0)
          | otherwise -> run api0
  go `catch` onServiceError @AuthError


-- | Print a service error and exit. Use as the catch handler in CLI wrappers.
onServiceError :: (HStratusError e) => e -> IO a
onServiceError e = putStrLn ("Error: " <> displayException e) >> exitFailure