packages feed

grpc-spec-1.0.0: src/Network/GRPC/Spec/CustomMetadata/Raw.hs

{-# LANGUAGE OverloadedStrings #-}

-- | Custom metadata
--
-- These are application-defined headers/trailers.
--
-- Intended for unqualified import.
module Network.GRPC.Spec.CustomMetadata.Raw (
    -- * Definition
    CustomMetadata(CustomMetadata)
  , customMetadataName
  , customMetadataValue
  , safeCustomMetadata
  , HeaderName(BinaryHeader, AsciiHeader)
  , safeHeaderName
  , isValidAsciiValue
  ) where

import Control.DeepSeq (NFData)
import Control.Monad
import Data.ByteString qualified as BS.Strict
import Data.ByteString qualified as Strict (ByteString)
import Data.List qualified as List
import Data.Maybe (fromMaybe)
import Data.Set (Set)
import Data.Set qualified as Set
import Data.String
import Data.Word
import GHC.Generics (Generic)
import GHC.Show
import GHC.Stack

import Network.GRPC.Spec.Util.ByteString (strip, ascii)

{-------------------------------------------------------------------------------
  Definition

  > Custom-Metadata → Binary-Header / ASCII-Header
  > Binary-Header   → {Header-Name "-bin" } {base64 encoded value}
  > ASCII-Header    → Header-Name ASCII-Value

  Implementation note: ASCII headers and binary headers are distinguished based
  on their name (see 'HeaderName'). We do /not/ introduce a different type for
  the /values/ of such headers, because if we did, we would then need additional
  machinery to make sure that binary header names are paired with binary header
  values, and similarly for ASCII headers, with little benefit. Instead we check
  in the smart constructor for 'CustomMetadata' that the header value satisfies
  the rules for the particular type of header.
-------------------------------------------------------------------------------}

-- | Custom metadata
--
-- This is an arbitrary set of key-value pairs defined by the application layer.
--
-- Custom metadata order is not guaranteed to be preserved except for values
-- with duplicate header names. Duplicate header names may have their values
-- joined with "," as the delimiter and be considered semantically equivalent.
data CustomMetadata = UnsafeCustomMetadata {
      -- | Header name
      --
      -- The header name determines if this is an ASCII header or a binary
      -- header; see the t'CustomMetadata' pattern synonym.
      customMetadataName :: HeaderName

      -- | Header value
    , customMetadataValue :: Strict.ByteString
    }
  deriving stock (Eq, Generic)
  deriving anyclass (NFData)

-- | 'Show' instance relies on the v'CustomMetadata' pattern synonym
instance Show CustomMetadata where
  showsPrec p (UnsafeCustomMetadata name value) = showParen (p >= appPrec1) $
         showString "CustomMetadata "
       . showsPrec appPrec1 name
       . showSpace
       . showsPrec appPrec1 value

-- | Check for valid ASCII header value
--
-- > ASCII-Value → 1*( %x20-%x7E ) ; space and printable ASCII
--
-- NOTE: By rights this should verify that the header is non-empty. However,
-- empty header values do occasionally show up, and so we permit them. The main
-- reason for checking for validity at all is to ensure that we don't confuse
-- binary headers and ASCII headers.
isValidAsciiValue :: Strict.ByteString -> Bool
isValidAsciiValue bs = BS.Strict.all (\c -> 0x20 <= c && c <= 0x7E) bs

-- | Construct t'CustomMetadata'
--
-- Returns 'Nothing' if the 'HeaderName' indicates an ASCII header but the
-- value is not valid ASCII (consider using a binary header instead).
safeCustomMetadata :: HeaderName -> Strict.ByteString -> Maybe CustomMetadata
safeCustomMetadata name value =
    case name of
      UnsafeAsciiHeader _ -> do
        guard $ isValidAsciiValue value
        return $ UnsafeCustomMetadata name (strip value)
      UnsafeBinaryHeader _ ->
        -- Values of binary headers are not subject to any constraints
        return $ UnsafeCustomMetadata name value

pattern CustomMetadata ::
     HasCallStack
  => HeaderName -> Strict.ByteString -> CustomMetadata
pattern CustomMetadata name value <- UnsafeCustomMetadata name value
  where
    CustomMetadata name value =
        fromMaybe (invalid constructedForError) $
          safeCustomMetadata name value
      where
        constructedForError :: CustomMetadata
        constructedForError = UnsafeCustomMetadata name value

{-# COMPLETE CustomMetadata #-}

{-------------------------------------------------------------------------------
  Header-Name

  > Header-Name → 1*( %x30-39 / %x61-7A / "_" / "-" / ".") ; 0-9 a-z _ - .
----------------------\--------------------------------------------------------}

-- | Header name
--
-- To construct a 'HeaderName', you can either use the 'IsString' instance
--
-- > "foo"     :: HeaderName -- an ASCII header
-- > "bar-bin" :: HeaderName -- a binary header
--
-- or alternatively use the 'AsciiHeader' and 'BinaryHeader' patterns
--
-- > AsciiHeader  "foo"
-- > BinaryHeader "bar-bin"
--
-- The latter style is more explicit, and can catch more errors:
--
-- > AsciiHeader  "foo-bin" -- exception: unexpected -bin suffix
-- > BinaryHeader "bar"     -- exception: expected   -bin suffix
--
-- Header names cannot be empty, and must consist of digits (@0-9@), lowercase
-- letters (@a-z@), underscore (@_@), hyphen (@-@), or period (@.@).
-- Reserved header names are disallowed.
--
-- See also 'safeHeaderName'.
data HeaderName =
    -- | Binary header
    --
    -- Binary headers will be base-64 encoded.
    --
    -- The header name must have a @-bin@ suffix (runtime libraries use this
    -- suffix to detect binary headers and properly apply base64 encoding &
    -- decoding as headers are sent and received).
    --
    -- Since this is binary data, padding considerations do not apply.
    UnsafeBinaryHeader Strict.ByteString

    -- | ASCII header
    --
    -- ASCII headers cannot be empty, and can only use characters in the range
    -- @0x20 .. 0x7E@. Note that although this range includes whitespace, any
    -- padding will be removed when constructing the value.
    --
    -- The gRPC spec is not precise about what exactly constitutes \"padding\",
    -- but the ABNF spec defines it as "space and horizontal tab"
    -- <https://www.rfc-editor.org/rfc/rfc5234#section-3.1>.
  | UnsafeAsciiHeader Strict.ByteString
  deriving stock (Eq, Ord, Generic)
  deriving anyclass (NFData)

pattern BinaryHeader :: HasCallStack => Strict.ByteString -> HeaderName
pattern BinaryHeader name <- UnsafeBinaryHeader name
  where
    BinaryHeader name =
      case safeHeaderName name of
        Just name'@UnsafeBinaryHeader{} ->
          name'
        Just UnsafeAsciiHeader{} ->
          error "binary headers must have -bin suffix"
        Nothing ->
          error $ "Invalid header name " ++ show name

pattern AsciiHeader :: HasCallStack => Strict.ByteString -> HeaderName
pattern AsciiHeader name <- UnsafeAsciiHeader name
  where
    AsciiHeader name =
      case safeHeaderName name of
        Just name'@UnsafeAsciiHeader{} ->
          name'
        Just UnsafeBinaryHeader{} ->
          error "ASCII headers cannot have -bin suffix"
        Nothing ->
          error $ "Invalid header name " ++ show name

{-# COMPLETE BinaryHeader, AsciiHeader #-}

-- | Check for header name validity
--
-- We choose between 'BinaryHeader' and 'AsciiHeader' based on the presence or
-- absence of a @-bin suffix.
safeHeaderName :: Strict.ByteString -> Maybe HeaderName
safeHeaderName bs = do
    guard $ BS.Strict.length bs >= 1
    guard $ BS.Strict.all isValidChar bs
    guard $ not $ "grpc-" `BS.Strict.isPrefixOf` bs
    guard $ not $ bs `Set.member` reservedNames
    return $ if "-bin" `BS.Strict.isSuffixOf` bs
               then UnsafeBinaryHeader bs
               else UnsafeAsciiHeader  bs
  where
    isValidChar :: Word8 -> Bool
    isValidChar c = or [
          0x30 <= c && c <= 0x39
        , 0x61 <= c && c <= 0x7A
        , c == ascii '_'
        , c == ascii '-'
        , c == ascii '.'
        ]

    -- Reserved header names that do not start with @grpc-@
    reservedNames :: Set Strict.ByteString
    reservedNames = Set.fromList [
          "user-agent"
        , "content-type"
        , "te"
        , "trailer"
        ]

instance IsString HeaderName where
  fromString str =
       fromMaybe (invalid constructedForError) $
         safeHeaderName (fromString str)
    where
      constructedForError :: HeaderName
      constructedForError =
          if "-bin" `List.isSuffixOf` str
            then UnsafeBinaryHeader $ fromString str
            else UnsafeAsciiHeader  $ fromString str

-- | 'Show' instance relies on the 'IsString' instance
instance Show HeaderName where
  show (UnsafeBinaryHeader name) = show name
  show (UnsafeAsciiHeader  name) = show name

{-------------------------------------------------------------------------------
  Internal auxiliary
-------------------------------------------------------------------------------}

invalid :: (Show a, HasCallStack) => a -> b
invalid x = error $ "Invalid: " ++ show x ++ " at "