packages feed

sensei-0.9.0: src/HTTP.hs

{-# LANGUAGE CPP #-}
{-# LANGUAGE NoImplicitPrelude #-}
module HTTP (
  withServer
, socketName
, newSocket
, socketAddr

#ifdef TEST
, app
#endif
) where

import           Imports hiding (putStrLn, strip, encodeUtf8)

import           System.Directory
import           Data.Aeson
import           Data.ByteString.Builder
import           Network.Wai
import           Network.HTTP.Types
import qualified Network.HTTP.Types.Status as Status
import           Network.HTTP.Media
import           Network.Wai.Handler.Warp (runSettingsSocket, defaultSettings)
import           Network.Socket

import           Util
import qualified Trigger
import           Config (Config)
import qualified Config
import qualified DeepSeek
import           GHC.Diagnostic

import           HTTP.Util
import           Sensei.API (QuickFixRequest(..))

socketAddr :: FilePath -> SockAddr
socketAddr = SockAddrUnix . socketName

withSocket :: (Socket -> IO a) -> IO a
withSocket = bracket newSocket close

withServer :: (String -> IO ()) -> Config -> FilePath -> IO (Trigger.Result, String, [Diagnostic]) -> IO a -> IO a
withServer putStrLn config dir = withApplication dir . app putStrLn config dir

withApplication :: FilePath -> Application -> IO a -> IO a
withApplication dir application action = do
  removeSocketFile dir
  withSocket $ \ sock -> do
    bracket_ (bind sock $ socketAddr dir) (removeSocketFile dir) $ do
      listen sock maxListenQueue
      withThread (runSettingsSocket defaultSettings sock application) action

removeSocketFile :: FilePath -> IO ()
removeSocketFile dir = void $ tryJust (guard . isDoesNotExistError) (removeFile $ socketName dir)

withThread :: IO () -> IO a -> IO a
withThread asyncAction action = do
  mvar <- newEmptyMVar
  tid <- forkIO $ do
    asyncAction `finally` putMVar mvar ()
  r <- action
  killThread tid
  takeMVar mvar
  return r

app :: (String -> IO ()) -> Config -> FilePath -> IO (Trigger.Result, String, [Diagnostic]) -> Application
app putStrLn config dir getLastResult request respond = case pathInfo request of

  [] -> requireMethod "GET" $ do
    getLastResult >>= textPlain

  ["diagnostics"] -> requireMethod "GET" $ do
    (_, _, diagnostics) <- getLastResult
    respond $ json diagnostics

  ["quick-fix"] -> requireMethod "POST" $ do
    consumeRequestBodyLazy request <&> eitherDecode @QuickFixRequest >>= \ case
      Right quickFixRequest -> getLastResult >>= \ case
        (_, _, (analyze -> Just action) : _) -> do
          apply dir quickFixRequest.choice action
          noContent
        _ -> do
          noContent
      Left err -> badRequest err

  ["deep-fix"] -> requireMethod "POST" $ getLastResult >>= \ case
    (_, _, diagnostic : _) -> case config.deepSeek of
      Just conf -> do
        DeepSeek.apply putStrLn conf dir diagnostic
        noContent
      Nothing -> do
        serviceUnavailable "missing config value deep-seek.auth"
    _ -> do
      noContent
  _ -> do
    respond $ genericStatus Status.notFound404 request

  where
    noContent :: IO ResponseReceived
    noContent = respond $ jsonResponse Status.noContent204 ""

    serviceUnavailable :: String -> IO ResponseReceived
    serviceUnavailable = respond . genericRfc7807Response Status.serviceUnavailable503 . Just

    badRequest :: String -> IO ResponseReceived
    badRequest = respond . genericRfc7807Response Status.badRequest400 . Just

    color :: Either Builder Bool
    color = case join $ lookup "color" $ queryString request of
      Nothing -> Right True
      Just "false" -> Right False
      Just "true" -> Right True
      Just value -> Left $ "invalid value for color: " <> urlEncodeBuilder True value

    textPlain :: (Trigger.Result, FilePath, [Diagnostic]) -> IO ResponseReceived
    textPlain (result, xs, _diagnostics) = case color of
      Left err -> respond $ textResponse Status.badRequest400 err
      Right c -> respond . textResponse status . stringUtf8 $ strip xs
        where
          strip :: String -> String
          strip
            | c = id
            | otherwise = stripAnsi

          status :: Status
          status = case result of
            Trigger.HookFailed -> Status.internalServerError500
            Trigger.Failure -> Status.internalServerError500
            Trigger.Success -> Status.ok200

    requireMethod :: Method -> IO ResponseReceived -> IO ResponseReceived
    requireMethod required action = case requestMethod request of
      method | method == required -> action
      _ -> respond $ genericRfc7807Response Status.methodNotAllowed405 Nothing

    json :: ToJSON a => a -> Response
    json = jsonResponse Status.ok200 . fromEncoding . toEncoding

genericStatus :: Status -> Request -> Response
genericStatus status@(Status number message) request = fromMaybe text $ mapAcceptMedia [
    ("text/plain", text)
  , ("application/json", json)
  ] =<< lookup "Accept" request.requestHeaders
  where
    text :: Response
    text = textResponse status $ intDec number <> " " <> byteString message

    json :: Response
    json = genericRfc7807Response status Nothing

genericRfc7807Response :: Status -> Maybe String -> Response
genericRfc7807Response status@(Status number message) detail = jsonResponse status body
  where
    body :: Builder
    body = "{\n  \"title\": \"" <> byteString message <> "\",\n  \"status\": " <> intDec number <> renderedDetail <> "\n}"

    renderedDetail :: Builder
    renderedDetail = case detail of
      Nothing -> ""
      Just err -> ",\n  \"detail\": " <> lazyByteString (encode err)

jsonResponse :: Status -> Builder -> Response
jsonResponse status body = responseBuilder status [(hContentType, "application/json")] body

textResponse :: Status -> Builder -> Response
textResponse status = responseBuilder status [(hContentType, "text/plain")]