on master help sign in
Merge branch 'master' into internal-state

Conflicts: wai-app-static/WaiAppStatic/Storage/Embedded.hs wai-extra/wai-extra.cabal

commit5cf67e901e17f0b93f62a55c8202b65926f8c43d authorMichael Snoyman <michael@snoyman.com> dateSun 8 Sep 2013, 17:03 (13 years ago) parent8af2f944f76770
9 files changed+512−93
wai-app-static/WaiAppStatic/Storage/Embedded.hs+9−90 wai-app-static/WaiAppStatic/Storage/Embedded/Runtime.hs+93 wai-app-static/WaiAppStatic/Storage/Embedded/TH.hs+259 wai-app-static/test/EmbeddedTestEntries.hs+45 wai-app-static/test/WaiAppEmbeddedTest.hs+78 wai-app-static/tests.hs+2−1 wai-app-static/wai-app-static.cabal+6 wai-extra/Network/Wai/Middleware/RequestLogger.hs+19−2 wai-extra/wai-extra.cabal+1
wai-app-static/WaiAppStatic/Storage/Embedded.hs+9−90
@@ -1,93 +1,12 @@
1−-- | Lookup files stored in memory instead of from the filesystem.
2−module WaiAppStatic.Storage.Embedded
3− ( -- * Settings
1+module WaiAppStatic.Storage.Embedded(
2+ -- * Basic
43 embeddedSettings
5− ) where
6−
7−import WaiAppStatic.Types
8−import Data.ByteString (ByteString)
9−import Control.Arrow ((&&&), second)
10−import Data.List
11−import Blaze.ByteString.Builder (fromByteString)
12−import qualified Network.Wai as W
13−import qualified Data.Map as Map
14−import Data.Function (on)
15−import qualified Data.Text as T
16−import Data.Ord
17−import qualified Data.ByteString as S
18−import qualified Crypto.Classes as CR
19−import Crypto.Hash.CryptoAPI (hash', MD5)
20−import qualified Data.ByteString.Base64 as B64
21−import WaiAppStatic.Storage.Filesystem (defaultFileServerSettings)
22−
23−-- | Serve the list of path/content pairs directly from memory.
24−embeddedSettings :: [(Prelude.FilePath, ByteString)] -> StaticSettings
25−embeddedSettings files = (defaultFileServerSettings $ error "unused")
26− { ssLookupFile = embeddedLookup $ toEmbedded files
27− }
28−
29−type Embedded = Map.Map Piece EmbeddedEntry
30−
31−data EmbeddedEntry = EEFile ByteString | EEFolder Embedded
32−
33−embeddedLookup :: Embedded -> Pieces -> IO LookupResult
34−embeddedLookup root pieces =
35− return $ elookup pieces root
36− where
37− elookup :: Pieces -> Embedded -> LookupResult
38− elookup [] x = LRFolder $ Folder $ map toEntry $ Map.toList x
39− elookup [p] x | T.null (fromPiece p) = elookup [] x
40− elookup (p:ps) x =
41− case Map.lookup p x of
42− Nothing -> LRNotFound
43− Just (EEFile f) ->
44− case ps of
45− [] -> LRFile $ bsToFile p f
46− _ -> LRNotFound
47− Just (EEFolder y) -> elookup ps y
484
49−toEntry :: (Piece, EmbeddedEntry) -> Either FolderName File
50−toEntry (name, EEFolder{}) = Left name
51−toEntry (name, EEFile bs) = Right File
52− { fileGetSize = S.length bs
53− , fileToResponse = \s h -> W.responseBuilder s h $ fromByteString bs
54− , fileName = name
55− , fileGetHash = return $ Just $ runHash bs
56− , fileGetModified = Nothing
57− }
58−
59−toEmbedded :: [(Prelude.FilePath, ByteString)] -> Embedded
60−toEmbedded fps =
61− go texts
62− where
63− texts = map (\(x, y) -> (filter (not . T.null . fromPiece) $ toPieces' x, y)) fps
64− toPieces' "" = []
65− toPieces' x =
66− let (y, z) = break (== '/') x
67− in unsafeToPiece (T.pack y) : toPieces' (drop 1 z)
68−
69− go :: [(Pieces, ByteString)] -> Embedded
70− go orig =
71− Map.fromList $ map (second go') hoisted
72− where
73− next = map (\(x, y) -> (head x, (tail x, y))) orig
74− grouped :: [[(Piece, ([Piece], ByteString))]]
75− grouped = groupBy ((==) `on` fst) $ sortBy (comparing fst) next
76− hoisted :: [(Piece, [([Piece], ByteString)])]
77− hoisted = map (fst . head &&& map snd) grouped
78−
79− go' :: [(Pieces, ByteString)] -> EmbeddedEntry
80− go' [([], content)] = EEFile content
81− go' x = EEFolder $ go $ filter (\y -> not $ null $ fst y) x
82−
83−bsToFile :: Piece -> ByteString -> File
84−bsToFile name bs = File
85− { fileGetSize = S.length bs
86− , fileToResponse = \s h -> W.responseBuilder s h $ fromByteString bs
87− , fileName = name
88− , fileGetHash = return $ Just $ runHash bs
89− , fileGetModified = Nothing
90− }
5+ -- * Template Haskell
6+ , Etag
7+ , EmbeddableEntry(..)
8+ , mkSettings
9+ ) where
9110
92−runHash :: ByteString -> ByteString
93−runHash = B64.encode . CR.encode . (hash' :: S.ByteString -> MD5)
11+import WaiAppStatic.Storage.Embedded.Runtime
12+import WaiAppStatic.Storage.Embedded.TH
wai-app-static/WaiAppStatic/Storage/Embedded/Runtime.hsnew file+93
@@ -0,0 +1,93 @@
1+-- | Lookup files stored in memory instead of from the filesystem.
2+module WaiAppStatic.Storage.Embedded.Runtime
3+ ( -- * Settings
4+ embeddedSettings
5+ ) where
6+
7+import WaiAppStatic.Types
8+import Data.ByteString (ByteString)
9+import Control.Arrow ((&&&), second)
10+import Data.List
11+import Blaze.ByteString.Builder (fromByteString)
12+import qualified Network.Wai as W
13+import qualified Data.Map as Map
14+import Data.Function (on)
15+import qualified Data.Text as T
16+import Data.Ord
17+import qualified Data.ByteString as S
18+import qualified Crypto.Classes as CR
19+import Crypto.Hash.CryptoAPI (hash', MD5)
20+import qualified Data.ByteString.Base64 as B64
21+import WaiAppStatic.Storage.Filesystem (defaultFileServerSettings)
22+
23+-- | Serve the list of path/content pairs directly from memory.
24+embeddedSettings :: [(Prelude.FilePath, ByteString)] -> StaticSettings
25+embeddedSettings files = (defaultFileServerSettings $ error "unused")
26+ { ssLookupFile = embeddedLookup $ toEmbedded files
27+ }
28+
29+type Embedded = Map.Map Piece EmbeddedEntry
30+
31+data EmbeddedEntry = EEFile ByteString | EEFolder Embedded
32+
33+embeddedLookup :: Embedded -> Pieces -> IO LookupResult
34+embeddedLookup root pieces =
35+ return $ elookup pieces root
36+ where
37+ elookup :: Pieces -> Embedded -> LookupResult
38+ elookup [] x = LRFolder $ Folder $ map toEntry $ Map.toList x
39+ elookup [p] x | T.null (fromPiece p) = elookup [] x
40+ elookup (p:ps) x =
41+ case Map.lookup p x of
42+ Nothing -> LRNotFound
43+ Just (EEFile f) ->
44+ case ps of
45+ [] -> LRFile $ bsToFile p f
46+ _ -> LRNotFound
47+ Just (EEFolder y) -> elookup ps y
48+
49+toEntry :: (Piece, EmbeddedEntry) -> Either FolderName File
50+toEntry (name, EEFolder{}) = Left name
51+toEntry (name, EEFile bs) = Right File
52+ { fileGetSize = S.length bs
53+ , fileToResponse = \s h -> W.responseBuilder s h $ fromByteString bs
54+ , fileName = name
55+ , fileGetHash = return $ Just $ runHash bs
56+ , fileGetModified = Nothing
57+ }
58+
59+toEmbedded :: [(Prelude.FilePath, ByteString)] -> Embedded
60+toEmbedded fps =
61+ go texts
62+ where
63+ texts = map (\(x, y) -> (filter (not . T.null . fromPiece) $ toPieces' x, y)) fps
64+ toPieces' "" = []
65+ toPieces' x =
66+ let (y, z) = break (== '/') x
67+ in unsafeToPiece (T.pack y) : toPieces' (drop 1 z)
68+
69+ go :: [(Pieces, ByteString)] -> Embedded
70+ go orig =
71+ Map.fromList $ map (second go') hoisted
72+ where
73+ next = map (\(x, y) -> (head x, (tail x, y))) orig
74+ grouped :: [[(Piece, ([Piece], ByteString))]]
75+ grouped = groupBy ((==) `on` fst) $ sortBy (comparing fst) next
76+ hoisted :: [(Piece, [([Piece], ByteString)])]
77+ hoisted = map (fst . head &&& map snd) grouped
78+
79+ go' :: [(Pieces, ByteString)] -> EmbeddedEntry
80+ go' [([], content)] = EEFile content
81+ go' x = EEFolder $ go $ filter (\y -> not $ null $ fst y) x
82+
83+bsToFile :: Piece -> ByteString -> File
84+bsToFile name bs = File
85+ { fileGetSize = S.length bs
86+ , fileToResponse = \s h -> W.responseBuilder s h $ fromByteString bs
87+ , fileName = name
88+ , fileGetHash = return $ Just $ runHash bs
89+ , fileGetModified = Nothing
90+ }
91+
92+runHash :: ByteString -> ByteString
93+runHash = B64.encode . CR.encode . (hash' :: S.ByteString -> MD5)
wai-app-static/WaiAppStatic/Storage/Embedded/TH.hsnew file+259
@@ -0,0 +1,259 @@
1+{-# LANGUAGE TemplateHaskell, QuasiQuotes, OverloadedStrings, MagicHash #-}
2+module WaiAppStatic.Storage.Embedded.TH(
3+ Etag
4+ , EmbeddableEntry(..)
5+ , mkSettings
6+) where
7+
8+import Blaze.ByteString.Builder.ByteString (insertByteString)
9+import Codec.Compression.GZip (compress)
10+import Control.Applicative
11+import Data.ByteString.Unsafe (unsafePackAddressLen)
12+import Data.Either (lefts, rights)
13+import GHC.Exts (Int(..))
14+import Language.Haskell.TH
15+import Network.Mime (MimeType, defaultMimeLookup)
16+import System.IO.Unsafe (unsafeDupablePerformIO)
17+import WaiAppStatic.Types
18+import WaiAppStatic.Storage.Filesystem (defaultWebAppSettings)
19+import qualified Data.ByteString as B
20+import qualified Data.ByteString.Lazy as BL
21+import qualified Data.HashMap.Strict as M
22+import qualified Data.Text as T
23+import qualified Data.Text.Encoding as T
24+import qualified Network.Wai as W
25+
26+-- | An Etag is used to return 304 Not Modified responses so the client does not need
27+-- to download resources a second time. Usually the etag is built from a hash of
28+-- the content. To disable Etags, you can pass the empty string. This will cause the
29+-- content to be redownloaded on every request.
30+type Etag = T.Text
31+
32+-- | Used at compile time to hold data about an entry to embed into the compiled executable.
33+data EmbeddableEntry = EmbeddableEntry {
34+ eLocation :: T.Text -- ^ The location where this resource should be served from. The
35+ -- location can contain forward slashes (/) to simulate directories,
36+ -- but must not end with a forward slash.
37+ , eMimeType :: MimeType -- ^ The mime type.
38+ , eContent :: Either (Etag, BL.ByteString) ExpQ
39+ -- ^ The content itself. The content can be given as a tag and bytestring,
40+ -- in which case the content will be embedded directly into the execuatble.
41+ -- Alternatively, the content can be given as a template haskell expression
42+ -- returning @IO ('Etag', 'BL.ByteString')@ in which case this action will
43+ -- be executed on every request to reload the content (this is useful
44+ -- for a debugging mode).
45+}
46+
47+-- | This structure is used at runtime to hold the entry.
48+data EmbeddedEntry = EmbeddedEntry {
49+ embLocation :: !T.Text
50+ , embMime :: !MimeType
51+ , embEtag :: !B.ByteString
52+ , embCompressed :: !Bool
53+ , embContent :: !B.ByteString
54+}
55+
56+-- | This structure is used at runtime to hold the reload entries.
57+data ReloadEntry = ReloadEntry {
58+ reloadLocation :: !T.Text
59+ , reloadMime :: !MimeType
60+ , reloadContent :: IO (T.Text, BL.ByteString)
61+}
62+
63+-- The use of unsafePackAddressLen is safe here because the length
64+-- is correct and we will only be reading from the bytestring, never
65+-- modifying it.
66+--
67+-- The only IO within unsafePackAddressLen is within newForeignPtr_ where
68+-- a new IORef is created as newIORef (NoFinalizers, []) to hold the finalizer
69+-- for the pointer. Since the pointer for the content will never have a finalizer
70+-- added, we do not care if this finalizer IORef gets created more than once since
71+-- the IORef will always be holding (NoFinalizers, []). Therefore
72+-- unsafeDupablePerformIO is safe.
73+bytestringE :: B.ByteString -> ExpQ
74+bytestringE b = [| unsafeDupablePerformIO (unsafePackAddressLen (I# $lenE) $ctE) |]
75+ where
76+ lenE = litE $ intPrimL $ toInteger $ B.length b
77+ ctE = litE $ stringPrimL $ B.unpack b
78+bytestringLazyE :: BL.ByteString -> ExpQ
79+bytestringLazyE b = [| unsafeDupablePerformIO (unsafePackAddressLen (I# $lenE) $ctE) |]
80+ where
81+ lenE = litE $ intPrimL $ toInteger $ BL.length b
82+ ctE = litE $ stringPrimL $ BL.unpack b
83+
84+-- | A template haskell expression which creates either an EmbeddedEntry or ReloadEntry.
85+mkEntry :: EmbeddableEntry -> ExpQ
86+mkEntry (EmbeddableEntry loc mime (Left (etag, ct))) =
87+ [| Left $ EmbeddedEntry (T.pack $locE)
88+ $(bytestringE mime)
89+ $(bytestringE $ T.encodeUtf8 etag)
90+ (1 == I# $compressedE)
91+ $(bytestringLazyE ct')
92+ |]
93+ where
94+ locE = litE $ stringL $ T.unpack loc
95+ (compressed, ct') = tryCompress mime ct
96+ compressedE = litE $ intPrimL $ if compressed then 1 else 0
97+
98+mkEntry (EmbeddableEntry loc mime (Right expr)) =
99+ [| Right $ ReloadEntry (T.pack $locE)
100+ $(bytestringE mime)
101+ $expr
102+ |]
103+ where
104+ locE = litE $ stringL $ T.unpack loc
105+
106+-- | Converts an embedded entry to a file
107+embeddedToFile :: EmbeddedEntry -> File
108+embeddedToFile entry = File
109+ { fileGetSize = fromIntegral $ B.length $ embContent entry
110+ , fileToResponse = \s h ->
111+ let h' = if embCompressed entry
112+ then h ++ [("Content-Encoding", "gzip")]
113+ else h
114+ in W.responseBuilder s h' $ insertByteString $ embContent entry
115+
116+ -- Usually the fileName should just be the filename not the entire path,
117+ -- but we need the whole path to make the lookup within lookupMime
118+ -- possible. lookupMime is provided only with the File and from that
119+ -- we must find the mime type. Putting the path here is OK since
120+ -- within staticApp the fileName is used for directory listings which
121+ -- we have disabled.
122+ , fileName = unsafeToPiece $ embLocation entry
123+ , fileGetHash = return $ if B.null (embEtag entry)
124+ then Nothing
125+ else Just $ embEtag entry
126+ , fileGetModified = Nothing
127+ }
128+
129+-- | Converts a reload entry to a file
130+reloadToFile :: ReloadEntry -> IO File
131+reloadToFile entry = do
132+ (etag, ct) <- reloadContent entry
133+ let etag' = T.encodeUtf8 etag
134+ return $ File
135+ { fileGetSize = fromIntegral $ BL.length ct
136+ , fileToResponse = \s h -> W.responseLBS s h ct
137+ -- Similar to above the entire path needs to be in the fileName.
138+ , fileName = unsafeToPiece $ reloadLocation entry
139+ , fileGetHash = return $ if T.null etag then Nothing else Just etag'
140+ , fileGetModified = Nothing
141+ }
142+
143+
144+-- | Build a static settings based on a filemap.
145+filemapToSettings :: M.HashMap T.Text (MimeType, IO File) -> StaticSettings
146+filemapToSettings mfiles = (defaultWebAppSettings "")
147+ { ssLookupFile = lookupFile
148+ , ssGetMimeType = lookupMime
149+ }
150+ where
151+ piecesToFile p = T.intercalate "/" $ map fromPiece p
152+
153+ lookupFile [] = return LRNotFound
154+ lookupFile p =
155+ case M.lookup (piecesToFile p) mfiles of
156+ Nothing -> return LRNotFound
157+ Just (_,act) -> LRFile <$> act
158+
159+ lookupMime (File { fileName = p }) =
160+ case M.lookup (fromPiece p) mfiles of
161+ Just (mime,_) -> return mime
162+ Nothing -> return $ defaultMimeLookup $ fromPiece p
163+
164+-- | Create a 'StaticSettings' from a list of entries. Executed at run time.
165+entriesToSt :: [Either EmbeddedEntry ReloadEntry] -> StaticSettings
166+entriesToSt entries = hmap `seq` filemapToSettings hmap
167+ where
168+ embFiles = [ (embLocation e, (embMime e, return $ embeddedToFile e)) | e <- lefts entries]
169+ reloadFiles = [ (reloadLocation r, (reloadMime r, reloadToFile r)) | r <- rights entries]
170+ hmap = M.fromList $ embFiles ++ reloadFiles
171+
172+-- | Create a 'StaticSettings' at compile time that embeds resources directly into the compiled
173+-- executable. The embedded resources are precompressed (depending on mime type)
174+-- so that during runtime the resource can be served very quickly.
175+--
176+-- Because of GHC Template Haskell stage restrictions, you must define
177+-- the entries in a different module than where you create the 'StaticSettings'.
178+-- For example,
179+--
180+-- > {-# LANGUAGE TemplateHaskell, QuasiQuotes, OverloadedStrings #-}
181+-- > module A (mkEmbedded) where
182+-- >
183+-- > import WaiAppStatic.Storage.Embedded
184+-- > import Crypto.Hash.MD5 (hashlazy)
185+-- > import qualified Data.ByteString.Lazy as BL
186+-- > import qualified Data.ByteString.Base64 as B64
187+-- > import qualified Data.Text as T
188+-- > import qualified Data.Text.Encoding as T
189+-- >
190+-- > hash :: BL.ByteString -> T.Text
191+-- > hash = T.take 8 . T.decodeUtf8 . B64.encode . hashlazy
192+-- >
193+-- > mkEmbedded :: IO [EmbeddableEntry]
194+-- > mkEmbedded = do
195+-- > file <- BL.readFile "test.css"
196+-- > let emb = EmbeddableEntry {
197+-- > eLocation = "somedir/test.css"
198+-- > , eMimeType = "text/css"
199+-- > , eContent = Left (hash file, file)
200+-- > }
201+-- >
202+-- > let reload = EmbeddableEntry {
203+-- > eLocation = "anotherdir/test2.txt"
204+-- > , eMimeType = "text/plain"
205+-- > , eContent = Right [| BL.readFile "test2.txt" >>= \c -> return (hash c, c) |]
206+-- > }
207+-- >
208+-- > return [emb, reload]
209+--
210+-- The above @mkEmbedded@ will be executed at compile time. It loads the contents of test.css and
211+-- computes the hash of test.css for the etag. The content will be available at the URL somedir/test.css.
212+-- Internally, 'embedApp' below will attempt to compress the content at compile time. The compression will
213+-- only happen if the compressed content is shorter than the original and the mime type is either text or
214+-- javascript. If the content is compressed, at runtime the precomputed compressed content will be served
215+-- with the appropriate HTTP header. If 'embedApp' decides not to compress the content, it will be
216+-- served directly.
217+--
218+-- Secondly, @mkEmbedded@ creates a reloadable entry. This will be available at the URL anotherdir/test2.txt.
219+-- Whenver a request comes in for anotherdir/test2.txt, the action inside the quasiquote in eContent will
220+-- be executed. This will re-read the test2.txt file and recompute its hash.
221+--
222+-- Finally, here is a module which uses the above action to create a 'W.Application'.
223+--
224+-- > {-# LANGUAGE TemplateHaskell #-}
225+-- > module B where
226+-- >
227+-- > import A
228+-- > import Network.Wai (Application)
229+-- > import Network.Wai.Application.Static (staticApp)
230+-- > import WaiAppStatic.Storage.Embedded
231+-- > import Network.Wai.Handler.Warp (run)
232+-- >
233+-- > myApp :: Application
234+-- > myApp = staticApp $(mkSettings mkEmbedded)
235+-- >
236+-- > main :: IO ()
237+-- > main = run 3000 myApp
238+mkSettings :: IO [EmbeddableEntry] -> ExpQ
239+mkSettings action = do
240+ entries <- runIO action
241+ [| entriesToSt $(listE $ map mkEntry entries) |]
242+
243+shouldCompress :: MimeType -> Bool
244+shouldCompress m = "text/" `B.isPrefixOf` m || m `elem` extra
245+ where
246+ extra = [ "application/json"
247+ , "application/javascript"
248+ , "application/ecmascript"
249+ ]
250+
251+-- | Only compress if the mime type is correct and the compressed text is actually shorter.
252+tryCompress :: MimeType -> BL.ByteString -> (Bool, BL.ByteString)
253+tryCompress mime ct
254+ | shouldCompress mime = (c, ct')
255+ | otherwise = (False, ct)
256+ where
257+ compressed = compress ct
258+ c = BL.length compressed < BL.length ct
259+ ct' = if c then compressed else ct
wai-app-static/test/EmbeddedTestEntries.hsnew file+45
@@ -0,0 +1,45 @@
1+{-# LANGUAGE TemplateHaskell, QuasiQuotes, OverloadedStrings #-}
2+
3+module EmbeddedTestEntries where
4+
5+import WaiAppStatic.Storage.Embedded
6+import qualified Data.Text as T
7+import qualified Data.Text.Lazy as TL
8+import qualified Data.Text.Lazy.Encoding as TL
9+import qualified Data.ByteString.Lazy as BL
10+
11+body :: Int -> Char -> BL.ByteString
12+body i c = TL.encodeUtf8 $ TL.pack $ take i $ repeat c
13+
14+mkEntries :: IO [EmbeddableEntry]
15+mkEntries = return
16+ -- An entry that should be compressed
17+ [ EmbeddableEntry "e1.txt"
18+ "text/plain"
19+ (Left ("Etag 1", body 1000 'A'))
20+
21+ -- An entry so short that the compressed text is longer
22+ , EmbeddableEntry "e2.txt"
23+ "text/plain"
24+ (Left ("Etag 2", "ABC"))
25+
26+ -- An entry that is not compressed because of the mime
27+ , EmbeddableEntry "somedir/e3.txt"
28+ "xxx"
29+ (Left ("Etag 3", body 1000 'A'))
30+
31+ -- A reloadable entry
32+ , EmbeddableEntry "e4.css"
33+ "text/css"
34+ (Right [| return ("Etag 4" :: T.Text, body 2000 'Q') |])
35+
36+ -- An entry without etag
37+ , EmbeddableEntry "e5.txt"
38+ "text/plain"
39+ (Left ("", body 1000 'Z'))
40+
41+ -- A reloadable entry without etag
42+ , EmbeddableEntry "e6.txt"
43+ "text/plain"
44+ (Right [| return ("" :: T.Text, body 1000 'W') |] )
45+ ]
wai-app-static/test/WaiAppEmbeddedTest.hsnew file+78
@@ -0,0 +1,78 @@
1+{-# LANGUAGE TemplateHaskell, OverloadedStrings #-}
2+module WaiAppEmbeddedTest (embSpec) where
3+
4+import Codec.Compression.GZip (compress)
5+import EmbeddedTestEntries
6+import Network.Wai
7+import Network.Wai.Application.Static (staticApp)
8+import Network.Wai.Test
9+import Test.Hspec
10+import WaiAppStatic.Storage.Embedded
11+
12+embApp :: Application
13+embApp = staticApp $(mkSettings mkEntries)
14+
15+defRequest :: Request
16+defRequest = defaultRequest
17+
18+embSpec :: Spec
19+embSpec = do
20+ let embed = flip runSession embApp
21+ describe "embedded, compressed entry" $ do
22+ it "served correctly" $ embed $ do
23+ req <- request (setRawPathInfo defRequest "e1.txt")
24+ assertStatus 200 req
25+ assertHeader "Content-Type" "text/plain" req
26+ assertHeader "Content-Encoding" "gzip" req
27+ assertHeader "ETag" "Etag 1" req
28+ assertNoHeader "Last-Modified req" req
29+ assertBody (compress $ body 1000 'A') req
30+
31+ it "304 when valid if-none-match sent" $ embed $ do
32+ req <- request (setRawPathInfo defRequest "e1.txt")
33+ { requestHeaders = [("If-None-Match", "Etag 1")] }
34+ assertStatus 304 req
35+
36+ describe "embedded, uncompressed entry" $ do
37+ it "too short" $ embed $ do
38+ req <- request (setRawPathInfo defRequest "e2.txt")
39+ assertStatus 200 req
40+ assertHeader "Content-Type" "text/plain" req
41+ assertNoHeader "Content-Encoding" req
42+ assertHeader "ETag" "Etag 2" req
43+ assertBody "ABC" req
44+
45+ it "wrong mime" $ embed $ do
46+ req <- request (setRawPathInfo defRequest "somedir/e3.txt")
47+ assertStatus 200 req
48+ assertHeader "Content-Type" "xxx" req
49+ assertNoHeader "Content-Encoding" req
50+ assertHeader "ETag" "Etag 3" req
51+ assertBody (body 1000 'A') req
52+
53+ describe "reloadable entry" $
54+ it "served correctly" $ embed $ do
55+ req <- request (setRawPathInfo defRequest "e4.css")
56+ assertStatus 200 req
57+ assertHeader "Content-Type" "text/css" req
58+ assertNoHeader "Content-Encoding" req
59+ assertHeader "ETag" "Etag 4" req
60+ assertBody (body 2000 'Q') req
61+
62+ describe "entries without etags" $ do
63+ it "embedded entry" $ embed $ do
64+ req <- request (setRawPathInfo defRequest "e5.txt")
65+ assertStatus 200 req
66+ assertHeader "Content-Type" "text/plain" req
67+ assertHeader "Content-Encoding" "gzip" req
68+ assertNoHeader "ETag" req
69+ assertBody (compress $ body 1000 'Z') req
70+
71+ it "reload entry" $ embed $ do
72+ req <- request (setRawPathInfo defRequest "e6.txt")
73+ assertStatus 200 req
74+ assertHeader "Content-Type" "text/plain" req
75+ assertNoHeader "Content-Encoding" req
76+ assertNoHeader "ETag" req
77+ assertBody (body 1000 'W') req
78+
wai-app-static/tests.hs+2−1
@@ -1,5 +1,6 @@
11import WaiAppStaticTest (spec)
2+import WaiAppEmbeddedTest (embSpec)
23import Test.Hspec.Monadic
34
45main :: IO ()
5−main = hspec spec
6+main = hspec $ spec >> embSpec
wai-app-static/wai-app-static.cabal+6
@@ -47,6 +47,9 @@ library
4747 , crypto-conduit >= 0.4
4848 , cereal >= 0.3.5
4949 , mime-types >= 0.1 && < 0.2
50+ , unordered-containers >= 0.2
51+ , template-haskell >= 2.8
52+ , zlib >= 0.5
5053
5154 exposed-modules: Network.Wai.Application.Static
5255 WaiAppStatic.Storage.Filesystem
@@ -54,6 +57,8 @@ library
5457 WaiAppStatic.Listing
5558 WaiAppStatic.Types
5659 other-modules: Util
60+ WaiAppStatic.Storage.Embedded.Runtime
61+ WaiAppStatic.Storage.Embedded.TH
5762 ghc-options: -Wall
5863 extensions: CPP
5964
@@ -80,6 +85,7 @@ test-suite runtests
8085 , text
8186 , transformers
8287 , mime-types
88+ , zlib
8389 -- , containers
8490 ghc-options: -Wall
8591
wai-extra/Network/Wai/Middleware/RequestLogger.hs+19−2
@@ -34,7 +34,7 @@ import qualified Data.Conduit as C
3434import qualified Data.Conduit.List as CL
3535
3636import System.Console.ANSI
37−import Data.IORef
37+import Data.IORef.Lifted
3838import System.IO.Unsafe
3939
4040import Data.Default (Default (def))
@@ -198,7 +198,24 @@ detailedMiddleware' cb getAddColor app req = do
198198 body <- requestBody req C.$$ CL.consume
199199 -- logging the body here consumes it, so fill it back up
200200 -- obviously not efficient, but this is the development logger
201− let req' = req { requestBody = CL.sourceList body }
201+ --
202+ -- Note: previously, we simply used CL.sourceList. However,
203+ -- that meant that you could read the request body in twice.
204+ -- While that in itself is not a problem, the issue is that,
205+ -- in production, you wouldn't be able to do this, and
206+ -- therefore some bugs wouldn't show up during testing. This
207+ -- implementation ensures that each chunk is only returned
208+ -- once.
209+ ichunks <- newIORef body
210+ let rbody = do
211+ chunks <- readIORef ichunks
212+ case chunks of
213+ [] -> return ()
214+ x:xs -> do
215+ writeIORef ichunks xs
216+ C.yield x
217+ rbody
218+ let req' = req { requestBody = rbody }
202219 return (req', body)
203220 _ -> return (req, [])
204221
wai-extra/wai-extra.cabal+1
@@ -46,6 +46,7 @@ Library
4646 , containers
4747 , base64-bytestring
4848 , word8
49+ , lifted-base
4950
5051 Exposed-modules: Network.Wai.Handler.CGI
5152 Network.Wai.Middleware.AcceptOverride