packages feed

kiroku-metrics-0.3.0.0: src/Kiroku/Metrics/Capabilities.hs

{-# LANGUAGE NoFieldSelectors #-}

-- | Mount-relative discovery, computed from configuration and actual provider wiring.
module Kiroku.Metrics.Capabilities (
    WebSocketChannels (..),
    noWebSocketChannels,
    storeWebSocketChannels,
    ProviderPresence (..),
    RouteAvailability (..),
    Capabilities (..),
    capabilitiesFor,
    kirokuMetricsVersion,
    processLocalRoutes,
    capabilitiesApp,
    capabilitiesPath,
) where

import Data.Aeson (FromJSON (..), ToJSON (..), encode, object, withObject, (.:), (.=))
import Data.Text (Text)
import Data.Text qualified as T
import Data.Version (showVersion)
import Network.HTTP.Types (status200, status404, status405)
import Network.Wai (Application, mapResponseHeaders, pathInfo, requestMethod, responseHeaders, responseLBS, responseStatus)

import Kiroku.Metrics.Config (MetricsServerConfig (..))
import Kiroku.Metrics.Cors qualified as Cors
import Kiroku.Metrics.JSON (errorResponse, jsonResponse)
import Paths_kiroku_metrics qualified as Paths

-- | Declaration by the host choosing the opaque WebSocket application.
data WebSocketChannels = WebSocketChannels
    {metricsChannel :: !Bool, eventsChannel :: !Bool}
    deriving stock (Eq, Show)

noWebSocketChannels, storeWebSocketChannels :: WebSocketChannels
noWebSocketChannels = WebSocketChannels False False
storeWebSocketChannels = WebSocketChannels True True

data ProviderPresence = ProviderPresence
    { hasSubscriptionStatus :: !Bool
    , hasCheckpointInventory :: !Bool
    , hasBrowser :: !Bool
    , hasDeadLetters :: !Bool
    , presentWebSocketChannels :: !WebSocketChannels
    }
    deriving stock (Eq, Show)

data RouteAvailability = RouteAvailability
    { metrics :: !Bool
    , prometheus :: !Bool
    , health :: !Bool
    , subscriptionsLive :: !Bool
    , subscriptionsCheckpoints :: !Bool
    , deadLetters :: !Bool
    , browse :: !Bool
    , websocketMetrics :: !Bool
    , websocketEvents :: !Bool
    }
    deriving stock (Eq, Show)

data Capabilities = Capabilities
    { package :: !Text
    , version :: !Text
    , routes :: !RouteAvailability
    , corsIsEnabled :: !Bool
    , processLocal :: ![Text]
    }
    deriving stock (Eq, Show)

kirokuMetricsVersion :: Text
kirokuMetricsVersion = T.pack (showVersion Paths.version)

-- | These answers describe only the process answering the request.
processLocalRoutes :: [Text]
processLocalRoutes = ["metrics", "prometheus", "health", "subscriptions_live", "websocket_metrics"]

capabilitiesFor :: MetricsServerConfig -> ProviderPresence -> Capabilities
capabilitiesFor cfg presence = Capabilities "kiroku-metrics" kirokuMetricsVersion availability (Cors.corsEnabled cfg.cors) processLocalRoutes
  where
    availability =
        RouteAvailability
            cfg.enableJSON
            cfg.enablePrometheus
            cfg.enableJSON
            presence.hasSubscriptionStatus
            presence.hasCheckpointInventory
            presence.hasDeadLetters
            presence.hasBrowser
            (cfg.enableWebSocket && presence.presentWebSocketChannels.metricsChannel)
            (cfg.enableWebSocket && presence.presentWebSocketChannels.eventsChannel)

instance ToJSON RouteAvailability where
    toJSON r =
        object
            [ "metrics" .= r.metrics
            , "prometheus" .= r.prometheus
            , "health" .= r.health
            , "subscriptions_live" .= r.subscriptionsLive
            , "subscriptions_checkpoints" .= r.subscriptionsCheckpoints
            , "dead_letters" .= r.deadLetters
            , "browse" .= r.browse
            , "websocket_metrics" .= r.websocketMetrics
            , "websocket_events" .= r.websocketEvents
            ]

instance FromJSON RouteAvailability where
    parseJSON = withObject "RouteAvailability" $ \o ->
        RouteAvailability
            <$> o .: "metrics"
            <*> o .: "prometheus"
            <*> o .: "health"
            <*> o .: "subscriptions_live"
            <*> o .: "subscriptions_checkpoints"
            <*> o .: "dead_letters"
            <*> o .: "browse"
            <*> o .: "websocket_metrics"
            <*> o .: "websocket_events"

instance ToJSON Capabilities where
    toJSON c =
        object
            [ "package" .= c.package
            , "version" .= c.version
            , "routes" .= c.routes
            , "cors" .= object ["enabled" .= c.corsIsEnabled]
            , "process_local" .= c.processLocal
            ]

instance FromJSON Capabilities where
    parseJSON = withObject "Capabilities" $ \o -> do
        corsObject <- o .: "cors"
        enabled <- withObject "cors" (.: "enabled") corsObject
        Capabilities <$> o .: "package" <*> o .: "version" <*> o .: "routes" <*> pure enabled <*> o .: "process_local"

capabilitiesPath :: [Text]
capabilitiesPath = ["capabilities"]

-- | No store access; the encoded body is shared by requests to this application.
capabilitiesApp :: Capabilities -> Application
capabilitiesApp caps = \req respond -> do
    let response
            | pathInfo req /= capabilitiesPath = errorResponse status404 "not_found" "Not found" Nothing
            | requestMethod req == "GET" || requestMethod req == "HEAD" = jsonResponse status200 body
            | otherwise =
                mapResponseHeaders (("Allow", "GET, HEAD") :) $
                    errorResponse status405 "method_not_allowed" "Use GET or HEAD." Nothing
    respond $
        if requestMethod req == "HEAD"
            then responseLBS (responseStatus response) (responseHeaders response) ""
            else response
  where
    body = encode caps