packages feed

keiro-0.14.0.0: src/Keiro/Outbox/Rejection.hs

-- | Validated terminal refusal data for outbox publication.
--
-- This module is intentionally not exposed by the package. Public callers use
-- the abstract 'PublishRejection' type and smart constructor re-exported from
-- "Keiro.Outbox.Types"; database decoders inside the package can use the data
-- constructor after schema constraints have validated the stored values.
module Keiro.Outbox.Rejection
  ( PublishRejection (..),
    PublishRejectionError (..),
    mkPublishRejection,
  )
where

import Data.ByteString qualified as ByteString
import Data.Text qualified as Text
import Data.Text.Encoding qualified as TE
import Keiro.Prelude

-- | A stable machine-readable refusal code and optional operator detail.
--
-- Codes are lowercase ASCII identifiers of 1 to 64 characters. Detail is
-- non-empty when present and is bounded to 1024 UTF-8 bytes.
data PublishRejection = PublishRejection
  { publishRejectionCode :: !Text,
    publishRejectionDetail :: !(Maybe Text)
  }
  deriving stock (Generic, Eq, Show)

-- | Why 'mkPublishRejection' refused caller-provided data.
data PublishRejectionError
  = InvalidPublishRejectionCode !Text
  | PublishRejectionDetailEmpty
  | PublishRejectionDetailTooLong !Int
  deriving stock (Generic, Eq, Show)

-- | Validate a terminal publication refusal without normalizing caller data.
mkPublishRejection :: Text -> Maybe Text -> Either PublishRejectionError PublishRejection
mkPublishRejection code detail
  | not (validCode code) = Left (InvalidPublishRejectionCode code)
  | otherwise =
      case detail of
        Just value
          | Text.null value -> Left PublishRejectionDetailEmpty
          | detailBytes value > 1024 -> Left (PublishRejectionDetailTooLong (detailBytes value))
        _ -> Right PublishRejection {publishRejectionCode = code, publishRejectionDetail = detail}
  where
    detailBytes = ByteString.length . TE.encodeUtf8

validCode :: Text -> Bool
validCode code =
  Text.length code <= 64
    && case Text.uncons code of
      Nothing -> False
      Just (first, rest) -> isLowerAscii first && Text.all isCodeTail rest
  where
    isLowerAscii char = char >= 'a' && char <= 'z'
    isCodeTail char =
      isLowerAscii char
        || (char >= '0' && char <= '9')
        || char == '.'
        || char == '_'
        || char == '-'