http-interchange 0.1.0.0 → 0.2.0.0
raw patch · 10 files changed
+346/−175 lines, 10 filesdep +contiguousdep ~base
Dependencies added: contiguous
Dependency ranges changed: base
Files
- CHANGELOG.md +6/−0
- http-interchange.cabal +10/−4
- src/Http/Bodied.hs +3/−0
- src/Http/Headers.hs +118/−0
- src/Http/Message/Request.hs +0/−88
- src/Http/Message/Response.hs +0/−74
- src/Http/Request.hs +96/−0
- src/Http/Response.hs +78/−0
- src/Http/Types.hs +22/−0
- test/Main.hs +13/−9
CHANGELOG.md view
@@ -1,5 +1,11 @@ # Revision history for http-interchange +## 0.2.0.0 -- 2023-08-14++* Redo module structure+* Introduce Headers type. Currently a low performance implementation.+* Introduce Http.Types module, which reexports all types+ ## 0.1.0.0 -- 2023-07-25 * Initial release
http-interchange.cabal view
@@ -1,12 +1,15 @@ cabal-version: 3.0 name: http-interchange-version: 0.1.0.0+version: 0.2.0.0 license: BSD-3-Clause license-file: LICENSE author: Andrew Martin maintainer: andrew.thaddeus@gmail.com synopsis: Types and serialization for HTTP-description: Types and serialization for HTTP. Very minimal.+description:+ Types and serialization for HTTP. This includes things like request+ line, status line, and headers. There is also a collection type+ for headers that supports fast, case-insensitive lookup. copyright: 2023 Andrew Martin category: Data build-type: Simple@@ -15,10 +18,12 @@ library ghc-options: -Wall -O2 exposed-modules:- Http.Message.Request- Http.Message.Response+ Http.Request+ Http.Response Http.Bodied Http.Header+ Http.Headers+ Http.Types build-depends: , base >=4.16.4.0 && <5 , bytesmith >=0.3.10@@ -26,6 +31,7 @@ , primitive >=0.7.4 , text >=2.0 , bytebuild >=0.3.10+ , contiguous >=0.6.4 hs-source-dirs: src default-language: GHC2021
src/Http/Bodied.hs view
@@ -4,7 +4,10 @@ import Data.Bytes.Chunks (Chunks) +-- | An HTTP request or response with a body. data Bodied a = Bodied { metadata :: !a+ -- ^ The request or response. , body :: !Chunks+ -- ^ The body. }
+ src/Http/Headers.hs view
@@ -0,0 +1,118 @@+{-# language DuplicateRecordFields #-}+{-# language DerivingStrategies #-}+{-# language GeneralizedNewtypeDeriving #-}+{-# language OverloadedStrings #-}++-- TODO: Right now, this uses a crummy implementation. Instead, we+-- should hash all the keys and search the hashes first to speed+-- things up.+module Http.Headers+ ( -- * Types+ Headers+ , LookupException(..)+ -- * Construct+ , fromArray+ -- * Expose+ , toArray+ -- * Lookup+ , lookup+ , lookupFirst+ , lookupAll+ -- * Specialized Lookup+ , lookupContentType+ , lookupContentLength+ , lookupTransferEncoding+ ) where++import Prelude hiding (lookup)++import Data.Text (Text)+import Data.Primitive (SmallArray)+import Http.Header (Header(Header))+import Data.Foldable (foldl')++import qualified Data.List as List+import qualified Data.Primitive.Contiguous as C+import qualified Data.Text as T+import qualified Http.Header++-- | Collection of HTTP headers. Supports case-insensitive lookup.+-- This is intended to be used for small collections of headers.+-- Expect degraded performance if this is used for collections of+-- more than 128 headers.+--+-- This preserves the original order of the headers and the original+-- case of the header names.+newtype Headers = Headers (SmallArray Header)+ deriving newtype (Show)++-- | Many headers cannot appear more than once. This is part of+-- the return type for 'lookup', and it helps us track whether the+-- lookup failure was the result of something that might be expected+-- (the header was @Missing@) or something that is definitely a mistake+-- (the header was duplicated).+data LookupException+ = Duplicate+ | Missing++-- | Convert array of headers to a 'Headers' collection that supports+-- efficient lookup.+fromArray :: SmallArray Header -> Headers+fromArray = Headers++-- | Recover the original headers from from the 'Headers' collection.+-- This is @O(1)@ and is most commonly used to fold over the headers.+toArray :: Headers -> SmallArray Header+toArray (Headers xs) = xs++-- | Case insensitive lookup of an HTTP header. If the header is present,+-- returns both the original header name (may differs in case from the+-- header name searched for) and the header value. Only returns the first+-- occurrence of the header.+lookupFirst ::+ Text -- header name+ -> Headers+ -> Maybe Header+lookupFirst needle (Headers hdrs) =+ List.find (\Header{name} -> caseInsensitiveEq needle name) hdrs++-- | Lookup a header that should not appear more than one time and verify+-- that it did not occur more than once. If it appears more than once+-- (or less than once), returns a 'LookupException'.+lookup ::+ Text -- header name+ -> Headers+ -> Either LookupException Header+lookup needle hdrs@(Headers xs) = case lookupFirst needle hdrs of+ Nothing -> Left Missing+ Just hdr ->+ let count = foldl'+ (\acc Header{name} -> if caseInsensitiveEq needle name+ then acc + 1+ else acc+ ) (0 :: Int) xs+ in if count > 1 then Left Duplicate else Right hdr++-- | Lookup a header that may appear more than once. Some headers+-- (e.g. @Set-Cookie@, @X-Forwarded-For@) are allowed to appear multiple+-- times. This returns all the headers that matched along with their+-- original names.+lookupAll ::+ Text -- header name+ -> Headers+ -> SmallArray Header+lookupAll needle (Headers hdrs) =+ C.filter (\Header{name} -> caseInsensitiveEq needle name) hdrs+ +-- TODO: Make this not allocate+caseInsensitiveEq :: Text -> Text -> Bool+caseInsensitiveEq a b = T.toLower a == T.toLower b++lookupTransferEncoding :: Headers -> Either LookupException Header+lookupTransferEncoding = lookup "transfer-encoding"++lookupContentType :: Headers -> Either LookupException Header+lookupContentType = lookup "content-type"++lookupContentLength :: Headers -> Either LookupException Header+lookupContentLength = lookup "content-length"
− src/Http/Message/Request.hs
@@ -1,88 +0,0 @@-{-# language LambdaCase #-}-{-# language MagicHash #-}--module Http.Message.Request- ( Request(..)- , RequestLine(..)- -- * Encode Request- , builder- , toChunks- , toChunksOnto- -- * Encode Request with Body- , bodiedToChunks- ) where--import Data.Bytes.Builder (Builder)-import Data.Bytes.Chunks (Chunks)-import Data.Primitive (SmallArray)-import Data.Text (Text)-import Data.Word (Word8)-import GHC.Exts (Ptr(Ptr))-import Http.Bodied (Bodied(..))-import Http.Header (Header)--import qualified Data.Bytes.Text.Utf8 as Utf8-import qualified Data.Bytes.Builder as Builder-import qualified Data.Bytes.Chunks as Chunks-import qualified Http.Header as Header---- | The response status line and the response headers.-data Request = Request- { requestLine :: !RequestLine- , headers :: !(SmallArray Header)- } deriving (Show)--data RequestLine = RequestLine- { method :: {-# UNPACK #-} !Text- , path :: {-# UNPACK #-} !Text- , versionMajor :: !Word8- , versionMinor :: !Word8- } deriving (Show)--builderRequestLine :: RequestLine -> Builder-builderRequestLine RequestLine{method,path,versionMajor,versionMinor} =- Builder.copy (Utf8.fromText method)- <>- Builder.ascii ' '- <>- Builder.copy (Utf8.fromText path)- <>- Builder.ascii6 ' ' 'H' 'T' 'T' 'P' '/'- <>- Builder.word8Dec versionMajor- <>- Builder.ascii '.'- <>- Builder.word8Dec versionMinor- <>- Builder.ascii2 '\r' '\n'--toChunks :: Request -> Chunks-toChunks = Builder.run 256 . builder--toChunksOnto :: Request -> Chunks -> Chunks-toChunksOnto r ch = Builder.runOnto 256 (builder r) ch--builder :: Request -> Builder-builder Request{requestLine,headers} =- builderRequestLine requestLine- <>- Header.builderSmallArray headers- <>- Builder.ascii2 '\r' '\n'---- | This adds the Content-Length header. It must not already--- be present.-bodiedToChunks :: Bodied Request -> Chunks-bodiedToChunks Bodied{metadata=Request{requestLine,headers},body} =- Builder.runOnto 256- ( builderRequestLine requestLine- <>- Header.builderSmallArray headers- <>- Builder.cstring (Ptr "Content-Length: "#)- <>- Builder.word64Dec (fromIntegral (Chunks.length body))- <>- Builder.ascii4 '\r' '\n' '\r' '\n'- ) body
− src/Http/Message/Response.hs
@@ -1,74 +0,0 @@-{-# language LambdaCase #-}--module Http.Message.Response- ( Response(..)- , StatusLine(..)- , decode- ) where--import Control.Monad (when)-import Data.Bytes (Bytes)-import Data.Bytes.Parser (Parser)-import Data.Bytes.Types (Bytes(Bytes))-import Data.Primitive (SmallArray,ByteArray(ByteArray))-import Data.Word (Word8,Word16)-import Data.Text (Text)-import Http.Header (Header)--import qualified Data.Text.Internal as Text-import qualified Data.Text.Array-import qualified Data.Bytes.Parser as Parser-import qualified Data.Bytes.Parser.Latin as Latin-import qualified Http.Header as Header---- | The response status line and the response headers.-data Response = Response- { statusLine :: !StatusLine- , headers :: !(SmallArray Header)- } deriving (Show)--data StatusLine = StatusLine- { versionMajor :: !Word8- , versionMinor :: !Word8- , statusCode :: !Word16- , statusReason :: {-# UNPACK #-} !Text- } deriving (Show)---- | Decode the response status line and the response headers. Fails if--- any extraneous input is present after the double CRLF sequence that--- ends the headers.-decode :: Int -> Bytes -> Maybe Response-decode !n !b =- Parser.parseBytesMaybe (parserResponse n <* Parser.endOfInput ()) b--parserResponse ::- Int -- ^ Maximum number of headers- -> Parser () s Response-parserResponse !n = do- statusLine <- parserStatusLine- headers <- Header.parserSmallArray n- pure Response{statusLine,headers}---- Consumes the trailing CRLF-parserStatusLine :: Parser () s StatusLine-parserStatusLine = do- Latin.char5 () 'H' 'T' 'T' 'P' '/'- versionMajor <- Latin.decWord8 ()- Latin.char () '.'- versionMinor <- Latin.decWord8 ()- Latin.char () ' '- statusCode <- Latin.decWord16 ()- when (statusCode >= 1000) (Parser.fail ())- Latin.char () ' '- -- RFC 7230: reason-phrase = *( HTAB / SP / VCHAR / obs-text )- statusReason <- Parser.takeWhile $ \c ->- (c >= 0x20 && c <= 0x7e)- ||- (c == 0x09)- Latin.char2 () '\r' '\n'- pure StatusLine{versionMajor,versionMinor,statusCode,statusReason=unsafeBytesToText statusReason}--unsafeBytesToText :: Bytes -> Text-{-# inline unsafeBytesToText #-}-unsafeBytesToText (Bytes (ByteArray arr) off len) =- Text.Text (Data.Text.Array.ByteArray arr) off len
+ src/Http/Request.hs view
@@ -0,0 +1,96 @@+{-# language DuplicateRecordFields #-}+{-# language LambdaCase #-}+{-# language MagicHash #-}++module Http.Request+ ( Request(..)+ , RequestLine(..)+ -- * Encode Request+ , builder+ , toChunks+ , toChunksOnto+ -- * Encode Request with Body+ , bodiedToChunks+ ) where++import Data.Bytes.Builder (Builder)+import Data.Bytes.Chunks (Chunks)+import Data.Primitive (SmallArray)+import Data.Text (Text)+import Data.Word (Word8)+import GHC.Exts (Ptr(Ptr))+import Http.Bodied (Bodied(..))+import Http.Header (Header)+import Http.Headers (Headers)++import qualified Data.Bytes.Text.Utf8 as Utf8+import qualified Data.Bytes.Builder as Builder+import qualified Data.Bytes.Chunks as Chunks+import qualified Http.Header as Header+import qualified Http.Headers as Headers++-- | The request line and the request headers.+data Request = Request+ { requestLine :: !RequestLine+ , headers :: !Headers+ } deriving (Show)++-- | An HTTP request line+data RequestLine = RequestLine+ { method :: {-# UNPACK #-} !Text+ , path :: {-# UNPACK #-} !Text+ , versionMajor :: !Word8+ , versionMinor :: !Word8+ } deriving (Show)++builderRequestLine :: RequestLine -> Builder+builderRequestLine RequestLine{method,path,versionMajor,versionMinor} =+ Builder.copy (Utf8.fromText method)+ <>+ Builder.ascii ' '+ <>+ Builder.copy (Utf8.fromText path)+ <>+ Builder.ascii6 ' ' 'H' 'T' 'T' 'P' '/'+ <>+ Builder.word8Dec versionMajor+ <>+ Builder.ascii '.'+ <>+ Builder.word8Dec versionMinor+ <>+ Builder.ascii2 '\r' '\n'++toChunks :: Request -> Chunks+toChunks = Builder.run 256 . builder++toChunksOnto :: Request -> Chunks -> Chunks+toChunksOnto r ch = Builder.runOnto 256 (builder r) ch++builder :: Request -> Builder+builder Request{requestLine,headers} =+ builderRequestLine requestLine+ <>+ Header.builderSmallArray headersArray+ <>+ Builder.ascii2 '\r' '\n'+ where+ headersArray = Headers.toArray headers++-- | This adds the Content-Length header. It must not already+-- be present.+bodiedToChunks :: Bodied Request -> Chunks+bodiedToChunks Bodied{metadata=Request{requestLine,headers},body} =+ Builder.runOnto 256+ ( builderRequestLine requestLine+ <>+ Header.builderSmallArray headersArray+ <>+ Builder.cstring (Ptr "Content-Length: "#)+ <>+ Builder.word64Dec (fromIntegral (Chunks.length body))+ <>+ Builder.ascii4 '\r' '\n' '\r' '\n'+ ) body+ where+ headersArray = Headers.toArray headers
+ src/Http/Response.hs view
@@ -0,0 +1,78 @@+{-# language DuplicateRecordFields #-}+{-# language LambdaCase #-}++module Http.Response+ ( Response(..)+ , StatusLine(..)+ , decode+ ) where++import Control.Monad (when)+import Data.Bytes (Bytes)+import Data.Bytes.Parser (Parser)+import Data.Bytes.Types (Bytes(Bytes))+import Data.Primitive (SmallArray,ByteArray(ByteArray))+import Data.Word (Word8,Word16)+import Data.Text (Text)+import Http.Header (Header)+import Http.Headers (Headers)++import qualified Data.Text.Internal as Text+import qualified Data.Text.Array+import qualified Data.Bytes.Parser as Parser+import qualified Data.Bytes.Parser.Latin as Latin+import qualified Http.Header as Header+import qualified Http.Headers as Headers++-- | The response status line and the response headers.+data Response = Response+ { statusLine :: !StatusLine+ , headers :: !Headers+ } deriving (Show)++data StatusLine = StatusLine+ { versionMajor :: !Word8+ , versionMinor :: !Word8+ , statusCode :: !Word16+ , statusReason :: {-# UNPACK #-} !Text+ } deriving (Show)++-- | Decode the response status line and the response headers. Fails if+-- any extraneous input is present after the double CRLF sequence that+-- ends the headers.+decode :: Int -> Bytes -> Maybe Response+decode !n !b =+ Parser.parseBytesMaybe (parserResponse n <* Parser.endOfInput ()) b++parserResponse ::+ Int -- ^ Maximum number of headers+ -> Parser () s Response+parserResponse !n = do+ statusLine <- parserStatusLine+ headers0 <- Header.parserSmallArray n+ let !headers = Headers.fromArray headers0+ pure Response{statusLine,headers}++-- Consumes the trailing CRLF+parserStatusLine :: Parser () s StatusLine+parserStatusLine = do+ Latin.char5 () 'H' 'T' 'T' 'P' '/'+ versionMajor <- Latin.decWord8 ()+ Latin.char () '.'+ versionMinor <- Latin.decWord8 ()+ Latin.char () ' '+ statusCode <- Latin.decWord16 ()+ when (statusCode >= 1000) (Parser.fail ())+ Latin.char () ' '+ -- RFC 7230: reason-phrase = *( HTAB / SP / VCHAR / obs-text )+ statusReason <- Parser.takeWhile $ \c ->+ (c >= 0x20 && c <= 0x7e)+ ||+ (c == 0x09)+ Latin.char2 () '\r' '\n'+ pure StatusLine{versionMajor,versionMinor,statusCode,statusReason=unsafeBytesToText statusReason}++unsafeBytesToText :: Bytes -> Text+{-# inline unsafeBytesToText #-}+unsafeBytesToText (Bytes (ByteArray arr) off len) =+ Text.Text (Data.Text.Array.ByteArray arr) off len
+ src/Http/Types.hs view
@@ -0,0 +1,22 @@+{-# language DuplicateRecordFields #-}++module Http.Types+ ( -- * Request+ Request(..)+ , RequestLine(..)+ -- * Response+ , Response(..)+ , StatusLine(..)+ -- * Header+ , Headers+ , Header(..)+ , LookupException(..)+ -- * Bodied+ , Bodied(..)+ ) where++import Http.Request (Request(..),RequestLine(..))+import Http.Response (Response(..),StatusLine(..))+import Http.Header (Header(..))+import Http.Headers (Headers,LookupException(..))+import Http.Bodied (Bodied(..))
test/Main.hs view
@@ -6,25 +6,26 @@ ) where import Control.Monad (when)+import Data.Aeson (FromJSON)+import GHC.Generics (Generic)+import Http.Types (Request,Header) import System.FilePath (takeBaseName, replaceExtension) import Test.Tasty (defaultMain, TestTree, testGroup) import Test.Tasty.Golden (goldenVsString, findByExtension)+import Http.Headers (Headers) import Text.Show.Pretty (ppShow)-import Http.Header (Header)-import Data.Aeson (FromJSON)-import GHC.Generics (Generic)-import Http.Message.Request (Request) +import qualified Data.Aeson as Aeson import qualified Data.ByteString.Lazy.Char8 as LBC8-import qualified Data.Bytes.Chunks as Chunks import qualified Data.Bytes as Bytes+import qualified Data.Bytes.Chunks as Chunks+import qualified Data.List as List import qualified Data.Primitive as PM import qualified GHC.Exts as Exts-import qualified Http.Message.Request as Request-import qualified Http.Message.Response as Response import qualified Http.Header-import qualified Data.Aeson as Aeson-import qualified Data.List as List+import qualified Http.Headers as Headers+import qualified Http.Request as Request+import qualified Http.Response as Response main :: IO () main = defaultMain =<< goldenTests@@ -36,6 +37,9 @@ deriving anyclass instance FromJSON Header deriving anyclass instance FromJSON Request.RequestLine deriving anyclass instance FromJSON Request.Request++instance FromJSON Headers where+ parseJSON = fmap Headers.fromArray . Aeson.parseJSON goldenTests :: IO TestTree goldenTests = do