packages feed

keiro-ops-http-0.22.0.0: src/Keiro/Ops/Http/Compose.hs

-- | Mount already allocated applications. This router owns no runtime resources.
module Keiro.Ops.Http.Compose
  ( SurfacePrefix,
    mkSurfacePrefix,
    surfacePrefixText,
    keiroPrefix,
    kirokuPrefix,
    shibuyaPrefix,
    pgmqPrefix,
    MountedSurface (..),
    ComposeConfig (..),
    defaultComposeConfig,
    ComposeError (..),
    renderComposeError,
    composeSurfaces,
    stripSurfacePrefix,
    composedCorsMiddleware,
  )
where

import Data.Aeson (encode, object, (.=))
import Data.ByteString qualified as Bytes
import Data.Map.Strict qualified as Map
import Data.Text (Text)
import Data.Text qualified as Text
import Keiro.Ops.Http.Application (OpsHttpError (..), errorResponse)
import Keiro.Ops.Http.Cors (corsMiddleware)
import Network.HTTP.Types
import Network.Wai

newtype SurfacePrefix = SurfacePrefix Text
  deriving stock (Eq, Ord, Show)

mkSurfacePrefix :: Text -> Either ComposeError SurfacePrefix
mkSurfacePrefix value
  | not (Text.null value), not (Text.isPrefixOf "-" value), Text.all valid value = Right (SurfacePrefix value)
  | otherwise = Left (InvalidPrefix value)
  where
    valid c = c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '-'

surfacePrefixText :: SurfacePrefix -> Text
surfacePrefixText (SurfacePrefix value) = value

keiroPrefix, kirokuPrefix, shibuyaPrefix, pgmqPrefix :: SurfacePrefix
keiroPrefix = SurfacePrefix "keiro"
kirokuPrefix = SurfacePrefix "kiroku"
shibuyaPrefix = SurfacePrefix "shibuya"
pgmqPrefix = SurfacePrefix "pgmq"

data MountedSurface = MountedSurface
  { prefix :: !SurfacePrefix,
    application :: !Application
  }

newtype ComposeConfig = ComposeConfig {allowedOrigins :: [Text]}
  deriving stock (Eq, Show)

defaultComposeConfig :: ComposeConfig
defaultComposeConfig = ComposeConfig []

data ComposeError = InvalidPrefix !Text | DuplicatePrefix !Text | NoSurfaces
  deriving stock (Eq, Show)

renderComposeError :: ComposeError -> Text
renderComposeError = \case
  InvalidPrefix value -> "invalid surface prefix: " <> value
  DuplicatePrefix value -> "duplicate surface prefix: " <> value
  NoSurfaces -> "at least one inspection surface must be mounted"

composeSurfaces :: ComposeConfig -> [MountedSurface] -> Either ComposeError Application
composeSurfaces _ [] = Left NoSurfaces
composeSurfaces config surfaces = do
  mounts <- foldl add (Right Map.empty) surfaces
  let names = map (surfacePrefixText . (.prefix)) surfaces
      index =
        responseLBS status200 [(hContentType, "application/json; charset=utf-8")] $
          encode (object ["surfaces" .= map (\name -> object ["prefix" .= name, "path" .= ("/" <> name)]) names])
      router req respond = case pathInfo req of
        [] | requestMethod req == methodGet -> respond index
        [] -> respond $ mapResponseHeaders ((hAllow, "GET") :) $ errorResponse (OpsHttpError status405 "method_not_allowed" "the composed root answers GET only" Nothing)
        name : _ -> case Map.lookup name mounts of
          Just surface -> case stripSurfacePrefix surface.prefix req of
            Just relative -> surface.application relative respond
            Nothing -> respond (errorResponse (OpsHttpError status400 "invalid_path" "inconsistent encoded request path" Nothing))
          Nothing -> respond (errorResponse (OpsHttpError status404 "surface_not_mounted" ("no surface is mounted at /" <> name) (Just (object ["mounted" .= names]))))
  pure (composedCorsMiddleware config.allowedOrigins router)
  where
    add result surface = do
      mounts <- result
      let name = surfacePrefixText surface.prefix
      if Map.member name mounts then Left (DuplicatePrefix name) else Right (Map.insert name surface mounts)

-- | Strip exactly one raw segment, preserving every remaining byte and query.
-- Only the mount root's trailing slash is normalized. Inconsistent synthetic
-- WAI requests are rejected rather than routed to a different target.
stripSurfacePrefix :: SurfacePrefix -> Request -> Maybe Request
stripSurfacePrefix prefix req = case pathInfo req of
  name : rest
    | name == surfacePrefixText prefix,
      Bytes.isPrefixOf "/" (rawPathInfo req),
      decodePathSegments (rawPathInfo req) == pathInfo req ->
        let remainder = Bytes.dropWhile (/= 47) (Bytes.drop 1 (rawPathInfo req))
            raw = if Bytes.null remainder then "/" else remainder
            segments = if rest == [""] then [] else rest
         in Just req {pathInfo = segments, rawPathInfo = raw}
  _ -> Nothing

-- | Use the same exact-origin, duplicate-safe policy as standalone HTTP.
composedCorsMiddleware :: [Text] -> Middleware
composedCorsMiddleware = corsMiddleware