packages feed

restman-0.7.1.1: src/Lib.hs

{-# language OverloadedStrings #-}
{-# language RecordWildCards #-}
{-|
Description: RESTman as a library

For the data inclined, I recommend starting with 'AppS', which is all the internal RESTman state.
For the logic inclined, I recommen starting with 'handleEvent', which is the main way the state is
manipulated over time.  The application handles command-line parsing and then calls into
'someFunc', which we never renamed from the stack template at it is responsible for building the
record of function required for a Bick application, also a reasonable starting place.
-}
module Lib
    ( chooseCursor
    , draw
    , focusRing
    , growWith
    , handleEvent
    , doRequest
    , defaultHeadersEditorName
    , responseBodyViewportName
    ) where

-- base
import Data.Foldable (for_)
import Data.List (nub)
import Data.Maybe (fromMaybe, mapMaybe)
import Data.Void (Void, absurd)

-- bytestring
import Data.ByteString.Lazy (toStrict)
import qualified Data.ByteString.Lazy as LBS

-- brick
import Brick.Focus
  ( FocusRing
  , focusGetCurrent
  , focusNext
  , focusPrev
  , focusRing
  , focusRingCursor
  , withFocusRing
  )
import Brick.Main
  ( hScrollBy
  , halt
  , lookupExtent
  , showCursorNamed
  , suspendAndResume
  , vScrollBy
  , vScrollPage
  , viewportScroll
  )
import Brick.Types
  ( BrickEvent(AppEvent, MouseDown, MouseUp, VtyEvent)
  , CursorLocation
  , Direction(Down, Up)
  , EventM
  , Extent
  , HScrollBarOrientation(OnBottom)
  , VScrollBarOrientation(OnRight)
  , ViewportType(Both)
  , Widget(render)
  , extentSize
  , extentUpperLeft
  , imageL
  , nestEventM
  )
import Brick.Widgets.Border (borderWithLabel)
import qualified Brick.Widgets.Border.Style as BS
import Brick.Widgets.Core
  ( fill
  , getName
  , hBox
  , hLimit
  , padLeftRight
  , raw
  , reportExtent
  , str
  , textWidth
  , translateBy
  , txt
  , vBox
  , vLimit
  , vLimitPercent
  , viewport
  , withBorderStyle
  , withHScrollBars
  , withVScrollBars
  )
import Brick.Widgets.Edit
  (Editor, editContentsL, handleEditorEvent, renderEditor)
import Brick.Widgets.List
  ( List
  , handleListEvent
  , listElements
  , listItemHeight
  , listSelectedElement
  , listSelectedL
  , renderList
  )
import qualified Brick.Widgets.List as BrickList
import Brick.Widgets.Table (renderTable, rowBorders, surroundingBorder, table)

-- case-insensitive
import Data.CaseInsensitive (original)

-- containers
import qualified Data.Map as Map

-- lens
import Control.Lens
  (ALens', _Just, cloneLens, set, view, (%=), (%~), (&), (.~), (^?))

-- microlens-mtl
import Lens.Micro.Mtl (zoom)

-- mime-types
import Network.Mime (defaultExtensionMap)

-- mtl
import Control.Monad.State.Class (get, gets, modify, put)

-- skylighting
import Skylighting
  ( Syntax
  , TokenizerConfig(TokenizerConfig)
  , defaultFormatOpts
  , defaultSyntaxMap
  , lookupSyntax
  , pygments
  , tokenize
  )

-- text
import Data.Text (Text)
import qualified Data.Text as Text
import Data.Text.Encoding (decodeUtf8With, encodeUtf8)
import Data.Text.Encoding.Error (lenientDecode)
import qualified Data.Text.Lazy as LText

-- text-zipper
import Data.Text.Zipper (getText, stringZipper)

-- unliftio
import UnliftIO.Exception (tryAnyDeep)

-- vector
import qualified Data.Vector as Vector

-- wreq
import qualified Network.Wreq as W

-- vty
import Graphics.Vty.Attributes (Attr, currentAttr, reverseVideo, withStyle)
import qualified Graphics.Vty.Attributes as VA
import Graphics.Vty.Image
  ( Image
  , charFill
  , imageHeight
  , imageWidth
  , text'
  , utf8Bytestring'
  , vertCat
  , (<->)
  , (<|>)
  )
import qualified Graphics.Vty.Image as VI
import Graphics.Vty.Input.Events
  ( Button(BScrollDown, BScrollUp)
  , Event(EvKey, EvMouseDown)
  , Key(KBackTab, KChar, KDown, KEnter, KEsc, KLeft, KPageDown, KPageUp, KRight, KUp)
  , Modifier(MCtrl, MShift)
  )

-- local libs
import HTTP.Client
  ( Header
  , Method
  , customMethodWith
  , customPayloadMethodWith
  , defaults
  , headers
  , knownMethods
  , responseBody
  , responseHeader
  )

import Skylighting.Format.Vty (formatVty)
import Types
  (AppS(..), OverlayS(..), RangeInAlign(..), VertAlign, WideAlign, left, top)

-- | Second argument must be positive.
splitExtraSpace :: RangeInAlign -> Int -> (Int, Int)
splitExtraSpace Min n = (0, n)
splitExtraSpace MidL n = let (q, r) = n `divMod` 2 in (q, q + r)
splitExtraSpace MidG n = let (q, r) = n `divMod` 2 in (q + r, q)
splitExtraSpace Max n = (n, 0)

{-|
Grows an image to a minimum size by padding it, with original images being aligned as specified
within the larger image.
-}
growWith
  :: (Int -> Int -> Image) -- ^ How to generate wide by vert padding image
  -> Int -- ^ minimum width
  -> WideAlign -- ^ width-wise ("horizontal") alignment
  -> Int -- ^ minimum vert ("height")
  -> VertAlign -- ^ vertical alignment
  -> Image -- ^ original, small image
  -> Image -- ^ resulting, grown image
growWith fillImage w wa v va img = vertCat
  [ fillImage width topPad
  , fillImage leftPad vi <|> img <|> fillImage rightPad vi
  , fillImage width bottomPad
  ]
 where
  vi = imageHeight img
  vPad = max 0 (v - vi)
  (topPad, bottomPad) = splitExtraSpace va vPad
  wi = imageWidth img
  width = max w wi
  wPad = width - wi
  (leftPad, rightPad) = splitExtraSpace wa wPad

{- Export?
growTransparent :: Int -> WideAlign -> Int -> VertAlign -> Image -> Image
growTransparent = growWith backgroundFill
-}

-- | See 'growWith', this one uses ASCII SPC @' '@ to fill the padding.
growSpaces :: Int -> WideAlign -> Int -> VertAlign -> Image -> Image
growSpaces = growWith (charFill currentAttr ' ')

-- | Update a widget by changing the final render and no other properties (almost a lens)
postprocessImage :: (Image -> Image) -> Widget n -> Widget n
postprocessImage process widget = widget{render = (imageL %~ process) <$> render widget}

-- | Lens, focus tracking from app state.
focusL :: Functor f => (FocusRing Text -> f (FocusRing Text)) -> AppS -> f AppS
focusL embed s = (\newFocus -> s{focus = newFocus}) <$> embed (focus s)

-- | Lens, method text entry from app state.
methodEditorL :: Functor f => (Editor Method Text -> f (Editor Method Text)) -> AppS -> f AppS
methodEditorL embed s = (\newMethodEditor -> s{methodEditor = newMethodEditor}) <$> embed (methodEditor s)

-- | Lens, method in text entry.
methodL :: Functor f => (Method -> f Method) -> AppS -> f AppS
methodL = methodEditorL . editContentsL . methodInEditorContentsL
 where
  methodInEditorContentsL embed tz = (\newMethod -> stringZipper [newMethod] (Just 1)) <$> embed (concat $ getText tz)

-- | Lens, optional overlay in app state
overlayStateL :: Functor f => (Maybe OverlayS -> f (Maybe OverlayS)) -> AppS -> f AppS
overlayStateL embed s = (\newOverlay -> s{overlayState = newOverlay}) <$> embed (overlayState s)

-- | Lens, location of pop-up in overlay state
methodEditorExtentL :: Functor f => (Extent Text -> f (Extent Text)) -> OverlayS -> f OverlayS
methodEditorExtentL embed os = (\newExtent -> os{methodEditorExtent = newExtent}) <$> embed (methodEditorExtent os)

-- | Lens, method selector in overlay state
methodListL :: Functor f => (List Text Method -> f (List Text Method)) -> OverlayS -> f OverlayS
methodListL embed os = (\newList -> os{methodList = newList}) <$> embed (methodList os)

-- | Lens, URL text entry in app state
urlEditorL :: Functor f => (Editor String Text -> f (Editor String Text)) -> AppS -> f AppS
urlEditorL embed s = (\newUrlEditor -> s{urlEditor = newUrlEditor}) <$> embed (urlEditor s)

useDefaultHeadersL :: Functor f => (Bool -> f Bool) -> AppS -> f AppS
useDefaultHeadersL embed s =
  (\newUse -> s{useDefaultHeaders = newUse}) <$> embed (useDefaultHeaders s)

-- | Extent name.  Emitted by main UI, used to construct overlay state
methodEditorExtentName :: Text
methodEditorExtentName = "methodEditorExtent"

{-|
Like 'borderWithLabel' but grows the image of the inner widget so the whole label is always
visible.
-}
textLabeledBorder ::  Text -> Widget n -> Widget n
textLabeledBorder label inner =
  borderWithLabel (txt label) (postprocessImage (growSpaces (textWidth label) left 0 top) inner)

-- | Like 'textLabeledBorder' but allows attributes to be applied to the label.
textAttrLabeledBorder :: Text -> Attr -> Widget n -> Widget n
textAttrLabeledBorder label attr inner =
  borderWithLabel
    (raw $ text' attr label)
    (postprocessImage (growSpaces (textWidth label) left 0 top) inner)

-- | Area between main menu and main content area
mainMenuSeparator :: Widget n
mainMenuSeparator = vLimit 1 $ fill '='

-- | Non-functional top menu bar
mainMenu :: Widget n
mainMenu = hBox $ map (padLeftRight 2 . txt) [ "Headers", "Response Tools", "\x2026" ]


{- Widget names -}
defaultHeadersEditorName :: Text
defaultHeadersEditorName = "defaultHeadersEditor"

responseBodyViewportName :: Text
responseBodyViewportName = "responseBodyViewport"

{-|
Render.

Widget names are all 'Text'.

URL editor and label, then last response body at the top of the available space.  No other widgets
or layers.
-}
draw :: AppS -> [Widget Text]
draw AppS{..} =
  [ drawOverlay os | Just os <- [overlayState] ]
  ++
  [ vBox $
    [ mainMenu
    , mainMenuSeparator
    , hBox
      [ reportExtent methodEditorExtentName $ textLabeledBorder "Method" methodWidget
      , textLabeledBorder "URL to Query?" urlWidget
      ]
    , if useDefaultHeaders
       then
        textAttrLabeledBorder
          "[X] Use Default Headers"
          defaultHeadersAttr
          (headersWidget defaultHeaders)
       else raw $ text' defaultHeadersAttr " [ ] Use Default Headers"
    ]
    ++
    [ textLabeledBorder "Custom Headers" (headersWidget customHeaders) | not $ null customHeaders ]
    ++
    [ focusedBorder
      . textLabeledBorder "Response Body"
      . withVScrollBars OnRight
      . withHScrollBars OnBottom
      . viewport responseBodyViewportName Both
      $ raw vtySafeResponse
    ]
  ] -- layers top to bottom
 where
  focusDefaultHeaders = focusGetCurrent focus == Just defaultHeadersEditorName
  focusedResponseBody = focusGetCurrent focus == Just responseBodyViewportName
  focusedBorder = if focusedResponseBody then withBorderStyle BS.unicodeBold else id
  defaultHeadersAttr =
    if focusDefaultHeaders then currentAttr `withStyle` reverseVideo else currentAttr
  defaultHeaders = view headers defaults
  responseImage = lastResponse
  vtySafeResponse = responseImage
  editorWidget = withFocusRing focus (renderEditor (vBox . map str))
  urlWidget = editorWidget urlEditor
  methodWidget = hLimit 18 $ hBox [editorWidget methodEditor, str "\x25BC"]

headersWidget :: [Header] -> Widget n
headersWidget = renderTable . surroundingBorder False . rowBorders False . table . map headerRow
 where
  headerRow (n, v) =
    [raw . utf8Bytestring' currentAttr $ original n, raw $ utf8Bytestring' currentAttr v]

-- | When the overlay is present, what are the widget to draw on that layer.
drawOverlay :: OverlayS -> Widget Text
drawOverlay OverlayS{..} =
  translateBy transLoc . hLimit w . textLabeledBorder "Method (Select)" $ methodSelectWidget
 where
  methodSelectWidget = vLimitPercent 50 . vLimit mv $ renderList (const str) True methodList
  w = fst $ extentSize methodEditorExtent
  transLoc = extentUpperLeft methodEditorExtent
  mv = Vector.length (listElements methodList) * listItemHeight methodList

-- | Where to place the cursor?  If overlay present, no cursor.  Otherwise determined by 'focus'
chooseCursor :: AppS -> [CursorLocation Text] -> Maybe (CursorLocation Text)
chooseCursor s =
  case overlayState s of
   Nothing -> focusRingCursor focus s
   Just os -> showCursorNamed (getName $ methodList os)

-- | App state change on [Tab] to advace the focus.
moveFocusNext :: AppS -> AppS
moveFocusNext = focusL %~ focusNext

-- | App state change on [S+Tab] to recede the focus
moveFocusPrev :: AppS -> AppS
moveFocusPrev = focusL %~ focusPrev

{-|
State update in response to event.

Widget names are all 'Text'.  No application specific events, so event type is 'Void'.

Global events handled here:
 * [Esc] (with any or no modifiers) and Ctrl+q (with any or no other modifiers) halts.
 * No application events are expected.
 * Mouse events are ignored.

Other Vty events are passed to 'handleEventLocal'.
-}
handleEvent :: BrickEvent Text Void -> EventM Text AppS ()
handleEvent (VtyEvent (EvKey KEsc _)) = halt -- global: [ESC]: exit cleanly
handleEvent (VtyEvent e@(EvKey (KChar 'q') mods)) = -- global Ctrl+q: exit cleanly
  if MCtrl `elem` mods
   then halt
   else handleEventLocal e
handleEvent (VtyEvent e) = handleEventLocal e
handleEvent (AppEvent bottom) = absurd bottom -- can't happen
handleEvent MouseDown{} = pure () -- ignore
handleEvent MouseUp{} = pure () -- ignore

{-|
If the overlay is deing displayed, defer to 'handleEventOverlay'.  Otherwise, defer to
'handleEventMain'.  "Global" events should already have been handled in 'handleEvent'.
-}
handleEventLocal :: Event -> EventM Text AppS ()
handleEventLocal e = do
  mos <- gets overlayState
  -- switch to different behavior based on overlay
  case mos of
   Nothing -> handleEventMain e
   Just os -> handleEventOverlay os e

{-|
Events for the main UI:
 * [Enter] causes the application to 'doRequest'
 * [Tab] causes 'moveFocusNext', [S+Tab] causes 'moveFocusPrev'.
 * [BackTab] casues 'moveFocusPrev', [S+BackTab] causes 'moveFocusNext'.
 * [Down] on the method text entry causes pop-up display; if focus is elsewhere it is ignored.
 * [Space] on the default headers editor toggles the setting.
 * If an editor is focused, other events update it.
-}
handleEventMain :: Event -> EventM Text AppS ()
handleEventMain (EvKey KEnter _) = get >>= suspendAndResume . doRequest
handleEventMain (EvKey (KChar '\t') mods) = -- change focus
  modify $ if MShift `elem` mods
    then moveFocusPrev
    else moveFocusNext
handleEventMain (EvKey KBackTab mods) = -- change focus
  modify $ if MShift `elem` mods
    then moveFocusNext
    else moveFocusPrev
handleEventMain evt = do -- Depends on focus
  s <- get
  case focusGetCurrent (focus s) of
    Nothing -> pure ()
    Just nm | nm == getName (urlEditor s) -> handleEditorLEvent urlEditorL evt
    Just nm | nm == getName (methodEditor s) -> handleMethodEditorEvent evt
    Just nm | nm == defaultHeadersEditorName -> handleDefaultHeadersEditorEvent evt
    Just nm | nm == responseBodyViewportName -> handleResponseBodyViewportEvent evt
    Just _u -> pure () -- unknown focus (log?)

-- | Pass event into 'handleEditorEvent', when not handled more specifically.
handleEditorLEvent :: ALens' AppS (Editor String Text) -> Event -> EventM Text AppS ()
handleEditorLEvent editorL evt = zoom (cloneLens editorL) (handleEditorEvent $ VtyEvent evt)

-- | Handle event when focus is on the method editor and will not change.
-- [Down] opens the method selector overlay.
-- Other events are passed to 'handleEditorLEvent'.
handleMethodEditorEvent :: Event -> EventM Text AppS ()
handleMethodEditorEvent (EvKey KDown _mods) = do
  s <- get
  let
    enteredMethod = view methodL s
    listContents = Vector.fromList . nub $ enteredMethod : knownMethods
  mMethodEditorExtent <- lookupExtent methodEditorExtentName
  for_ mMethodEditorExtent $ \methodEditorExtent ->
    let
      initialListState = set listSelectedL (Just 0) $ BrickList.list "methodSelector" listContents 1
      overlay = OverlayS
        { methodList = initialListState
        , methodEditorExtent = methodEditorExtent
        }
    in put s{overlayState = Just overlay}
handleMethodEditorEvent evt = handleEditorLEvent methodEditorL evt

-- | Handle event when focus is on response body viewport and will not change.
-- * [Down] or 'j': line scroll down
-- * [Up] or 'k': line scroll up
-- * [Right] or 'l': column scroll right
-- * [Left] or 'h': column scroll left
-- * [PgDn]: page scroll down
-- * [PgUp]: page scroll up
-- * <ScrollUp>: 3 line scroll up
-- * <ScrollDown>: 3 line scroll down
handleResponseBodyViewportEvent :: Event -> EventM Text AppS ()
handleResponseBodyViewportEvent evt =
  case evt of
    EvKey k _mods ->
      case k of
        KDown -> vScrollBy vps 1
        KChar 'j' -> vScrollBy vps 1
        KUp -> vScrollBy vps (-1)
        KChar 'k' -> vScrollBy vps (-1)
        KRight -> hScrollBy vps 1
        KChar 'l' -> hScrollBy vps 1
        KLeft -> hScrollBy vps (-1)
        KChar 'h' -> hScrollBy vps (-1)
        KPageDown -> vScrollPage vps Down
        KPageUp -> vScrollPage vps Up
        _ -> pure () -- ignored
    EvMouseDown _x _y b _mods ->
      case b of
        BScrollUp -> vScrollBy vps (-3)
        BScrollDown -> vScrollBy vps 3
        _ -> pure () -- ignored
    _ -> pure () -- ignored
 where
  vps = viewportScroll responseBodyViewportName

-- | Handle event when focus in on default headers editor and will not change.
handleDefaultHeadersEditorEvent :: Event -> EventM Text AppS ()
handleDefaultHeadersEditorEvent (EvKey (KChar ' ') _) = useDefaultHeadersL %= not
handleDefaultHeadersEditorEvent _ = pure ()

-- | Retrieve extent emitted during last draw and update overlay state.
updateExtent :: EventM Text OverlayS ()
updateExtent = do
  mExtent <- lookupExtent methodEditorExtentName
  for_ mExtent $ \extent ->
   modify $ set methodEditorExtentL extent

{-|
Events for the overlay:
 * [Enter] closes the overlay taking the selected method and updating the application state.
 * Other events are sent to the method selection list.

All code paths should 'updateExtent', in case the pop-up needs to move.
-}
handleEventOverlay :: OverlayS -> Event -> EventM Text AppS ()
handleEventOverlay currentOverlay (EvKey KEnter _) = do -- pick selected method, remove popup
  mSelectedMethod <- fmap snd . nestEventM currentOverlay $ do
    updateExtent
    gets $ fmap snd . listSelectedElement . methodList
  for_ mSelectedMethod $ \method ->
    modify $ set methodL method . set overlayStateL Nothing
handleEventOverlay _currentOverlay e = zoom (overlayStateL . _Just) $ do -- defer to list
    updateExtent
    zoom methodListL (handleListEvent e)

{-|
Construct and issue a wreq request from the application state, handle request failures that are
raised as exceptions in IO, updating the application state ('lastResponse') either from the
response or the execption.
-}
doRequest :: AppS -> IO AppS
doRequest s = do
  result <- tryAnyDeep $ do
    let opts = W.defaults & (W.manager .~ Right (connManager s))
    response <- httpRequest opts . concat . getText . view editContentsL $ urlEditor s
    pure $ syntaxHighlightResponse (pure $ view responseBody response) (responseContentType response)
  pure s{ lastResponse = either exResponse id result }
 where
  updateHeaders AppS{..} = if useDefaultHeaders then (++ customHeaders) else const customHeaders
  httpMethod AppS{..} = concat . getText $ view editContentsL methodEditor
  httpOptions = headers %~ updateHeaders s
  httpRequest opts =
    case payload s of
     Nothing -> customMethodWith (httpMethod s) (httpOptions opts)
     Just lbs -> \url -> customPayloadMethodWith (httpMethod s) (httpOptions opts) url lbs
  exResponse ex = VI.text (VA.Attr VA.Default VA.Default VA.Default VA.Default) $ "[Exception: " <> LText.pack (show ex) <> "]"
  responseContentType resp = fmap LBS.fromStrict $ resp ^? responseHeader "Content-Type"

{- Incoming ByteString for payload has not been pruned of invalid chacaters nor been tab expanded.

   - These operations are required to be compatible with Vty's text rendering. The require removing
   paticular characters like escape, tab, vertical tab, and new lines. The newslines are dealt with as part of the
   -}
syntaxHighlightResponse :: Maybe LBS.ByteString -> Maybe LBS.ByteString -> Image
syntaxHighlightResponse Nothing _ = VI.text (VA.Attr VA.Default VA.Default VA.Default VA.Default) "-- No response body --\n"
syntaxHighlightResponse (Just payload) Nothing = noSyntaxFound $ cleanPayload payload
syntaxHighlightResponse (Just payload) (Just contentType) = case syntaxFromExtension of
  (syn:_) -> highlighted syn $ cleanPayload payload
  [] -> noSyntaxFound $ cleanPayload payload
 where
  syntaxFromExtension = mapMaybe (`lookupSyntax` defaultSyntaxMap) contentTypeToExtensionList
  contentTypeToExtensionList = fromMaybe [] $ Map.lookup mimeType defaultExtensionMap
  mimeType = encodeUtf8 $ Text.takeWhile (/= ';') $ lbsToText contentType


cleanPayload :: LBS.ByteString -> Text
cleanPayload = expandTabs . cleanText
  where
    cleanText = Text.filter (`notElem` ['\ESC', '\v']) . lbsToText
    expandTabs = Text.replace "\t" "    "

highlighted :: Syntax -> Text -> Image
highlighted s t = case tokenize (TokenizerConfig defaultSyntaxMap False) s t of
  Left err -> foldr ((<->) . VI.text (VA.Attr VA.Default VA.Default VA.Default VA.Default))
    VI.emptyImage
    ["-- Syntax highlighting error: ", LText.pack err, " --" , LText.fromStrict t]
  Right sourceLines -> formatVty defaultFormatOpts pygments sourceLines

noSyntaxFound :: Text -> Image
noSyntaxFound payload = foldr
    ((<->) . VI.text (VA.Attr VA.Default VA.Default VA.Default VA.Default))
    VI.emptyImage
    ("--Uknown Body Type --" : map LText.fromStrict (Text.lines payload))

-- Helper function to convert a lazy ByteString to Text in UTF-8 encoding
lbsToText :: LBS.ByteString -> Text
lbsToText = decodeUtf8With lenientDecode . toStrict