module Main (main) where -- base import Control.Applicative (many, optional, (<|>)) import Control.Exception (SomeException, try) import Data.Bits (Bits, unsafeShiftL) import System.Exit (exitFailure) import System.IO (IOMode(ReadMode), hPutStrLn, stderr, stdout, withFile) -- bytestring import Data.ByteString (ByteString, hGetSome) import qualified Data.ByteString as BS import qualified Data.ByteString.Char8 as CS import Data.ByteString.Lazy (fromChunks, fromStrict, hPut) import qualified Data.ByteString.Lazy as Lazy (ByteString) -- case-insensitive import qualified Data.CaseInsensitive as CI -- microlens import Lens.Micro ((%~), (&), (.~)) import Lens.Micro.Extras (view) -- optparse-applicatve import Options.Applicative ( ParseError(ShowHelpText) , Parser , ParserInfo , abortOption , completeWith , execParser , flag' , footer , fullDesc , header , help , info , long , metavar , progDesc , short , showDefault , strArgument , strOption , switch ) import qualified Options.Applicative as OptParse -- text import Data.Text (Text, unpack) -- utf8-string import qualified Data.ByteString.UTF8 as UTF8 -- http-client import qualified Network.HTTP.Client as HC -- wreq import qualified Network.Wreq as W -- local imports import HTTP.Client import UI -- | Handles @-m@ / @--method@ methodParser :: Parser Method methodParser = strOption ( short 'm' <> long "method" <> help "HTTP(S) Method (or \"Verb\")" <> OptParse.value "GET" <> showDefault <> metavar "METHOD" <> completeWith knownMethods ) -- | handles @--[no-]default-headers@ useDefaultParser :: Parser UseDefaultHeaders useDefaultParser = noDefaultHeaders <|> defaultHeaders <|> pure AppendCustomToDefaultHeaders where noDefaultHeaders = flag' ReplaceDefaultHeaders ( long "no-default-headers" <> help "Send ONLY the custom headers specified." ) defaultHeaders = flag' AppendCustomToDefaultHeaders ( long "default-headers" <> help "Send custom headers after default headers. This is the default behavior." ) {-| Handles the option-argument for @-h@ / @--custom-header@. Unicode value is split on the first colon. Anything before the colon must truncate to ISO-8859-1, and is treated as the header name. Anything after the colon is treated as the header value, and full Unicode is accepted and encoded as UTF-8. No validation is done on the header name. No allowance is made for specifying an (invalid) header name that contains a colon. -} optArgToHeader :: String -> Header optArgToHeader arg = (CI.mk $ CS.pack name, UTF8.fromString value) where (name, colonValue) = break (':' ==) arg value = drop 1 colonValue -- | handles @-h@ / @--custom-header@ customHeadersParser :: Parser [Header] customHeadersParser = many . fmap optArgToHeader $ strOption ( short 'h' <> long "custom-header" <> help "Add a custom header, argument is name:value (colon separates name from value)." <> metavar "NAME_VALUE" ) -- | Specification of the payload from the user data Payload = Literal ByteString -- ^ argument converted to bytes | File FilePath -- ^ argument converts to file path -- | Handles @--payload@ and @--payload-file@ payloadParser :: Parser (Maybe Payload) payloadParser = optional ((Literal <$> literalParser) <|> (File <$> fileParser)) where literalParser = UTF8.fromString <$> strOption ( long "payload" <> help "Specify (in UTF-8) payload directly on command-line" <> metavar "PAYLOAD" ) fileParser = strOption ( long "payload-file" <> help "Load payload from file on startup" <> metavar "PATHNAME" ) -- | Handle optional positional parameter for URI uriPositionalParser :: Parser (Maybe Text) uriPositionalParser = optional $ strArgument (metavar "URI") -- | Options from the command-line data Options = MkOptions { method :: Method , useDefaultHeaders :: UseDefaultHeaders , customHeaders :: [Header] , payload :: Maybe Payload , uri :: Maybe Text , oneshot :: Bool } -- | Handles @--oneshot@: make one request, print the body, and exit without starting the TUI. oneshotParser :: Parser Bool oneshotParser = switch ( long "oneshot" <> help "Make the request, print the response body to stdout, and exit without starting the TUI." ) helpParser :: Parser (a -> a) helpParser = abortOption (ShowHelpText mempty) ( long "help" <> help "Show command-line help and abort normal operation." ) -- | Handles full command-line, generating help, and completions appParser :: ParserInfo Options appParser = info (helpParser <*> optionsParser) ( fullDesc <> header "RESTman, an HTTP(S) application" <> footer "https://gitlab.com/krakrjak/restman" <> progDesc "A TUI application for interactively using the full range of HTTP(S) directly" ) where optionsParser = MkOptions <$> methodParser <*> useDefaultParser <*> customHeadersParser <*> payloadParser <*> uriPositionalParser <*> oneshotParser -- | Lens for the method in the options. optMethod :: Functor f => (Method -> f Method) -> Options -> f Options optMethod embed opts = fmap (\m -> opts{ method = m}) . embed $ method opts -- | 'unsafeShilfL' (.<<.) :: Bits b => b -> Int -> b (.<<.) = unsafeShiftL -- | Go from payload specification to a payload ready for library. loadPayload :: Payload -> IO Lazy.ByteString loadPayload (Literal bs) = pure $ fromStrict bs loadPayload (File fp) = fromChunks <$> withFile fp ReadMode readChunks where readChunks hdl = go where go = do chunk <- hGetSome hdl (1 .<<. 15 {- 32k -}) if BS.null chunk then return [] else do chunks <- go return $ chunk:chunks -- | Non-TUI entry point: perform one request, write the response body to stdout, and exit. -- -- Exits with failure if no URI was supplied or the request throws an exception. runOneshot :: Options -> Maybe Lazy.ByteString -> IO () runOneshot opts payloadLbs = case uri opts of Nothing -> do hPutStrLn stderr "restman: --oneshot requires a URI argument" exitFailure Just u -> do manager <- HC.newManager robustSettings let baseOpts = W.defaults & W.manager .~ Right manager wreqOpts = case useDefaultHeaders opts of AppendCustomToDefaultHeaders -> baseOpts & headers %~ (customHeaders opts ++) ReplaceDefaultHeaders -> baseOpts & headers .~ customHeaders opts url = unpack u doReq = case payloadLbs of Nothing -> customMethodWith (method opts) wreqOpts url Just lbs -> customPayloadMethodWith (method opts) wreqOpts url lbs result <- try doReq :: IO (Either SomeException (W.Response Lazy.ByteString)) case result of Left ex -> hPutStrLn stderr ("restman: " <> show ex) >> exitFailure Right resp -> hPut stdout (view responseBody resp) -- | Application entry point. main :: IO () main = do options <- execParser appParser payloadLbs <- traverse loadPayload $ payload options if oneshot options then runOneshot options payloadLbs else startUI (view optMethod options) (useDefaultHeaders options) (customHeaders options) payloadLbs (uri options)