Replace scotty with servant #4

Merged
MasseR merged 3 commits from servant into main 2026-09-25 12:10:59 +03:00
5 changed files with 118 additions and 60 deletions
Showing only changes of commit 05bd609c7e - Show all commits
+8 -6
View File
@@ -1,8 +1,9 @@
{ mkDerivation, aeson, annotated-exception, async, base, bytestring { mkDerivation, aeson, annotated-exception, async, base, bytestring
, cereal, cereal-conduit, conduit, containers, directory, ekg-core , cereal, cereal-conduit, conduit, containers, directory, ekg-core
, filepath, hedgehog, hspec, hspec-hedgehog, http-types, katip , exceptions, filepath, hedgehog, hspec, hspec-hedgehog, http-types
, lens, lens-aeson, lib, network, process, retry, scotty, stm, text , katip, lens, lens-aeson, lib, network, process, retry, scotty
, time, unordered-containers, uuid, wai, warp, websockets , servant, servant-server, stm, text, time, unliftio
, unordered-containers, uuid, wai, warp, websockets
}: }:
mkDerivation { mkDerivation {
pname = "home-assistant-controller"; pname = "home-assistant-controller";
@@ -12,9 +13,10 @@ mkDerivation {
isExecutable = true; isExecutable = true;
libraryHaskellDepends = [ libraryHaskellDepends = [
aeson annotated-exception async base bytestring cereal aeson annotated-exception async base bytestring cereal
cereal-conduit conduit containers directory ekg-core filepath cereal-conduit conduit containers directory ekg-core exceptions
http-types katip lens lens-aeson network process retry scotty stm filepath http-types katip lens lens-aeson network process retry
text time unordered-containers uuid wai warp websockets scotty servant servant-server stm text time unliftio
unordered-containers uuid wai warp websockets
]; ];
executableHaskellDepends = [ base ]; executableHaskellDepends = [ base ];
testHaskellDepends = [ testHaskellDepends = [
+4 -1
View File
@@ -84,6 +84,7 @@ library
-- Other library packages from which modules are imported. -- Other library packages from which modules are imported.
build-depends: base ^>=4.20.2.0 build-depends: base ^>=4.20.2.0
, websockets , websockets
, http-media
, aeson , aeson
, lens-aeson , lens-aeson
, lens , lens
@@ -108,7 +109,8 @@ library
, filepath , filepath
, conduit , conduit
, cereal-conduit , cereal-conduit
, scotty , servant
, servant-server
, http-types , http-types
, warp , warp
, wai , wai
@@ -182,6 +184,7 @@ test-suite home-assistant-controller-test
bytestring, bytestring,
hspec, hspec,
stm, stm,
servant,
aeson, aeson,
cereal, cereal,
text, text,
+1 -1
View File
@@ -115,7 +115,7 @@ defaultMain = withSocketsDo $ do
[ ("reader", readerAction host 8123 token ents bus) [ ("reader", readerAction host 8123 token ents bus)
, ("writer", writerAction writerLimiter bus) , ("writer", writerAction writerLimiter bus)
, ("metrics", HomeAssistant.Runtime.Metrics.metricsAction store rrdPath rrdtool) , ("metrics", HomeAssistant.Runtime.Metrics.metricsAction store rrdPath rrdtool)
, ("metrics-http", HomeAssistant.Runtime.Graphing.graphAction rrdPath rrdtool metricsPort) , ("metrics-http", HomeAssistant.Runtime.Graphing.servantGraphAction rrdPath rrdtool metricsPort)
] ++ [ (name, runController rootPath bus c) | c@(Controller True name _) <- active ] ] ++ [ (name, runController rootPath bus c) | c@(Controller True name _) <- active ]
runKatipContextT (busLogEnv bus) () mempty $ runKatipContextT (busLogEnv bus) () mempty $
mapConcurrently_ (uncurry supervised) workers mapConcurrently_ (uncurry supervised) workers
+86 -51
View File
@@ -11,37 +11,32 @@ module HomeAssistant.Runtime.Graphing
, parseNameParam , parseNameParam
, buildGraphArgs , buildGraphArgs
, readProcessBytes , readProcessBytes
, graphAction , Graph(..)
, servantGraphAction
) where ) where
import Control.Exception (evaluate) import Control.Exception (evaluate)
import Control.Monad (forever) import Control.Monad (forever)
import Control.Monad.IO.Class (liftIO, MonadIO) import Control.Monad.IO.Class (liftIO, MonadIO)
import qualified Data.ByteString as BS import qualified Data.ByteString as BS
import qualified Data.ByteString.Lazy as BL
import Data.Text (Text) import Data.Text (Text)
import qualified Data.Text as T import qualified Data.Text as T
import qualified Data.Text.Lazy as TL
import Data.Void (Void) import Data.Void (Void)
import Network.HTTP.Types.Status (Status, status400, status404, status500) import Network.Wai.Handler.Warp (run)
import Network.Wai.Handler.Warp (defaultSettings, setHost, setPort)
import System.Exit (ExitCode (..)) import System.Exit (ExitCode (..))
import System.IO (hGetContents) import System.IO (hGetContents)
import System.Process (CreateProcess (..), StdStream (..), proc, waitForProcess, withCreateProcess) import System.Process (CreateProcess (..), StdStream (..), proc, waitForProcess, withCreateProcess)
import Web.Scotty import Servant.API ((:>), Capture, Get, FromHttpApiData (..), ToHttpApiData (..), QueryParam, (:-), MimeRender (..), Accept (..))
( ActionM import Data.ByteString (ByteString)
, ScottyM import GHC.Generics (Generic)
, defaultOptions import Servant (Handler, err404)
, get import Control.Monad.Catch (throwM)
, pathParam import Servant.Server (err500)
, queryParamMaybe import Data.Maybe (fromMaybe)
, raw import Control.Exception.Annotated (checkpoint, Annotation (Annotation))
, scottyOpts import Servant.Server.Generic (AsServer, genericServe)
, setHeader import Network.Wai (Application)
, settings import Network.HTTP.Media ((//))
, status
, text
)
data Range = TenMinutes | OneHour | OneDay | OneWeek | OneMonth data Range = TenMinutes | OneHour | OneDay | OneWeek | OneMonth
deriving (Eq, Show, Bounded, Enum) deriving (Eq, Show, Bounded, Enum)
@@ -56,6 +51,12 @@ rangeSuffix OneMonth = "30d"
parseRange :: Text -> Maybe Range parseRange :: Text -> Maybe Range
parseRange t = lookup t [(T.pack (rangeSuffix r), r) | r <- [minBound .. maxBound]] parseRange t = lookup t [(T.pack (rangeSuffix r), r) | r <- [minBound .. maxBound]]
instance ToHttpApiData Range where
toQueryParam = T.pack . rangeSuffix
instance FromHttpApiData Range where
parseQueryParam x = maybe (Left ("Could not parse '" <> x <> "' as Range")) Right $ parseRange x
rangeStart :: Range -> String rangeStart :: Range -> String
rangeStart r = "end-" ++ rangeSuffix r rangeStart r = "end-" ++ rangeSuffix r
@@ -91,6 +92,7 @@ data GraphSpec = GraphSpec
{ gsName :: Text { gsName :: Text
, gsTitle :: String , gsTitle :: String
, gsPlots :: [Plot] , gsPlots :: [Plot]
, gsIdentifier :: Graph
} }
deriving (Eq, Show) deriving (Eq, Show)
@@ -141,6 +143,7 @@ ratelimitGraph =
, serLabel = "Requests exhausted/min" , serLabel = "Requests exhausted/min"
} }
] ]
, gsIdentifier = RateLimit
} }
trafficGraph :: GraphSpec trafficGraph :: GraphSpec
@@ -164,6 +167,7 @@ trafficGraph =
, serLabel = "Service calls out/min" , serLabel = "Service calls out/min"
} }
] ]
, gsIdentifier = Traffic
} }
memoryGraph :: GraphSpec memoryGraph :: GraphSpec
@@ -174,17 +178,21 @@ memoryGraph =
[ area "live" "#99ccff" (average "rts_gc_current__a7a") [ area "live" "#99ccff" (average "rts_gc_current__a7a")
, line "peak" "#cc0000" (maximumCF "rts_gc_current__a7a") , line "peak" "#cc0000" (maximumCF "rts_gc_current__a7a")
] ]
Memory
graphs :: [GraphSpec] graphs :: [GraphSpec]
graphs = [memoryGraph, ratelimitGraph, trafficGraph] graphs = [memoryGraph, ratelimitGraph, trafficGraph]
lookupGraph :: [GraphSpec] -> Text -> Maybe GraphSpec -- XXX: Proper lookup..?
lookupGraph specs name = case [g | g <- specs, gsName g == name] of lookupGraph :: [GraphSpec] -> Graph -> Maybe GraphSpec
lookupGraph specs graphMode = case [g | g <- specs, gsIdentifier g == graphMode] of
(g : _) -> Just g (g : _) -> Just g
[] -> Nothing [] -> Nothing
parseNameParam :: [GraphSpec] -> Text -> Maybe GraphSpec parseNameParam :: [GraphSpec] -> Text -> Maybe GraphSpec
parseNameParam specs t = T.stripSuffix ".png" t >>= lookupGraph specs parseNameParam specs t = rightToMaybe (parseQueryParam t) >>= lookupGraph specs
where
rightToMaybe = either (const Nothing) Just
cfName :: CF -> String cfName :: CF -> String
cfName = \case cfName = \case
@@ -255,39 +263,66 @@ readProcessBytes cmd args =
pure (code, out, err) pure (code, out, err)
_ -> pure (ExitFailure 1, BS.empty, "failed to create pipes") _ -> pure (ExitFailure 1, BS.empty, "failed to create pipes")
respond :: Status -> TL.Text -> ActionM () renderGraph :: MonadIO m => FilePath -> FilePath -> GraphSpec -> Range -> m (Either Text BS.ByteString)
respond st body = status st >> text body renderGraph rrdPath rrdtool spec range = do
serve :: FilePath -> FilePath -> GraphSpec -> Range -> ActionM ()
serve rrdPath rrdtool spec range = do
(code, out, err) <- liftIO (readProcessBytes rrdtool (buildGraphArgs rrdPath spec range)) (code, out, err) <- liftIO (readProcessBytes rrdtool (buildGraphArgs rrdPath spec range))
case code of case code of
ExitSuccess ExitSuccess
| not (BS.null out) -> do | not (BS.null out) -> pure $ Right out
setHeader "Content-Type" "image/png" | otherwise -> pure $ Left "empty graph"
raw (BL.fromStrict out) _ -> pure $ Left (T.pack $ take 200 err)
| otherwise -> respond status500 "empty graph"
_ -> respond status500 (TL.pack (take 200 err))
handler :: FilePath -> FilePath -> ActionM ()
handler rrdPath rrdtool = do
name <- pathParam "name"
case parseNameParam graphs name of data Graph
Nothing -> respond status404 "unknown graph" = Memory
| Traffic
| RateLimit
deriving (Show, Eq, Enum, Bounded)
instance ToHttpApiData Graph where
toQueryParam Memory = "memory.png"
toQueryParam Traffic = "traffic.png"
toQueryParam RateLimit = "ratelimit.png"
instance FromHttpApiData Graph where
parseQueryParam "memory.png" = Right Memory
parseQueryParam "traffic.png" = Right Traffic
parseQueryParam "ratelimit.png" = Right RateLimit
parseQueryParam x = Left ("Can't parse '" <> x <> "' as Graph")
data ImagePng
instance MimeRender ImagePng ByteString where
mimeRender _ = BS.fromStrict
instance Accept ImagePng where
contentType _ = "image" // "png"
newtype API mode = API
{ getMetrics :: mode :- "metrics" :> Capture "graph" Graph :> QueryParam "range" Range :> Get '[ImagePng] ByteString
}
deriving Generic
getMetricsHandler :: FilePath -> FilePath -> Graph -> Maybe Range -> Handler ByteString
getMetricsHandler rrdPath rrdtool graphMode mRange = checkpoint (Annotation (graphMode, mRange)) $ do
case lookupGraph graphs graphMode of
Nothing -> throwM err404
Just spec -> do Just spec -> do
rangeArg <- queryParamMaybe "range" let range = fromMaybe OneDay mRange
case rangeArg of g <- renderGraph rrdPath rrdtool spec range
Nothing -> serve rrdPath rrdtool spec OneDay -- XXX: Want logging for the error (not expose to response body)
Just t -> case parseRange t of -- but I don't have logging context yet
Nothing -> respond status400 "invalid range" either (\_ -> throwM err500) pure g
Just range -> serve rrdPath rrdtool spec range
app :: FilePath -> FilePath -> ScottyM () server :: FilePath -> FilePath -> API AsServer
app rrdPath rrdtool = get "/metrics/:name" (handler rrdPath rrdtool) server rrdPath rrdtool = API { getMetrics = getMetricsHandler rrdPath rrdtool }
graphAction :: MonadIO m => FilePath -> FilePath -> Int -> m Void servantApp :: FilePath -> FilePath -> Application
graphAction rrdPath rrdtool port = liftIO $ forever $ scottyOpts opts (app rrdPath rrdtool) servantApp rrdPath rrdtool = genericServe (server rrdPath rrdtool)
where
opts = defaultOptions servantGraphAction :: MonadIO m => FilePath -> FilePath -> Int -> m Void
{ settings = setHost "0.0.0.0" (setPort port defaultSettings) servantGraphAction rrdPath rrdtool port = liftIO $ forever $ run port (servantApp rrdPath rrdtool)
}
+19 -1
View File
@@ -20,15 +20,30 @@ import System.Directory (findExecutable, getTemporaryDirectory, removeFile)
import System.Exit (ExitCode (..)) import System.Exit (ExitCode (..))
import System.Process (readProcessWithExitCode) import System.Process (readProcessWithExitCode)
import Test.Hspec import Test.Hspec
import Servant.API (FromHttpApiData(..), ToHttpApiData (..))
import HomeAssistant.Runtime.Graphing (Graph(..))
traffic :: GraphSpec traffic :: GraphSpec
traffic = case lookupGraph graphs "traffic" of traffic = case lookupGraph graphs Traffic of
Just s -> s Just s -> s
Nothing -> error "traffic graph is missing from the table" Nothing -> error "traffic graph is missing from the table"
spec :: Spec spec :: Spec
spec = do spec = do
describe "Graph HttpApiData" $ do
-- There's an enumerable amount of graphs, we can just enumerate through them all
it "satisfies roundtrip property" $ do
let modes = [minBound..maxBound]
let got = map (parseQueryParam . toQueryParam @Graph) modes
let wanted = map Right modes
got `shouldBe` wanted
describe "parseRange" $ do describe "parseRange" $ do
-- Fully enumerable
it "the http-api-data satisfies roundtrip property" $ do
let rs = [minBound..maxBound]
let got = map (parseQueryParam . toQueryParam @Range) rs
let wanted = map Right rs
got `shouldBe` wanted
it "accepts every suffix in the closed set" $ it "accepts every suffix in the closed set" $
map parseRange ["10m", "1h", "24h", "7d", "30d"] map parseRange ["10m", "1h", "24h", "7d", "30d"]
`shouldBe` [ Just TenMinutes `shouldBe` [ Just TenMinutes
@@ -124,3 +139,6 @@ spec = do
out `shouldSatisfy` BS.isPrefixOf pngMagic out `shouldSatisfy` BS.isPrefixOf pngMagic
) )
`finally` cleanup `finally` cleanup