packages feed

spire-grpc-0.1.0.0: src/Spire/Grpc/Web.hs

-- | gRPC-Web protocol translation layer.
--
-- gRPC-Web allows browser clients (which cannot use HTTP/2 trailers) to
-- communicate with gRPC servers. The key differences from native gRPC:
--
-- * Content-Type: @application\/grpc-web@ or @application\/grpc-web+proto@
-- * Trailers are encoded in the response body (not HTTP/2 trailers) as a
--   1-byte flag (@0x80@) + length-prefixed trailer block appended after
--   the last message
-- * Works over HTTP/1.1 (no HTTP/2 required)
--
-- @
-- import Spire.Layer (applyLayer)
-- import Spire.Grpc.Web (grpcWebLayer)
--
-- -- Wrap your gRPC server to accept gRPC-Web clients:
-- grpcWebServer = applyLayer grpcWebLayer (grpcServer services)
-- @
module Spire.Grpc.Web
  ( grpcWebLayer
  ) where

import Data.ByteString (ByteString)
import qualified Data.ByteString as BS
import qualified Data.ByteString.Builder as Builder
import qualified Data.ByteString.Builder.Extra as BuilderEx
import qualified Data.ByteString.Lazy as LBS
import qualified Data.CaseInsensitive as CI

import Spire.Layer (Middleware, around)
import Http.Core (Request (..), Response (..), Body, fromBytes, bodyToStrict)


-- | A spire 'Middleware' that translates between gRPC-Web and native gRPC.
--
-- On request: if the content-type starts with @application\/grpc-web@,
-- rewrites it to @application\/grpc@ so the inner service sees a normal
-- gRPC request.
--
-- On response: encodes @grpc-status@ and @grpc-message@ trailers into the
-- response body as a trailer frame (flag @0x80@ + length-prefixed trailer
-- block), and sets the response content-type to @application\/grpc-web@.
--
-- Non-gRPC-Web requests pass through unmodified.
grpcWebLayer :: Middleware IO (Request Body) (Response Body)
grpcWebLayer = around $ \req callInner -> do
  let ct = lookup "content-type" (requestHeaders req)
  case ct of
    Just v | isGrpcWeb v -> do
      -- Rewrite content-type to native gRPC
      let req' = req { requestHeaders = rewriteContentType (requestHeaders req) }
      resp <- callInner req'
      -- Encode trailers into body for gRPC-Web
      encodeGrpcWebResponse resp
    _ -> callInner req


-- | Check if a content-type value indicates gRPC-Web.
isGrpcWeb :: ByteString -> Bool
isGrpcWeb = BS.isPrefixOf "application/grpc-web"


-- | Rewrite @application\/grpc-web*@ content-type to @application\/grpc+proto@.
rewriteContentType
  :: [(CI.CI ByteString, ByteString)]
  -> [(CI.CI ByteString, ByteString)]
rewriteContentType = map rewrite
  where
    rewrite (name, val)
      | name == "content-type" && isGrpcWeb val = (name, "application/grpc+proto")
      | otherwise = (name, val)


-- | Encode a native gRPC response into gRPC-Web format.
--
-- Extracts @grpc-status@ and @grpc-message@ from the response headers,
-- encodes them as a trailer frame appended to the response body, removes
-- them from headers, and sets the content-type to @application\/grpc-web@.
encodeGrpcWebResponse :: Response Body -> IO (Response Body)
encodeGrpcWebResponse resp = do
  bodyBytes <- bodyToStrict (responseBody resp)
  let hdrs = responseHeaders resp
      -- Extract trailer values
      grpcStatus  = lookup "grpc-status" hdrs
      grpcMessage = lookup "grpc-message" hdrs
      -- Build trailer block as "key: value\r\n" pairs
      trailerPairs = maybe [] (\s -> ["grpc-status: " <> s <> "\r\n"]) grpcStatus
                  ++ maybe [] (\m -> ["grpc-message: " <> m <> "\r\n"]) grpcMessage
      trailerBlock = BS.concat trailerPairs
      -- Encode as trailer frame: 0x80 flag + 4-byte big-endian length + data
      trailerFrame = encodeTrailerFrame trailerBlock
      -- Append trailer frame to body
      newBody = bodyBytes <> trailerFrame
      -- Remove trailer headers and set content-type
      newHeaders = setContentType "application/grpc-web+proto"
                 $ removeHeader "grpc-status"
                 $ removeHeader "grpc-message" hdrs
  pure $ resp
    { responseHeaders = newHeaders
    , responseBody    = fromBytes newBody
    }


-- | Encode a trailer block as a gRPC-Web trailer frame.
--
-- Format: 1-byte flag (0x80 = trailer) + 4-byte big-endian length + data.
encodeTrailerFrame :: ByteString -> ByteString
encodeTrailerFrame block =
  let len = BS.length block
  in LBS.toStrict
     $ BuilderEx.toLazyByteStringWith
         (BuilderEx.safeStrategy (5 + len) 64)
         LBS.empty
     $ Builder.word8 0x80
       <> Builder.word32BE (fromIntegral len)
       <> Builder.byteString block


-- | Remove a header by name (case-insensitive).
removeHeader :: CI.CI ByteString -> [(CI.CI ByteString, ByteString)] -> [(CI.CI ByteString, ByteString)]
removeHeader name = filter (\(k, _) -> k /= name)


-- | Set the content-type header, replacing any existing one.
setContentType :: ByteString -> [(CI.CI ByteString, ByteString)] -> [(CI.CI ByteString, ByteString)]
setContentType ct hdrs =
  ("content-type", ct) : removeHeader "content-type" hdrs