packages feed

kansoku-api-0.2.0.0: src/Kansoku/Api/Response.hs

{-# LANGUAGE DataKinds #-}
{-# LANGUAGE UndecidableInstances #-}

-- | Shared response machinery for every Kansoku API slice.
module Kansoku.Api.Response
  ( AcceptedResponses,
    ProblemDetails (..),
    KansokuResult (..),
    ProblemJSON,
    problemFieldName,
    problem,
    problemCatalog,
    OkResponses,
    badRequest,
    notFound,
    tooLarge,
    rateLimited,
    unauthorized,
    unavailable,
    forbidden,
  )
where

import Data.Aeson qualified as Aeson
import Data.SOP (I (..), NS (..))
import Data.Text.Encoding qualified as Text.Encoding
import GHC.TypeLits (Symbol)
import Kansoku.Prelude
import Network.HTTP.Media ((//))
import Network.HTTP.Types.Status (status400, status401, status403, status404, status405, status406, status413, status415, status429, status500, status503, statusMessage)
import Servant.API (Accept (..), Header, JSON, MimeRender (..), MimeUnrender (..))
import Servant.API.MultiVerb (AsUnion (..), RespondAs, WithHeaders)

-- | RFC 9457 problem document. `about:blank` uses the HTTP status phrase.
data ProblemDetails = ProblemDetails
  { problemType :: !Text,
    title :: !Text,
    status :: !Int,
    detail :: !Text,
    code :: !Text,
    retryable :: !Bool
  }
  deriving stock (Generic, Eq, Show)

problemFieldName :: String -> String
problemFieldName "problemType" = "type"
problemFieldName field = field

instance ToJSON ProblemDetails where
  toJSON = Aeson.genericToJSON Aeson.defaultOptions {Aeson.fieldLabelModifier = problemFieldName}

instance FromJSON ProblemDetails where
  parseJSON = Aeson.genericParseJSON Aeson.defaultOptions {Aeson.fieldLabelModifier = problemFieldName}

-- | Errors have a distinct media type; successful payloads remain JSON.
data ProblemJSON

instance Accept ProblemJSON where
  contentType _ = "application" // "problem+json"

instance MimeRender ProblemJSON ProblemDetails where
  mimeRender _ = Aeson.encode

instance MimeUnrender ProblemJSON ProblemDetails where
  mimeUnrender _ = Aeson.eitherDecode

-- | What every analytics handler returns. The success status is selected by
-- the route's response list; error constructors map to fixed statuses.
data KansokuResult a
  = KansokuOk a
  | KansokuBadRequest !ProblemDetails
  | KansokuNotFound !ProblemDetails
  | KansokuTooLarge !ProblemDetails
  | KansokuUnavailable !ProblemDetails
  | KansokuRateLimited !ProblemDetails !Int
  deriving stock (Generic, Eq, Show, Functor)

type RateLimitedResponse =
  WithHeaders
    '[Header "Retry-After" Int]
    (ProblemDetails, Int)
    (RespondAs ProblemJSON 429 "Rate limited" ProblemDetails)

type OkResponses (desc :: Symbol) a =
  '[ RespondAs JSON 200 desc a,
     RespondAs ProblemJSON 400 "Malformed request" ProblemDetails,
     RespondAs ProblemJSON 404 "Not found" ProblemDetails,
     RespondAs ProblemJSON 413 "Payload too large" ProblemDetails,
     RespondAs ProblemJSON 503 "Storage unavailable" ProblemDetails,
     RateLimitedResponse
   ]

type AcceptedResponses (desc :: Symbol) a =
  '[ RespondAs JSON 202 desc a,
     RespondAs ProblemJSON 400 "Malformed request" ProblemDetails,
     RespondAs ProblemJSON 404 "Not found" ProblemDetails,
     RespondAs ProblemJSON 413 "Payload too large" ProblemDetails,
     RespondAs ProblemJSON 503 "Storage unavailable" ProblemDetails,
     RateLimitedResponse
   ]

-- These instances deliberately spell out the positional mapping. The final
-- empty case is an exhaustiveness witness: growing either response list makes
-- the instance stop compiling until the result mapping is updated.
instance
  AsUnion
    '[ RespondAs JSON 200 desc a,
       RespondAs ProblemJSON 400 "Malformed request" ProblemDetails,
       RespondAs ProblemJSON 404 "Not found" ProblemDetails,
       RespondAs ProblemJSON 413 "Payload too large" ProblemDetails,
       RespondAs ProblemJSON 503 "Storage unavailable" ProblemDetails,
       RateLimitedResponse
     ]
    (KansokuResult a)
  where
  toUnion = \case
    KansokuOk value -> Z (I value)
    KansokuBadRequest err -> S (Z (I err))
    KansokuNotFound err -> S (S (Z (I err)))
    KansokuTooLarge err -> S (S (S (Z (I err))))
    KansokuUnavailable err -> S (S (S (S (Z (I err)))))
    KansokuRateLimited err delay -> S (S (S (S (S (Z (I (err, delay)))))))
  fromUnion = \case
    Z (I value) -> KansokuOk value
    S (Z (I err)) -> KansokuBadRequest err
    S (S (Z (I err))) -> KansokuNotFound err
    S (S (S (Z (I err)))) -> KansokuTooLarge err
    S (S (S (S (Z (I err))))) -> KansokuUnavailable err
    S (S (S (S (S (Z (I (err, delay))))))) -> KansokuRateLimited err delay
    S (S (S (S (S (S impossible))))) -> case impossible of {}

instance
  AsUnion
    '[ RespondAs JSON 202 desc a,
       RespondAs ProblemJSON 400 "Malformed request" ProblemDetails,
       RespondAs ProblemJSON 404 "Not found" ProblemDetails,
       RespondAs ProblemJSON 413 "Payload too large" ProblemDetails,
       RespondAs ProblemJSON 503 "Storage unavailable" ProblemDetails,
       RateLimitedResponse
     ]
    (KansokuResult a)
  where
  toUnion = \case
    KansokuOk value -> Z (I value)
    KansokuBadRequest err -> S (Z (I err))
    KansokuNotFound err -> S (S (Z (I err)))
    KansokuTooLarge err -> S (S (S (Z (I err))))
    KansokuUnavailable err -> S (S (S (S (Z (I err)))))
    KansokuRateLimited err delay -> S (S (S (S (S (Z (I (err, delay)))))))
  fromUnion = \case
    Z (I value) -> KansokuOk value
    S (Z (I err)) -> KansokuBadRequest err
    S (S (Z (I err))) -> KansokuNotFound err
    S (S (S (Z (I err)))) -> KansokuTooLarge err
    S (S (S (S (Z (I err))))) -> KansokuUnavailable err
    S (S (S (S (S (Z (I (err, delay))))))) -> KansokuRateLimited err delay
    S (S (S (S (S (S impossible))))) -> case impossible of {}

-- | Central status/title/retry policy. Callers supply a stable code and safe detail.
problem :: Int -> Text -> Text -> ProblemDetails
problem status code detail =
  ProblemDetails
    { problemType = "about:blank",
      title =
        Text.Encoding.decodeUtf8
          ( statusMessage
              ( case status of
                  400 -> status400
                  401 -> status401
                  403 -> status403
                  404 -> status404
                  405 -> status405
                  406 -> status406
                  413 -> status413
                  415 -> status415
                  429 -> status429
                  503 -> status503
                  _ -> status500
              )
          ),
      status,
      detail,
      code,
      retryable = status == 503 || status == 429
    }

badRequest, notFound, tooLarge, rateLimited, unauthorized, forbidden, unavailable :: Text -> Text -> ProblemDetails
badRequest = catalogProblem 400
notFound = catalogProblem 404
tooLarge = catalogProblem 413
rateLimited = catalogProblem 429
unauthorized = catalogProblem 401
forbidden = catalogProblem 403
unavailable = catalogProblem 503

-- | Stable public codes and their HTTP statuses. Titles and retry policy come
-- from 'problem'; human-readable detail is supplied by the failure producer.
problemCatalog :: [(Text, Int)]
problemCatalog =
  [ ("invalid_cohort", 400),
    ("cohort_not_found", 404),
    ("cohort_store_unavailable", 503),
    ("invalid_date_range", 400),
    ("query_range_too_large", 400),
    ("too_many_funnel_steps", 400),
    ("too_many_retention_days", 400),
    ("metric_result_too_large", 400),
    ("malformed_request", 400),
    ("validation_failed", 400),
    ("event_id_conflict", 400),
    ("invalid_path_options", 400),
    ("invalid_funnel_steps", 400),
    ("invalid_funnel_step", 400),
    ("invalid_funnel_window", 400),
    ("invalid_retention_days", 400),
    ("invalid_experiment_time_grain", 400),
    ("invalid_experiment_name", 400),
    ("invalid_event_name", 400),
    ("invalid_conversion_event", 400),
    ("unknown_property", 400),
    ("invalid_property_filter", 400),
    ("invalid_property", 400),
    ("invalid_group_by", 400),
    ("invalid_metric_definition", 400),
    ("unauthorized", 401),
    ("forbidden", 403),
    ("not_found", 404),
    ("metric_not_found", 404),
    ("not_implemented", 404),
    ("method_not_allowed", 405),
    ("not_acceptable", 406),
    ("payload_too_large", 413),
    ("batch_too_large", 413),
    ("rate_limited", 429),
    ("unsupported_media_type", 415),
    ("internal_error", 500),
    ("auth_store_unavailable", 503),
    ("queue_unavailable", 503),
    ("clickhouse_unavailable", 503)
  ]

catalogProblem :: Int -> Text -> Text -> ProblemDetails
catalogProblem fallbackStatus code = problem (fromMaybe fallbackStatus (lookup code problemCatalog)) code