diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2bcda4f..3be0a83 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,44 +8,17 @@ on: - reopened - synchronize paths: - - '**.hs' - - '**.cabal' - - 'stack.yaml' - - '.github/workflows/ci.yml' + - "**.hs" + - "**.cabal" + - "stack.yaml" + - ".github/workflows/ci.yml" push: branches: [master] jobs: - # Just to test very old compiler for low base version dependency - oldest: - name: oldest / ghc-7.10.3 / ubuntu - runs-on: ubuntu-latest - steps: - - - uses: actions/checkout@v4 - - - uses: haskell-actions/setup@v2 - id: setup-haskell-cabal - name: Setup Haskell - with: - ghc-version: "7.10.3" - cabal-version: 3.0.0.0 - - - uses: actions/cache@v3 - name: Cache cabal-store - with: - path: ${{ steps.setup-haskell-cabal.outputs.cabal-store }} - key: ubuntu-7.10.3-cabal - - - name: Build - run: | - cabal build --ghc-option='-Wall' \ - --constraint="bytestring==0.10.6.0" \ - --constraint="array==0.5.1.0" \ - --constraint="case-insensitive==1.2.0.2" \ - --constraint="text==1.2.0.2" - cabal: + env: + MANUAL_RESET: v5 name: cabal / ghc-${{matrix.ghc}} / ${{ matrix.os }} continue-on-error: ${{ matrix.ghc == '9.14.1'}} runs-on: ${{ matrix.os }} @@ -53,7 +26,7 @@ jobs: matrix: os: - ubuntu-latest - - macOS-latest + # - macOS-latest # Is giving issues for some reason cabal: ["latest"] ghc: - "9.6.7" @@ -63,31 +36,44 @@ jobs: - "9.14.1" steps: - - uses: actions/checkout@v4 - - - uses: haskell-actions/setup@v2 - id: setup-haskell-cabal - name: Setup Haskell - with: - ghc-version: ${{ matrix.ghc }} - cabal-version: ${{ matrix.cabal }} - cabal-update: true - - - uses: actions/cache@v3 - name: Cache cabal-store - with: - path: ${{ steps.setup-haskell-cabal.outputs.cabal-store }} - key: ${{ runner.os }}-${{ matrix.ghc }}-cabal - - - name: Build - run: | - cabal build all --enable-tests --enable-benchmarks --write-ghc-environment-files=always --ghc-option='-Wall' - - name: Test - run: | - cabal test test:spec --enable-tests --ghc-option='-Wall' - - name: Test Docs - run: | - cabal exec -- cabal test test:doctests --enable-tests --ghc-option='-Wall' + - uses: actions/checkout@v4 + + - name: Cache GHCup + uses: actions/cache@v6 + id: ghcup-cache + with: + path: | + ~/.ghcup/bin/* + ~/.ghcup/cache/* + ~/.ghcup/config.yaml + ~/.ghcup/ghc/${{ matrix.ghc }} + ~/.ghcup/cabal/**/cabal + key: CI-ghcup-cabal-${{ env.MANUAL_RESET }}-${{ matrix.ghc }} + + - name: Setup Haskell + if: steps.ghcup-cache.outputs.cache-hit != 'true' + uses: haskell-actions/setup@v2 + id: setup-haskell-cabal + with: + ghc-version: ${{ matrix.ghc }} + cabal-version: ${{ matrix.cabal }} + cabal-update: true + + - name: Cache cabal + uses: actions/cache@v6 + with: + path: ~/.cabal + key: ${{ matrix.ghc }}-cabal + + - name: Build + run: | + cabal build all --enable-tests --enable-benchmarks --write-ghc-environment-files=always --ghc-option='-Wall' + - name: Test + run: | + cabal test test:spec --enable-tests --ghc-option='-Wall' + - name: Test Docs + run: | + cabal exec -- cabal test test:doctests --enable-tests --ghc-option='-Wall' # # We probably want to add benchmarks at some point, just to make sure # # functions don't regress in performance too much? # - name: Bench @@ -95,6 +81,8 @@ jobs: # cabal bench --enable-benchmarks stack: name: stack ${{ matrix.resolver }} + env: + MANUAL_RESET: v3 runs-on: ubuntu-latest # This makes the CI jobs not all be cancelled if nightly fails to build. # However, if nightly fails to build, CI still gets a red X in the GitHub UI. @@ -104,40 +92,85 @@ jobs: # When some sort of `allow-failure` functionality is available in GitHub # actions, we should switch to it: # https://github.com/actions/toolkit/issues/399 - continue-on-error: ${{ matrix.resolver == '--resolver nightly' }} + continue-on-error: ${{ matrix.resolver == 'nightly' || matrix.resolver == 'lts-12' }} strategy: matrix: - stack: ["latest"] - resolver: - - "--resolver lts-22" # GHC 9.6.7 - - "--resolver lts-23" # GHC 9.8.4 - - "--resolver lts-24" # GHC 9.10.3 - - "--resolver nightly" # GHC 9.12.4 + include: + - resolver: "lts-12" + ghc: "8.4.4" + - resolver: "lts-22" + ghc: "9.6.7" + - resolver: "lts-23" + ghc: "9.8.4" + - resolver: "lts-24" + ghc: "9.10.3" + - resolver: "nightly" + ghc: "9.12.4" steps: - - uses: actions/checkout@v4 - - - uses: haskell-actions/setup@v2 - name: Setup Haskell Stack - with: - stack-version: ${{ matrix.stack }} - enable-stack: true - - - uses: actions/cache@v3 - name: Cache ~/.stack - with: - path: ${{ steps.setup-haskell-cabal.outputs.stack-root }} - key: ${{ runner.os }}-${{ matrix.resolver }}-stack - - - name: Build - run: | - stack build ${{ matrix.resolver }} --test --bench --no-run-tests --no-run-benchmarks --ghc-options='-Wall' - - name: Test - run: | - stack test --no-rerun-tests http-types:test:spec ${{ matrix.resolver }} - - name: Test Docs - run: | - stack test http-types:test:doctests ${{ matrix.resolver }} + - uses: actions/checkout@v4 + + - name: Cache GHCup + uses: actions/cache@v6 + id: ghcup-cache + with: + path: | + ~/.ghcup/bin/* + ~/.ghcup/cache/* + ~/.ghcup/config.yaml + ~/.ghcup/ghc/${{ matrix.ghc }} + ~/.ghcup/stack/* + key: CI-ghcup-stack-${{ env.MANUAL_RESET }}-${{ matrix.ghc }} + + - name: Setup Haskell Stack + uses: haskell-actions/setup@v2 + if: steps.ghcup-cache.outputs.cache-hit != 'true' + with: + stack-version: "latest" + ghc-version: ${{ matrix.ghc }} + enable-stack: true + stack-setup-ghc: false + + - name: Cache Pantry (Stackage package index) + id: pantry + uses: actions/cache@v6 + with: + path: ~/.stack/pantry + key: CI-pantry-${{ env.MANUAL_RESET }}-${{ matrix.resolver }} + + - name: Recompute Stackage package index + if: steps.pantry.outputs.cache-hit != 'true' + run: stack update # populates ~/.stack/pantry + + - name: Cache Dependencies + uses: actions/cache@v6 + with: + path: | + ~/.stack/stack.sqlite3 + ~/.stack/snapshots + key: ${{ runner.os }}-${{ matrix.resolver }}-stack + + - name: Build lts-12 + if: "${{ matrix.resolver == 'lts-12' }}" + run: | + stack config set system-ghc true --global + stack build --resolver=${{ matrix.resolver }} --ghc-options='-Wall' + + - name: Build + if: "${{ matrix.resolver != 'lts-12' }}" + run: | + stack config set system-ghc true --global + stack build --resolver=${{ matrix.resolver }} --test --bench \ + --no-run-tests --no-run-benchmarks --ghc-options='-Wall' + - name: Test + if: "${{ matrix.resolver != 'lts-12' }}" + run: | + stack test --no-rerun-tests http-types:test:spec --resolver=${{ matrix.resolver }} + - name: Test Docs + if: "${{ matrix.resolver != 'lts-12' }}" + run: | + stack test http-types:test:doctests --resolver=${{ matrix.resolver }} + # # We probably want to add benchmarks at some point, just to make sure # # functions don't regress in performance too much? # - name: Bench diff --git a/CHANGELOG.md b/CHANGELOG.md index 1227657..5742643 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,33 @@ * Add support for the QUERY method and Accept-Query response field name from [RFC 10008](https://www.rfc-editor.org/rfc/rfc10008.html). +## 0.12.7 [unreleased] + +* Add parsing and rendering functions for `Status` +* Add pattern synonyms for `HttpVersion` + * `Http09` + * `Http10` + * `Http11` + * `Http20` + * `Http30` +* Add pattern synonyms for `Status` as `StatusCode` to only match the 3 digit code +* Add more constant headers: + * `hAcceptPatch` + * `hAccessControlAllowCredentials` + * `hAccessControlAllowHeaders` + * `hAccessControlAllowMethods` + * `hAccessControlAllowOrigin` + * `hAccessControlExposeHeaders` + * `hAccessControlMaxAge` + * `hAccessControlRequestMethod` + * `hAltSvc` + * `hContentDigest` + * `hContentSecurityPolicy` + * `hContentSecurityPolicyReportOnly` + * `hForwarded` + * `hLink` + * `hStrictTransportSecurity` + ## 0.12.6 [2026-08-13] * Remove `array` dependency diff --git a/Network/HTTP/Types.hs b/Network/HTTP/Types.hs index 1c48629..040910e 100644 --- a/Network/HTTP/Types.hs +++ b/Network/HTTP/Types.hs @@ -2,7 +2,6 @@ module Network.HTTP.Types ( -- * Methods -- | __For more information__: "Network.HTTP.Types.Method" - Method, -- ** Constants @@ -26,7 +25,6 @@ module Network.HTTP.Types ( -- * Versions -- | __For more information__: "Network.HTTP.Types.Version" - HttpVersion (..), http09, http10, @@ -37,11 +35,20 @@ module Network.HTTP.Types ( -- * Status -- | __For more information__: "Network.HTTP.Types.Status" - Status (..), + mkStatus, + + -- ** Parsing and Rendering + parseStatusCode, + renderStatusCode, + parseFullStatus, + renderFullStatus, + + -- ** Low level functions + renderStatusCodeToPtr, + renderFullStatusToPtr, -- ** Constants - mkStatus, status100, continue100, status101, @@ -140,6 +147,8 @@ module Network.HTTP.Types ( httpVersionNotSupported505, status511, networkAuthenticationRequired511, + + -- *** Category checks statusIsInformational, statusIsSuccessful, statusIsRedirection, @@ -156,18 +165,28 @@ module Network.HTTP.Types ( RequestHeaders, ResponseHeaders, - -- ** Header constants + -- ** Constants hAccept, hAcceptCharset, hAcceptEncoding, hAcceptLanguage, + hAcceptPatch, hAcceptQuery, hAcceptRanges, + hAccessControlAllowCredentials, + hAccessControlAllowHeaders, + hAccessControlAllowMethods, + hAccessControlAllowOrigin, + hAccessControlExposeHeaders, + hAccessControlMaxAge, + hAccessControlRequestMethod, hAge, hAllow, + hAltSvc, hAuthorization, hCacheControl, hConnection, + hContentDigest, hContentDisposition, hContentEncoding, hContentLanguage, @@ -175,12 +194,15 @@ module Network.HTTP.Types ( hContentLocation, hContentMD5, hContentRange, + hContentSecurityPolicy, + hContentSecurityPolicyReportOnly, hContentType, hCookie, hDate, hETag, hExpect, hExpires, + hForwarded, hFrom, hHost, hIfMatch, @@ -189,6 +211,7 @@ module Network.HTTP.Types ( hIfRange, hIfUnmodifiedSince, hLastModified, + hLink, hLocation, hMaxForwards, hMIMEVersion, @@ -203,6 +226,7 @@ module Network.HTTP.Types ( hRetryAfter, hServer, hSetCookie, + hStrictTransportSecurity, hTE, hTrailer, hTransferEncoding, diff --git a/Network/HTTP/Types/Header.hs b/Network/HTTP/Types/Header.hs index a7e95fa..0688923 100644 --- a/Network/HTTP/Types/Header.hs +++ b/Network/HTTP/Types/Header.hs @@ -23,13 +23,23 @@ module Network.HTTP.Types.Header ( hAcceptCharset, hAcceptEncoding, hAcceptLanguage, + hAcceptPatch, hAcceptQuery, hAcceptRanges, + hAccessControlAllowCredentials, + hAccessControlAllowHeaders, + hAccessControlAllowMethods, + hAccessControlAllowOrigin, + hAccessControlExposeHeaders, + hAccessControlMaxAge, + hAccessControlRequestMethod, hAge, hAllow, + hAltSvc, hAuthorization, hCacheControl, hConnection, + hContentDigest, hContentDisposition, hContentEncoding, hContentLanguage, @@ -37,12 +47,15 @@ module Network.HTTP.Types.Header ( hContentLocation, hContentMD5, hContentRange, + hContentSecurityPolicy, + hContentSecurityPolicyReportOnly, hContentType, hCookie, hDate, hETag, hExpect, hExpires, + hForwarded, hFrom, hHost, hIfMatch, @@ -51,6 +64,7 @@ module Network.HTTP.Types.Header ( hIfRange, hIfUnmodifiedSince, hLastModified, + hLink, hLocation, hMaxForwards, hMIMEVersion, @@ -65,6 +79,7 @@ module Network.HTTP.Types.Header ( hRetryAfter, hServer, hSetCookie, + hStrictTransportSecurity, hTE, hTrailer, hTransferEncoding, @@ -90,13 +105,21 @@ module Network.HTTP.Types.Header ( ) where +import Control.Monad (guard) import qualified Data.ByteString as B -import qualified Data.ByteString.Builder as B +import qualified Data.ByteString.Builder as B ( + Builder, + byteString, + integerDec, + toLazyByteString, + word8, + ) import qualified Data.ByteString.Char8 as B8 import qualified Data.ByteString.Lazy as BL import qualified Data.CaseInsensitive as CI import Data.Data (Data) import Data.List (intersperse) +import Data.Word (Word8) import GHC.Generics (Generic) -- | A full HTTP header field with the name and value separated. @@ -137,6 +160,48 @@ hAcceptCharset = "Accept-Charset" hAcceptEncoding :: HeaderName hAcceptEncoding = "Accept-Encoding" +-- | [Access-Control-Allow-Credentials](https://www.w3.org/TR/2014/REC-cors-20140116/#access-control-allow-origin-response-header) +-- +-- @since 0.12.7 +hAccessControlAllowCredentials :: HeaderName +hAccessControlAllowCredentials = "Access-Control-Allow-Credentials" + +-- | [Access-Control-Allow-Headers](https://www.w3.org/TR/2014/REC-cors-20140116/#access-control-allow-headers-response-header) +-- +-- @since 0.12.7 +hAccessControlAllowHeaders :: HeaderName +hAccessControlAllowHeaders = "Access-Control-Allow-Headers" + +-- | [Access-Control-Allow-Methods](https://www.w3.org/TR/2014/REC-cors-20140116/#access-control-allow-methods-response-header) +-- +-- @since 0.12.7 +hAccessControlAllowMethods :: HeaderName +hAccessControlAllowMethods = "Access-Control-Allow-Methods" + +-- | [Access-Control-Allow-Origin](https://www.w3.org/TR/2014/REC-cors-20140116/#access-control-allow-origin-response-header) +-- +-- @since 0.12.7 +hAccessControlAllowOrigin :: HeaderName +hAccessControlAllowOrigin = "Access-Control-Allow-Origin" + +-- | [Access-Control-Expose-Headers](https://www.w3.org/TR/2014/REC-cors-20140116/#access-control-expose-headers-response-header) +-- +-- @since 0.12.7 +hAccessControlExposeHeaders :: HeaderName +hAccessControlExposeHeaders = "Access-Control-Expose-Headers" + +-- | [Access-Control-Max-Age](https://www.w3.org/TR/2014/REC-cors-20140116/#access-control-max-age-response-header) +-- +-- @since 0.12.7 +hAccessControlMaxAge :: HeaderName +hAccessControlMaxAge = "Access-Control-Max-Age" + +-- | [Access-Control-Request-Method](https://www.w3.org/TR/2014/REC-cors-20140116/#access-control-request-method-request-header) +-- +-- @since 0.12.7 +hAccessControlRequestMethod :: HeaderName +hAccessControlRequestMethod = "Access-Control-Request-Method" + -- | [Accept-Language](https://www.rfc-editor.org/rfc/rfc9110.html#name-accept-language) -- -- @since 0.7.0 @@ -413,17 +478,41 @@ hWWWAuthenticate = "WWW-Authenticate" hWarning :: HeaderName hWarning = "Warning" +-- | [Accept-Patch](https://www.rfc-editor.org/rfc/rfc5789.html#section-3.1) +-- +-- @since 0.12.7 +hAcceptPatch :: HeaderName +hAcceptPatch = "Accept-Patch" + +-- | [Alt-Svc](https://www.rfc-editor.org/rfc/rfc7838.html#section-3) +-- +-- @since 0.12.7 +hAltSvc :: HeaderName +hAltSvc = "Alt-Svc" + -- | [Content-Disposition](https://www.rfc-editor.org/rfc/rfc6266.html) -- -- @since 0.10 hContentDisposition :: HeaderName hContentDisposition = "Content-Disposition" --- | [MIME-Version](https://www.rfc-editor.org/rfc/rfc2616.html#section-19.4.1) +-- | [Content-Digest](https://www.rfc-editor.org/rfc/rfc9530.html#section-2) -- --- @since 0.10 -hMIMEVersion :: HeaderName -hMIMEVersion = "MIME-Version" +-- @since 0.12.7 +hContentDigest :: HeaderName +hContentDigest = "Content-Digest" + +-- | [Content-Security-Policy](https://www.w3.org/TR/CSP3/#csp-header) +-- +-- @since 0.12.7 +hContentSecurityPolicy :: HeaderName +hContentSecurityPolicy = "Content-Security-Policy" + +-- | [Content-Security-Policy-Report-Only](https://www.w3.org/TR/CSP3/#cspro-header) +-- +-- @since 0.12.7 +hContentSecurityPolicyReportOnly :: HeaderName +hContentSecurityPolicyReportOnly = "Content-Security-Policy-Report-Only" -- | [Cookie](https://www.rfc-editor.org/rfc/rfc6265.html#section-4.2) -- @@ -431,11 +520,23 @@ hMIMEVersion = "MIME-Version" hCookie :: HeaderName hCookie = "Cookie" --- | [Set-Cookie](https://www.rfc-editor.org/rfc/rfc6265.html#section-4.1) +-- | [Forwarded](https://www.rfc-editor.org/rfc/rfc7239.html) +-- +-- @since 0.12.7 +hForwarded :: HeaderName +hForwarded = "Forwarded" + +-- | [Link](https://www.rfc-editor.org/rfc/rfc8288.html#section-3) +-- +-- @since 0.12.7 +hLink :: HeaderName +hLink = "Link" + +-- | [MIME-Version](https://www.rfc-editor.org/rfc/rfc2616.html#section-19.4.1) -- -- @since 0.10 -hSetCookie :: HeaderName -hSetCookie = "Set-Cookie" +hMIMEVersion :: HeaderName +hMIMEVersion = "MIME-Version" -- | [Origin](https://www.rfc-editor.org/rfc/rfc6454.html#section-7) -- @@ -455,6 +556,18 @@ hPrefer = "Prefer" hPreferenceApplied :: HeaderName hPreferenceApplied = "Preference-Applied" +-- | [Set-Cookie](https://www.rfc-editor.org/rfc/rfc6265.html#section-4.1) +-- +-- @since 0.10 +hSetCookie :: HeaderName +hSetCookie = "Set-Cookie" + +-- | [Strict-Transport-Security](https://www.rfc-editor.org/rfc/rfc6797.html#section-6.1) +-- +-- @since 0.12.7 +hStrictTransportSecurity :: HeaderName +hStrictTransportSecurity = "Strict-Transport-Security" + -- | An individual byte range. Used in @Range@ request headers. -- This type and its accompanying functions are /NOT/ compatible with the -- @Content-Range@ response header. @@ -483,9 +596,9 @@ data ByteRange -- -- @since 0.6.11 renderByteRangeBuilder :: ByteRange -> B.Builder -renderByteRangeBuilder (ByteRangeFrom from) = B.integerDec from `mappend` B.char7 '-' -renderByteRangeBuilder (ByteRangeFromTo from to) = B.integerDec from `mappend` B.char7 '-' `mappend` B.integerDec to -renderByteRangeBuilder (ByteRangeSuffix suffix) = B.char7 '-' `mappend` B.integerDec suffix +renderByteRangeBuilder (ByteRangeFrom from) = B.integerDec from `mappend` B.word8 _hyphen +renderByteRangeBuilder (ByteRangeFromTo from to) = B.integerDec from `mappend` B.word8 _hyphen `mappend` B.integerDec to +renderByteRangeBuilder (ByteRangeSuffix suffix) = B.word8 _hyphen `mappend` B.integerDec suffix -- | Renders a byte range into a 'B.ByteString'. -- @@ -507,7 +620,7 @@ type ByteRanges = [ByteRange] renderByteRangesBuilder :: ByteRanges -> B.Builder renderByteRangesBuilder xs = B.byteString "bytes=" - `mappend` mconcat (intersperse (B.char7 ',') $ map renderByteRangeBuilder xs) + `mappend` mconcat (intersperse (B.word8 _comma) $ map renderByteRangeBuilder xs) -- | Renders a list of byte ranges into a 'B.ByteString'. -- @@ -540,33 +653,35 @@ renderByteRanges = BL.toStrict . B.toLazyByteString . renderByteRangesBuilder -- @since 0.9.1 parseByteRanges :: B.ByteString -> Maybe ByteRanges parseByteRanges bs1 = do - bs2 <- stripPrefixB "bytes=" bs1 - (r, bs3) <- range bs2 - ranges (r :) bs3 + (r, bs2) <- stripBytes >>= range + ranges (r :) bs2 where + stripBytes = do + let prefix = "bytes=" + prefixLen = B.length prefix + (pre, post) = B.splitAt prefixLen bs1 + guard $ pre == prefix + Just post range bs2 = do (i, bs3) <- B8.readInteger bs2 if i < 0 -- has prefix "-" ("-0" is not valid, but here treated as "0-") then Just (ByteRangeSuffix (negate i), bs3) else do - bs4 <- stripPrefixB "-" bs3 + bs4 <- pop _hyphen bs3 case B8.readInteger bs4 of Just (j, bs5) | j >= i -> Just (ByteRangeFromTo i j, bs5) _ -> Just (ByteRangeFrom i, bs4) ranges front bs3 | B.null bs3 = Just (front []) | otherwise = do - bs4 <- stripPrefixB "," bs3 + bs4 <- pop _comma bs3 (r, bs5) <- range bs4 ranges (front . (r :)) bs5 - -stripPrefixB :: B.ByteString -> B.ByteString -> Maybe B.ByteString -#if !MIN_VERSION_bytestring(0,10,8) --- FIXME: Use 'stripPrefix' from the 'bytestring' package. --- Might have to update the dependency constraints though. -stripPrefixB x y - | x `B.isPrefixOf` y = Just (B.drop (B.length x) y) - | otherwise = Nothing -#else -stripPrefixB = B.stripPrefix -#endif + pop w8 bs = do + (b, rest) <- B.uncons bs + guard $ b == w8 + Just rest + +_comma, _hyphen :: Word8 +_comma = 0x2C +_hyphen = 0x2D diff --git a/Network/HTTP/Types/QueryLike.hs b/Network/HTTP/Types/QueryLike.hs index 1c24843..fc46ca5 100644 --- a/Network/HTTP/Types/QueryLike.hs +++ b/Network/HTTP/Types/QueryLike.hs @@ -9,8 +9,8 @@ module Network.HTTP.Types.QueryLike ( where import Control.Arrow ((***)) -import Data.ByteString as B (ByteString, concat) -import Data.ByteString.Lazy as L (ByteString, toChunks) +import Data.ByteString as B (ByteString) +import Data.ByteString.Lazy as L (ByteString, toStrict) import Data.Maybe (catMaybes) import Data.Text as T (Text, pack) import Data.Text.Encoding as T (encodeUtf8) @@ -48,13 +48,13 @@ instance (QueryKeyLike k, QueryValueLike v) => QueryLike [Maybe (k, v)] where toQuery = toQuery . catMaybes instance QueryKeyLike B.ByteString where toQueryKey = id -instance QueryKeyLike L.ByteString where toQueryKey = B.concat . L.toChunks +instance QueryKeyLike L.ByteString where toQueryKey = L.toStrict instance QueryKeyLike T.Text where toQueryKey = T.encodeUtf8 instance QueryKeyLike [Char] where toQueryKey = T.encodeUtf8 . T.pack instance QueryValueLike B.ByteString where toQueryValue = Just -instance QueryValueLike L.ByteString where toQueryValue = Just . B.concat . L.toChunks +instance QueryValueLike L.ByteString where toQueryValue = Just . L.toStrict instance QueryValueLike T.Text where toQueryValue = Just . T.encodeUtf8 instance QueryValueLike [Char] where toQueryValue = Just . T.encodeUtf8 . T.pack -instance QueryValueLike a => QueryValueLike (Maybe a) where +instance (QueryValueLike a) => QueryValueLike (Maybe a) where toQueryValue mVal = mVal >>= toQueryValue diff --git a/Network/HTTP/Types/Status.hs b/Network/HTTP/Types/Status.hs index a939a15..aec5122 100644 --- a/Network/HTTP/Types/Status.hs +++ b/Network/HTTP/Types/Status.hs @@ -1,23 +1,36 @@ +{-# LANGUAGE CPP #-} {-# LANGUAGE DeriveDataTypeable #-} {-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE PatternSynonyms #-} -- | Types and constants to describe HTTP status codes. -- --- At the bottom are some functions to check if a given 'Status' is from a certain category. (i.e. @1XX@, @2XX@, etc.) +-- At the bottom are some functions to check if a given t'Status' is from a certain category. (i.e. @1XX@, @2XX@, etc.) module Network.HTTP.Types.Status ( -- * HTTP Status - - -- If we ever want to deprecate the 'Status' data constructor: - -- #if __GLASGOW_HASKELL__ >= 908 - -- {-# DEPRECATED "Use 'mkStatus' when constructing a 'Status'" #-} Status(Status) - -- #else - Status (Status), - -- #endif +#if __GLASGOW_HASKELL__ >= 800 + Status (Status, StatusCode), +#else + Status(Status), + pattern StatusCode, +#endif statusCode, statusMessage, mkStatus, + -- ** Parsing and Rendering + + -- | These functions are quicker and more efficient than doing it yourself. + parseStatusCode, + renderStatusCode, + parseFullStatus, + renderFullStatus, + + -- ** Low level functions + renderStatusCodeToPtr, + renderFullStatusToPtr, + -- * Common statuses status100, continue100, @@ -126,8 +139,23 @@ module Network.HTTP.Types.Status ( statusIsServerError, ) where -import Data.ByteString as B (ByteString, empty) +import Control.Monad (guard, when) +import Data.Bits ((.&.), (.|.)) +import Data.ByteString as B (ByteString, drop, empty, length, uncons) +import qualified Data.ByteString.Char8 as B8 +import Data.ByteString.Internal as B ( + ByteString (..), + accursedUnutterablePerformIO, + unsafeCreate, + ) import Data.Data (Data) +import Data.Function (on) +import Foreign (Ptr, Word8, copyBytes, peek, plusPtr, poke) +#if !MIN_VERSION_base(4,15,0) +import Foreign.ForeignPtr (ForeignPtr, withForeignPtr) +#else +import GHC.ForeignPtr (unsafeWithForeignPtr) +#endif import GHC.Generics (Generic) -- | HTTP Status. @@ -140,11 +168,11 @@ import GHC.Generics (Generic) -- Note that the 'Show' instance is only for debugging. data Status = Status { statusCode :: Int - -- ^ The 3-digit code of a 'Status' + -- ^ The 3-digit code of a t'Status' -- -- For example: "200" in a @200 OK@ status , statusMessage :: B.ByteString - -- ^ The textual message of a 'Status' + -- ^ The textual message of a t'Status' -- -- For example: "Not Found" in a @404 Not Found@ status } @@ -156,26 +184,36 @@ data Status = Status Generic ) --- FIXME: If the data constructor of 'Status' is ever deprecated, we should define --- a pattern synonym to minimize any breakage. This also involves changing the --- name of the constructor, so that it doesn't clash with the new pattern synonym --- that's replacing it. --- --- > data Status = MkStatus ... --- > pattern Status code msg = MkStatus code msg - --- | A 'Status' is equal to another 'Status' if the status codes are equal. +-- | If you only need to check whether the `statusCode` is a specific number, +-- this pattern can make matching on it a bit easier: +-- +-- @ +-- handleResponse :: Status -> IO () +-- handleResponse (StatusCode st) = +-- case st of +-- 200 -> handleOK +-- 401 -> reportAuthFailure +-- 500 -> retry +-- xxx -> abort xxx +-- @ +-- +-- @since 0.12.7 +pattern StatusCode :: Int -> Status +pattern StatusCode code <- Status code _ +{-# COMPLETE StatusCode #-} + +-- | A t'Status' is equal to another t'Status' if the status codes are equal. instance Eq Status where - Status{statusCode = a} == Status{statusCode = b} = a == b + (==) = (==) `on` statusCode --- | 'Status'es are ordered according to their status codes only. +-- | t'Status'es are ordered according to their status codes only. instance Ord Status where - compare Status{statusCode = a} Status{statusCode = b} = a `compare` b + compare = compare `on` statusCode --- | Be advised, that when using the \"enumFrom*\" family of methods or +-- | Be advised, that when using the @enumFrom*@ family of methods or -- ranges in lists, it will generate all possible status codes. -- --- E.g. @[status100 .. status200]@ generates 'Status'es of @100, 101, 102 .. 198, 199, 200@ +-- E.g. @[status100 .. status200]@ generates t'Status'es of @100, 101, 102 .. 198, 199, 200@ -- -- The statuses not included in this library will have an empty message. -- @@ -238,7 +276,7 @@ instance Bounded Status where minBound = status100 maxBound = status511 --- | Create a 'Status' from a status code and message. +-- | Create a t'Status' from a status code and message. -- -- @since 0.7.3 mkStatus :: Int -> B.ByteString -> Status @@ -840,3 +878,135 @@ statusIsClientError (Status{statusCode = code}) = code >= 400 && code < 500 -- @since 0.8.0 statusIsServerError :: Status -> Bool statusIsServerError (Status{statusCode = code}) = code >= 500 && code < 600 + +-- | Write the 3 digit t'Status' code to the provided 'Ptr'. +-- +-- /N.B. This function assumes @statusCode < 1000@!/ +-- /If it is @>= 1000@, the first byte will not be a digit./ +-- +-- @since 0.12.7 +renderStatusCodeToPtr :: Status -> Ptr Word8 -> IO () +renderStatusCodeToPtr (Status code _) ptr = do + poke ptr $ toByte h + poke (ptr `plusPtr` 1) $ toByte t + poke (ptr `plusPtr` 2) $ toByte i + where + (h, rest) = code `divMod` 100 + (t, i) = rest `divMod` 10 + toByte :: Int -> Word8 + toByte x = fromIntegral x .|. 0x30 +{-# INLINABLE renderStatusCodeToPtr #-} + +-- | Render the 3 digit t'Status' code into a 'ByteString'. +-- +-- @since 0.12.7 +renderStatusCode :: Status -> ByteString +renderStatusCode s@(Status code _) + | code >= 1000 = B8.pack $ show s + | otherwise = + unsafeCreate 3 $ renderStatusCodeToPtr s + +-- | Writes the full t'Status' code and message to the provided 'Ptr'. +-- +-- /N.B. Same caveat from 'renderStatusCodeToPtr' applies./ +-- +-- @since 0.12.7 +renderFullStatusToPtr :: Status -> Ptr Word8 -> IO () +renderFullStatusToPtr s@(Status _ (PS fptr offset len)) ptr = do + renderStatusCodeToPtr s ptr + poke (ptr `plusPtr` 3) (0x20 :: Word8) -- space + when (len > 0) $ + unsafeWithForeignPtr fptr $ \src -> + copyBytes (ptr `plusPtr` 4) (src `plusPtr` offset) len +{-# INLINABLE renderFullStatusToPtr #-} + +-- | Render the full t'Status' code with status message into a 'ByteString'. +-- +-- @since 0.12.7 +renderFullStatus :: Status -> ByteString +renderFullStatus s@(Status code msg) + | code >= 1000 = + B8.pack (show code) `mappend` " " `mappend` msg + | otherwise = + unsafeCreate (4 + len) $ renderFullStatusToPtr s + where + len = B.length msg + +-- | Parses the first three characters as digits and converts them to an 'Int'. +-- +-- If the first 3 characters are not digits (i.e. @0-9@), or the 'ByteString' +-- is less than 3 bytes long, the result will be 'Nothing'. +-- +-- When successful, it will return the parsed status code and the remainder of +-- the 'ByteString'. +-- +-- >>> parseStatusCode "307" +-- Just (307,"") +-- +-- >>> parseStatusCode "404 Not Found" +-- Just (404," Not Found") +-- +-- >>> parseStatusCode "No Digits" +-- Nothing +-- +-- >>> parseStatusCode "12 Is Not Enough Digits" +-- Nothing +parseStatusCode :: ByteString -> Maybe (Int, ByteString) +parseStatusCode bs@(PS fptr offset len) + | len < 3 = Nothing + | otherwise = + accursedUnutterablePerformIO $ + unsafeWithForeignPtr fptr $ \ptr' -> do + let ptr = ptr' `plusPtr` offset + w1 <- peek ptr + w2 <- peek (ptr `plusPtr` 1) + w3 <- peek (ptr `plusPtr` 2) + pure $ do + h <- toNumber w1 + t <- toNumber w2 + i <- toNumber w3 + Just (h * 100 + t * 10 + i, B.drop 3 bs) + where + toNumber :: Word8 -> Maybe Int + toNumber w = do + guard $ 0x30 <= w && w <= 0x39 + Just . fromIntegral $ w .&. 0x0F + +-- | Assumes the provided 'ByteString' is either: +-- +-- * only 3 digits, or +-- * 3 digits, a space, and the rest of the status message +-- +-- /N.B. this function does not check for newlines, it puts everything/ +-- /after the code and space into the 'statusMessage'./ +-- +-- >>> parseFullStatus "307" +-- Just (Status {statusCode = 307, statusMessage = ""}) +-- +-- >>> parseFullStatus "404 Not Found" +-- Just (Status {statusCode = 404, statusMessage = "Not Found"}) +-- +-- >>> parseFullStatus "500 Someone Forgot To\r\nBreak At The Newline" +-- Just (Status {statusCode = 500, statusMessage = "Someone Forgot To\r\nBreak At The Newline"}) +-- +-- >>> parseFullStatus "1337 Is A Bad Status Code" +-- Nothing +-- +-- >>> parseFullStatus "101Still Needs A Space" +-- Nothing +-- +-- >>> parseFullStatus "No Digits" +-- Nothing +parseFullStatus :: ByteString -> Maybe Status +parseFullStatus bs = do + (code, rest) <- parseStatusCode bs + case B.uncons rest of + Nothing -> Just $ mkStatus code "" + Just (w, ws) + | w == 0x20 -> Just $ mkStatus code ws + | otherwise -> Nothing + +#if !MIN_VERSION_base(4,15,0) +unsafeWithForeignPtr :: ForeignPtr a -> (Ptr a -> IO b) -> IO b +unsafeWithForeignPtr = withForeignPtr +#endif diff --git a/Network/HTTP/Types/URI.hs b/Network/HTTP/Types/URI.hs index 8d34f18..f920f19 100644 --- a/Network/HTTP/Types/URI.hs +++ b/Network/HTTP/Types/URI.hs @@ -84,7 +84,7 @@ module Network.HTTP.Types.URI ( where import Control.Arrow (second, (***)) -import Data.Bits (shiftL, (.|.)) +import Data.Bits (shiftL, shiftR, (.&.), (.|.)) import qualified Data.ByteString as B import qualified Data.ByteString.Builder as B import qualified Data.ByteString.Lazy as BL @@ -114,6 +114,9 @@ import Data.Word (Word8) type QueryItem = (B.ByteString, Maybe B.ByteString) -- | A sequence of 'QueryItem's. +-- +-- General form: @a=b&c=d@, but if for example the value of @a@ is 'Nothing' +-- instead of @'Just' "b"@, it becomes @a&c=d@. type Query = [QueryItem] -- | Like Query, but with 'Text' instead of 'B.ByteString' (UTF8-encoded). @@ -174,21 +177,17 @@ simpleQueryToQuery = map (second Just) renderQueryBuilder :: Bool -> Query -> B.Builder renderQueryBuilder _ [] = mempty renderQueryBuilder qmark' (p : ps) = - -- FIXME: replace mconcat + map with foldr - mconcat $ - go (if qmark' then qmark else mempty) p - : map (go amp) ps + mconcat $ go qmark p : map (go amp) ps where - qmark = B.byteString "?" - amp = B.byteString "&" - equal = B.byteString "=" + qmark = if qmark' then B.word8 _question else mempty + amp = B.word8 _ampersand go sep (k, mv) = mconcat [ sep , urlEncodeBuilder True k , case mv of Nothing -> mempty - Just v -> equal `mappend` urlEncodeBuilder True v + Just v -> B.word8 _equal `mappend` urlEncodeBuilder True v ] -- | Renders the given 'Query' into a 'B.ByteString'. @@ -234,30 +233,29 @@ parseQueryReplacePlus replacePlus bs = parseQueryString' $ dropQuestion bs where dropQuestion q = case B.uncons q of - Just (63, q') -> q' + -- 0x3F == _question + Just (0x3F, q') -> q' _ -> q parseQueryString' q | B.null q = [] parseQueryString' q = let (x, xs) = breakDiscard queryStringSeparators q in parsePair x : parseQueryString' xs where + queryStringSeparators :: B.ByteString + queryStringSeparators = "&;" parsePair x = - let (k, v) = B.break (== 61) x -- equal sign + let (k, v) = B.break (== _equal) x v'' = case B.uncons v of Just (_, v') -> Just $ urlDecode replacePlus v' _ -> Nothing in (urlDecode replacePlus k, v'') - -queryStringSeparators :: B.ByteString -queryStringSeparators = B.pack [38, 59] -- ampersand, semicolon - --- | Break the second bytestring at the first occurrence of any bytes from --- the first bytestring, discarding that byte. -breakDiscard :: B.ByteString -> B.ByteString -> (B.ByteString, B.ByteString) -breakDiscard seps s = - let (x, y) = B.break (`B.elem` seps) s - in (x, B.drop 1 y) + -- Break the second bytestring at the first occurrence of any bytes from + -- the first bytestring, discarding that byte. + breakDiscard :: B.ByteString -> B.ByteString -> (B.ByteString, B.ByteString) + breakDiscard seps s = + let (x, y) = B.break (`B.elem` seps) s + in (x, B.drop 1 y) -- | Parse 'SimpleQuery' from a 'B.ByteString'. -- @@ -300,19 +298,22 @@ urlEncodeBuilder' extraUnreserved = | unreserved ch = B.word8 ch | otherwise = h2 ch - unreserved ch - | ch >= 65 && ch <= 90 = True -- A-Z - | ch >= 97 && ch <= 122 = True -- a-z - | ch >= 48 && ch <= 57 = True -- 0-9 - unreserved c = c `elem` extraUnreserved + -- The order is optimized from most expected to least expected + unreserved ch = + -- FIXME: could be one index lookup + (ch >= 0x61 && ch <= 0x7A) -- a-z + || (ch >= 0x30 && ch <= 0x39) -- 0-9 + || (ch >= 0x41 && ch <= 0x5A) -- A-Z + || (ch `elem` extraUnreserved) -- must be upper-case - h2 v = B.word8 37 `mappend` B.word8 (h a) `mappend` B.word8 (h b) -- 37 = % + h2 v = B.word8 _percent `mappend` B.word8 (h a) `mappend` B.word8 (h b) where - (a, b) = v `divMod` 16 + a = v `shiftR` 4 + b = v .&. 0x0F h i - | i < 10 = 48 + i -- zero (0) - | otherwise = 65 + i - 10 -- 65: A + | i < 10 = 0x30 + i -- digits (0x30 == '0') + | otherwise = 0x37 + i -- A-F (0x41 - 10; 0x41 == 'A') -- | Percent-encoding for URLs. -- @@ -359,20 +360,23 @@ urlDecode replacePlus z = fst $ B.unfoldrN (B.length z) go z case B.uncons bs of Nothing -> Nothing -- plus to space - Just (43, ws) | replacePlus -> Just (32, ws) + Just (0x2B, ws) | replacePlus -> Just (0x20, ws) -- percent - Just (37, ws) -> Just $ fromMaybe (37, ws) $ do + Just tup@(0x25, ws) -> Just $ fromMaybe tup $ do (x, xs) <- B.uncons ws - x' <- hexVal x (y, ys) <- B.uncons xs - y' <- hexVal y - Just (combine x' y', ys) - Just (w, ws) -> Just (w, ws) + a <- hexVal x + b <- hexVal y + Just (a `combine` b, ys) + Just other -> Just other hexVal w - | 48 <= w && w <= 57 = Just $ w - 48 -- 0 - 9 - | 65 <= w && w <= 70 = Just $ w - 55 -- A - F - | 97 <= w && w <= 102 = Just $ w - 87 -- a - f + -- FIXME: could be one index lookup + | 0x30 <= w && w <= 0x39 = Just result -- 0 - 9 + | 0x41 <= w && w <= 0x46 = Just (result + 9) -- A - F + | 0x61 <= w && w <= 0x66 = Just (result + 9) -- a - f | otherwise = Nothing + where + result = w .&. 0x0F combine :: Word8 -> Word8 -> Word8 combine a b = shiftL a 4 .|. b @@ -409,13 +413,13 @@ urlDecode replacePlus z = fst $ B.unfoldrN (B.length z) go z -- -- @since 0.5 encodePathSegments :: [Text] -> B.Builder -encodePathSegments = foldr (\x -> mappend (B.byteString "/" `mappend` encodePathSegment x)) mempty +encodePathSegments = foldr (\x -> mappend (B.word8 _slash `mappend` encodePathSegment x)) mempty -- | Like 'encodePathSegments', but without the initial slash. -- -- @since 0.6.10 encodePathSegmentsRelative :: [Text] -> B.Builder -encodePathSegmentsRelative xs = mconcat $ intersperse (B.byteString "/") (map encodePathSegment xs) +encodePathSegmentsRelative xs = mconcat $ intersperse (B.word8 _slash) (map encodePathSegment xs) encodePathSegment :: Text -> B.Builder encodePathSegment = urlEncodeBuilder False . encodeUtf8 @@ -433,10 +437,11 @@ decodePathSegments a = where drop1Slash bs = case B.uncons bs of - Just (47, bs') -> bs' -- 47 == / + -- 0x2F == _slash + Just (0x2F, bs') -> bs' _ -> bs go bs = - let (x, y) = B.break (== 47) bs + let (x, y) = B.break (== _slash) bs in decodePathSegment x : if B.null y then [] @@ -474,11 +479,17 @@ decodePathSegment = decodeUtf8With lenientDecode . urlDecode False extractPath :: B.ByteString -> B.ByteString extractPath = ensureNonEmpty . extract where - extract path - | "http://" `B.isPrefixOf` path = (snd . breakOnSlash . B.drop 7) path - | "https://" `B.isPrefixOf` path = (snd . breakOnSlash . B.drop 8) path - | otherwise = path - breakOnSlash = B.break (== 47) + extract path = + case prefix of + "http://" -> fromSlash rest + "https:/" + -- we need one more _slash for it to be a correct protocol prefix + | Just (0x2F, more) <- B.uncons rest -> + fromSlash more + _ -> path + where + (prefix, rest) = B.splitAt 7 path + fromSlash = B.dropWhile (/= _slash) ensureNonEmpty "" = "/" ensureNonEmpty p = p @@ -494,7 +505,7 @@ encodePath x y = encodePathSegments x `mappend` renderQueryBuilder True y -- @since 0.5 decodePath :: B.ByteString -> ([Text], Query) decodePath b = - let (x, y) = B.break (== 63) b -- question mark + let (x, y) = B.break (== _question) b in (decodePathSegments x, parseQuery y) ----------------------------------------------------------------------------------------- @@ -545,22 +556,25 @@ renderQueryPartialEscape qm = -- @since 0.12.1 renderQueryBuilderPartialEscape :: Bool -> PartialEscapeQuery -> B.Builder renderQueryBuilderPartialEscape _ [] = mempty --- FIXME: replace mconcat + map with foldr renderQueryBuilderPartialEscape qmark' (p : ps) = - mconcat $ - go (if qmark' then qmark else mempty) p - : map (go amp) ps + mconcat $ go qmark p : map (go amp) ps where - qmark = B.byteString "?" - amp = B.byteString "&" - equal = B.byteString "=" + qmark = if qmark' then B.word8 _question else mempty + amp = B.word8 _ampersand go sep (k, mv) = mconcat [ sep , urlEncodeBuilder True k , case mv of [] -> mempty - vs -> equal `mappend` mconcat (map encode vs) + vs -> B.word8 _equal `mappend` mconcat (map encode vs) ] encode (QE v) = urlEncodeBuilder True v encode (QN v) = B.byteString v + +_percent, _ampersand, _slash, _equal, _question :: Word8 +_percent = 0x25 +_ampersand = 0x26 +_slash = 0x2F +_equal = 0x3D +_question = 0x3F diff --git a/Network/HTTP/Types/Version.hs b/Network/HTTP/Types/Version.hs index 6a68731..e79943f 100644 --- a/Network/HTTP/Types/Version.hs +++ b/Network/HTTP/Types/Version.hs @@ -1,9 +1,35 @@ +{-# LANGUAGE CPP #-} {-# LANGUAGE DeriveDataTypeable #-} {-# LANGUAGE DeriveGeneric #-} +{-# LANGUAGE PatternSynonyms #-} -- | Types and constants to describe the HTTP version. +-- +-- There are no parsing functions, as the formats are fairly different +-- per version of HTTP. And seeing as there are only a handful of versions, +-- it is easier to manually parse the version ad hoc. +-- +-- For example, when you are expecting HTTP v1, try to match @HTTP/1.1@ or +-- @HTTP/1.0@. If you're expecting HTTP v2 or v3, you'd be using ALPN tokens, +-- which would be @http/1.1@, @h2@ or @h3@. module Network.HTTP.Types.Version ( +#if __GLASGOW_HASKELL__ >= 800 + HttpVersion ( + .., + Http09, + Http10, + Http11, + Http20, + Http30 + ), +#else HttpVersion (..), + pattern Http09, + pattern Http10, + pattern Http11, + pattern Http20, + pattern Http30, +#endif http09, http10, http11, @@ -32,6 +58,11 @@ data HttpVersion = HttpVersion -- | >>> show http11 -- "HTTP/1.1" +-- >>> show http20 +-- "HTTP/2.0" +-- +-- This should not be used to render the HTTP version, as different versions +-- have different ways of rendering (i.e. HTTP v2 uses @"h2"@ instead of @"HTTP/2.0"@) instance Show HttpVersion where show (HttpVersion major minor) = "HTTP/" ++ show major ++ "." ++ show minor @@ -58,3 +89,29 @@ http20 = HttpVersion 2 0 -- @since 0.12.5 http30 :: HttpVersion http30 = HttpVersion 3 0 + +---------------------- +-- Pattern Synonyms -- +---------------------- + +-- DO NOT put these on one line with commas, as GHC 7.10.3 doesn't parse that +pattern Http09 :: HttpVersion +pattern Http10 :: HttpVersion +pattern Http11 :: HttpVersion +pattern Http20 :: HttpVersion +pattern Http30 :: HttpVersion + +-- | @since 0.12.7 +pattern Http09 = HttpVersion 0 9 + +-- | @since 0.12.7 +pattern Http10 = HttpVersion 1 0 + +-- | @since 0.12.7 +pattern Http11 = HttpVersion 1 1 + +-- | @since 0.12.7 +pattern Http20 = HttpVersion 2 0 + +-- | @since 0.12.7 +pattern Http30 = HttpVersion 3 0 diff --git a/cabal.7.10.3.project b/cabal.7.10.3.project new file mode 100644 index 0000000..95069dc --- /dev/null +++ b/cabal.7.10.3.project @@ -0,0 +1,9 @@ +packages: ./http-types.cabal +ignore-project: False +allow-newer: Cabal-3.14.2.0:containers, + Cabal-syntax-3.14.2.0:containers +jobs: 2 +constraints: bytestring == 0.10.6.0, + case-insensitive == 1.2.0.2, + text == 1.2.0.2 +tests: True diff --git a/http-types.cabal b/http-types.cabal index e8b2c1c..f9b9782 100644 --- a/http-types.cabal +++ b/http-types.cabal @@ -1,13 +1,13 @@ -Cabal-version: 2.2 +Cabal-version: 1.22 Name: http-types -Version: 0.12.6 +Version: 0.12.7 Synopsis: Generic HTTP types for Haskell (for both client and server code). Description: Types and functions to describe and handle HTTP concepts. Including "methods", "headers", "query strings", "paths" and "HTTP versions". Homepage: https://github.com/Vlix/http-types License: BSD-3-Clause License-file: LICENSE -Author: Aristid Breitkreuz, Michael Snoyman +Author: Felix Paulusma, Aristid Breitkreuz, Michael Snoyman Maintainer: felix.paulusma@gmail.com Copyright: (C) 2011 Aristid Breitkreuz, (C) 2023 Felix Paulusma Category: Network, Web @@ -24,7 +24,7 @@ Extra-doc-files: Source-repository this type: git location: https://github.com/Vlix/http-types.git - tag: v0.12.6 + tag: v0.13.0 Source-repository head type: git diff --git a/test/Network/HTTP/Types/HeaderSpec.hs b/test/Network/HTTP/Types/HeaderSpec.hs index 1f4f146..e8e1195 100644 --- a/test/Network/HTTP/Types/HeaderSpec.hs +++ b/test/Network/HTTP/Types/HeaderSpec.hs @@ -46,13 +46,23 @@ allHeaders = , (hAcceptCharset, "Accept-Charset") , (hAcceptEncoding, "Accept-Encoding") , (hAcceptLanguage, "Accept-Language") + , (hAcceptPatch, "Accept-Patch") , (hAcceptQuery, "Accept-Query") , (hAcceptRanges, "Accept-Ranges") + , (hAccessControlAllowCredentials, "Access-Control-Allow-Credentials") + , (hAccessControlAllowHeaders, "Access-Control-Allow-Headers") + , (hAccessControlAllowMethods, "Access-Control-Allow-Methods") + , (hAccessControlAllowOrigin, "Access-Control-Allow-Origin") + , (hAccessControlExposeHeaders, "Access-Control-Expose-Headers") + , (hAccessControlMaxAge, "Access-Control-Max-Age") + , (hAccessControlRequestMethod, "Access-Control-Request-Method") , (hAge, "Age") , (hAllow, "Allow") + , (hAltSvc, "Alt-Svc") , (hAuthorization, "Authorization") , (hCacheControl, "Cache-Control") , (hConnection, "Connection") + , (hContentDigest, "Content-Digest") , (hContentDisposition, "Content-Disposition") , (hContentEncoding, "Content-Encoding") , (hContentLanguage, "Content-Language") @@ -60,12 +70,15 @@ allHeaders = , (hContentLocation, "Content-Location") , (hContentMD5, "Content-MD5") , (hContentRange, "Content-Range") + , (hContentSecurityPolicy, "Content-Security-Policy") + , (hContentSecurityPolicyReportOnly, "Content-Security-Policy-Report-Only") , (hContentType, "Content-Type") , (hCookie, "Cookie") , (hDate, "Date") , (hETag, "ETag") , (hExpect, "Expect") , (hExpires, "Expires") + , (hForwarded, "Forwarded") , (hFrom, "From") , (hHost, "Host") , (hIfMatch, "If-Match") @@ -74,6 +87,7 @@ allHeaders = , (hIfRange, "If-Range") , (hIfUnmodifiedSince, "If-Unmodified-Since") , (hLastModified, "Last-Modified") + , (hLink, "Link") , (hLocation, "Location") , (hMaxForwards, "Max-Forwards") , (hMIMEVersion, "MIME-Version") @@ -88,6 +102,7 @@ allHeaders = , (hRetryAfter, "Retry-After") , (hServer, "Server") , (hSetCookie, "Set-Cookie") + , (hStrictTransportSecurity, "Strict-Transport-Security") , (hTE, "TE") , (hTrailer, "Trailer") , (hTransferEncoding, "Transfer-Encoding") diff --git a/test/Network/HTTP/Types/StatusSpec.hs b/test/Network/HTTP/Types/StatusSpec.hs index 7f8f700..69370a6 100644 --- a/test/Network/HTTP/Types/StatusSpec.hs +++ b/test/Network/HTTP/Types/StatusSpec.hs @@ -1,4 +1,5 @@ {-# LANGUAGE OverloadedStrings #-} +{-# OPTIONS_GHC -Wno-incomplete-uni-patterns #-} {-# OPTIONS_GHC -Wno-orphans #-} module Network.HTTP.Types.StatusSpec (main, spec) where @@ -8,7 +9,7 @@ import qualified Data.ByteString.Char8 as B8 import Data.Function (on) import qualified Data.List as L import Test.Hspec -import Test.QuickCheck (Arbitrary (..), choose, property, resize) +import Test.QuickCheck (Arbitrary (..), choose, property, resize, (===)) import Test.QuickCheck.Instances () import Network.HTTP.Types @@ -34,6 +35,42 @@ spec = do it "only orders on 'statusCode'" $ property $ \st1 st2 -> (st1 < st2) == ((<) `on` statusCode) st1 st2 + describe "Render functions" $ do + it "renders the code" $ do + renderStatusCode notFound404 `shouldBe` "404" + renderStatusCode continue100 `shouldBe` "100" + renderStatusCode (mkStatus 12 "") `shouldBe` "012" + renderStatusCode (mkStatus 987 "") `shouldBe` "987" + it "renders the message" $ do + renderFullStatus notFound404 `shouldBe` "404 Not Found" + renderFullStatus continue100 `shouldBe` "100 Continue" + renderFullStatus (mkStatus 12 "Short") `shouldBe` "012 Short" + renderFullStatus (mkStatus 987 "Pretty Long If I May Say So Myself") + `shouldBe` "987 Pretty Long If I May Say So Myself" + describe "Parsing functions" $ do + it "parses the code" $ do + parseStatusCode "307" `shouldBe` Just (307, "") + parseStatusCode "404 Not Found" `shouldBe` Just (404, " Not Found") + parseStatusCode "1337 Works Still" `shouldBe` Just (133, "7 Works Still") + parseStatusCode "No Digits" `shouldBe` Nothing + parseStatusCode "12 Is Not Enough" `shouldBe` Nothing + it "parses the message" $ do + parseFullStatus "307" `shouldBe` Just (mkStatus 307 "") + parseFullStatus "404 Not Found" `shouldBe` Just (mkStatus 404 "Not Found") + parseFullStatus "500 Someone Forgot To\r\nBreak At The Newline" + `shouldBe` Just (mkStatus 500 "Someone Forgot To\r\nBreak At The Newline") + parseFullStatus "1337 Works Still" `shouldBe` Nothing + parseFullStatus "No Digits" `shouldBe` Nothing + it "round trips" $ do + parseFullStatus (renderFullStatus notFound404) `shouldBe` Just notFound404 + -- I know this is under "round trips" and loses the message, but + -- it's about the status code here. + parseStatusCode (renderStatusCode notFound404) `shouldBe` Just (404, "") + describe "Patterns" $ do + it "matches on StatusCode" $ + property $ \st -> + case st of + StatusCode code -> code === statusCode st categoryCheck :: String -> (Status -> Bool) -> [StatusTuple] -> Spec categoryCheck name p shoulds = do diff --git a/test/Network/HTTP/Types/URISpec.hs b/test/Network/HTTP/Types/URISpec.hs index 5e5591c..cb901c4 100644 --- a/test/Network/HTTP/Types/URISpec.hs +++ b/test/Network/HTTP/Types/URISpec.hs @@ -7,10 +7,12 @@ module Network.HTTP.Types.URISpec (main, spec) where +import Data.Bits ((.|.)) import qualified Data.ByteString as B import qualified Data.ByteString.Builder as BB import qualified Data.ByteString.Char8 as B8 import qualified Data.ByteString.Lazy as BL +import qualified Data.Char as C import Data.Maybe (fromMaybe) import Data.Text as T (Text, null) import Debug.Trace (traceShow) @@ -25,6 +27,7 @@ import Test.QuickCheck ( property, suchThat, (.&&.), + (===), (==>), ) import Test.QuickCheck.Instances () @@ -42,7 +45,7 @@ spec = do it "does not escape period and dash" $ toStrictBS (encodePath ["foo-bar.baz"] []) `shouldBe` "/foo-bar.baz" - -- FIXME: needs more path tests + -- FIXME: needs more path tests describe "encode/decode query" $ do it "is identity to encode and then decode" $ @@ -85,6 +88,13 @@ spec = do it "still encodes the same (query)" $ mkGoldenFile "urlEncode-query" $ urlEncode True asciis + it "decodes lower case" $ + property $ \bs -> + -- force all bytes to be above the ASCII range + let onlyPercent = B.map (.|. 0x80) bs + -- Should only be percent encoded and then all "A-F -> a-f" + lowerCaseEncoded = B8.map C.toLower $ urlEncode True onlyPercent + in urlDecode True lowerCaseEncoded === onlyPercent describe "decodePathSegments" $ do it "is inverse to encodePathSegments" $ @@ -150,15 +160,15 @@ goldenDir = "test" ".golden" mkGoldenFile :: String -> B.ByteString -> Golden B.ByteString mkGoldenFile name content = - Golden { - output = content, - encodePretty = B8.unpack, - writeToFile = B.writeFile, - readFromFile = B.readFile, - goldenFile = goldenDir name "golden", - actualFile = Just (goldenDir name "actual"), - failFirstTime = False - } + Golden + { output = content + , encodePretty = B8.unpack + , writeToFile = B.writeFile + , readFromFile = B.readFile + , goldenFile = goldenDir name "golden" + , actualFile = Just (goldenDir name "actual") + , failFirstTime = False + } propEncodeDecodePath :: ([Text], QueryGen B.ByteString) -> Bool propEncodeDecodePath (p', QueryGen b) = @@ -209,15 +219,15 @@ propDecodeSimpleQuery (QueryGen q) = where rq = renderQuery True q -propEncodeDecodeQuerySimple :: QueryGen B.ByteString -> Bool -> Bool +propEncodeDecodeQuerySimple :: QueryGen B.ByteString -> Bool -> Property propEncodeDecodeQuerySimple (QueryGen q') b = - q == (parseSimpleQuery . renderSimpleQuery b) q + q === (parseSimpleQuery . renderSimpleQuery b) q where q = fmap (fmap $ fromMaybe "") q' -propEncodeDecodeURL :: B.ByteString -> Bool -> Bool -> Bool +propEncodeDecodeURL :: B.ByteString -> Bool -> Bool -> Property propEncodeDecodeURL bs b1 b2 = - bs == urlDecode b1 (urlEncode b3 bs) + bs === urlDecode b1 (urlEncode b3 bs) where b3 = b1 || b2 diff --git a/test/Network/HTTP/Types/VersionSpec.hs b/test/Network/HTTP/Types/VersionSpec.hs index 1a80923..e5ef90d 100644 --- a/test/Network/HTTP/Types/VersionSpec.hs +++ b/test/Network/HTTP/Types/VersionSpec.hs @@ -1,3 +1,5 @@ +{-# OPTIONS_GHC -Wno-incomplete-uni-patterns #-} + module Network.HTTP.Types.VersionSpec (main, spec) where import Test.Hspec @@ -8,9 +10,21 @@ main :: IO () main = hspec spec spec :: Spec -spec = +spec = do describe "Regression tests" $ mapM_ checkVersion allVersions + describe "Patterns" $ do + patternMatch http09 (\Http09 -> pure ()) + patternMatch http10 (\Http10 -> pure ()) + patternMatch http11 (\Http11 -> pure ()) + patternMatch http20 (\Http20 -> pure ()) + patternMatch http30 (\Http30 -> pure ()) + +patternMatch :: HttpVersion -> (HttpVersion -> IO ()) -> SpecWith () +patternMatch hv f = + it name $ f hv `shouldReturn` () + where + name = "match the version " <> show hv -- | [("Rendered", {constant}, {literal})] allVersions :: [(String, HttpVersion, HttpVersion)]