on master help sign in
built in http caching
170b47aGreg Weber
.gitignorenew file+1
@@ -0,0 +1 @@
1+dist/
Network/Wai/Application/Static.hs+177−66
@@ -1,6 +1,5 @@
1{-# LANGUAGE OverloadedStrings #-}
1{-# LANGUAGE OverloadedStrings #-}
2−{-# LANGUAGE TemplateHaskell #-}
2+{-# LANGUAGE TemplateHaskell, CPP #-}
3−{-# LANGUAGE CPP #-}
4-- | Static file serving for WAI.
3-- | Static file serving for WAI.
5module Network.Wai.Application.Static
4module Network.Wai.Application.Static
6 ( -- * Generic, non-WAI code
5 ( -- * Generic, non-WAI code
@@ -15,8 +14,8 @@ module Network.Wai.Application.Static
15 , mimeTypeByExt
14 , mimeTypeByExt
16 , defaultMimeTypeByExt
15 , defaultMimeTypeByExt
17 -- ** Finding files
16 -- ** Finding files
18− , CheckPieces
17+ , Pieces
19− , checkPieces
18+ , pathFromPieces
20 -- ** File/folder metadata
19 -- ** File/folder metadata
21 , MetaData (..)
20 , MetaData (..)
22 , mdIsFile
21 , mdIsFile
@@ -24,10 +23,15 @@ module Network.Wai.Application.Static
24 -- ** Directory listings
23 -- ** Directory listings
25 , Listing
24 , Listing
26 , defaultListing
25 , defaultListing
26+ , defaultDirListing
27 -- * WAI application
27 -- * WAI application
28− , StaticSettings (..)
29 , staticApp
28 , staticApp
30 , staticAppPieces
29 , staticAppPieces
30+ -- ** Settings
31+ , StaticSettings (..)
32+ , defaultStaticSettings
33+ , defaultPublicSettings
34+ , CacheSettings (..)
31 ) where
35 ) where
32
36
33import qualified Network.Wai as W
37import qualified Network.Wai as W
@@ -42,7 +46,7 @@ import Data.ByteString.Lazy.Char8 ()
42import System.PosixCompat.Files (fileSize, getFileStatus, modificationTime)
46import System.PosixCompat.Files (fileSize, getFileStatus, modificationTime)
43import System.Posix.Types (FileOffset, EpochTime)
47import System.Posix.Types (FileOffset, EpochTime)
44import Control.Monad.IO.Class (liftIO)
48import Control.Monad.IO.Class (liftIO)
45−import Data.Maybe (catMaybes)
49+import Data.Maybe (catMaybes, isNothing, isJust)
46
50
47import Text.Blaze ((!))
51import Text.Blaze ((!))
48import qualified Text.Blaze.Html5 as H
52import qualified Text.Blaze.Html5 as H
@@ -63,6 +67,17 @@ import qualified Data.Text as T
63import qualified Data.Text.Encoding as TE
67import qualified Data.Text.Encoding as TE
64import qualified Data.Text.Encoding.Error as TEE
68import qualified Data.Text.Encoding.Error as TEE
65
69
70+#ifdef PRINT
71+import Debug.Trace
72+debug :: (Show a) => a -> a
73+debug a = trace ("DEBUG: " ++ show a) a
74+#else
75+trace :: String -> a -> a
76+trace _ x = x
77+debug :: a -> a
78+debug = id
79+#endif
80+
66-- | A list of all possible extensions, starting from the largest.
81-- | A list of all possible extensions, starting from the largest.
67takeExtensions :: FilePath -> [String]
82takeExtensions :: FilePath -> [String]
68takeExtensions s =
83takeExtensions s =
@@ -157,20 +172,19 @@ defaultMimeTypeByExt :: FilePath -> MimeType
157defaultMimeTypeByExt = mimeTypeByExt defaultMimeTypes defaultMimeType
172defaultMimeTypeByExt = mimeTypeByExt defaultMimeTypes defaultMimeType
158
173
159data CheckPieces
174data CheckPieces
160− = Redirect [String]
175+ = Redirect Pieces
161 | Forbidden
176 | Forbidden
162 | NotFound
177 | NotFound
163 | FileResponse FilePath
178 | FileResponse FilePath
179+ | NotModified
164 | DirectoryResponse FilePath
180 | DirectoryResponse FilePath
181+ -- TODO: add file size
165 | SendContent MimeType L.ByteString
182 | SendContent MimeType L.ByteString
166 deriving Show
183 deriving Show
167
184
168−anyButLast :: (a -> Bool) -> [a] -> Bool
185+safeInit :: [a] -> [a]
169−anyButLast _ [] = False
186+safeInit [] = []
170−anyButLast _ [_] = False
187+safeInit xs = init xs
171−anyButLast p (x:xs)
172− | p x == True = True
173− | otherwise = anyButLast p xs
174
188
175filterButLast :: (a -> Bool) -> [a] -> [a]
189filterButLast :: (a -> Bool) -> [a] -> [a]
176filterButLast _ [] = []
190filterButLast _ [] = []
@@ -179,6 +193,7 @@ filterButLast f (x:xs)
179 | f x = x : filterButLast f xs
193 | f x = x : filterButLast f xs
180 | otherwise = filterButLast f xs
194 | otherwise = filterButLast f xs
181
195
196+
182unsafe :: FilePath -> Bool
197unsafe :: FilePath -> Bool
183unsafe ('.':_) = True
198unsafe ('.':_) = True
184unsafe s = any (== '/') s
199unsafe s = any (== '/') s
@@ -188,43 +203,75 @@ stripTrailingSlash "/" = ""
188stripTrailingSlash "" = ""
203stripTrailingSlash "" = ""
189stripTrailingSlash (x:xs) = x : stripTrailingSlash xs
204stripTrailingSlash (x:xs) = x : stripTrailingSlash xs
190
205
206+type Pieces = [Text]
207+relativeFromPieces :: Pieces -> Text
208+relativeFromPieces pieces = concatMap (const "../") (drop 1 pieces) -- last path which is not a dir
209+
210+pathFromPieces :: FilePath -> Pieces -> FilePath
211+pathFromPieces prefix pieces =
212+ concat $ prefix : map ((:) '/') (map unfixPathName pieces)
213+
191−checkPieces :: FilePath -- ^ static file prefix
214+checkPieces :: FilePath -- ^ static file prefix
192− -> [FilePath] -- ^ List of default index files. Cannot contain slashes.
215+ -> [FilePath] -- ^ List of default index files. Cannot contain slashes.
193− -> [String] -- ^ parsed request
216+ -> Pieces -- ^ parsed request
217+ -> CacheSettings
218+ -> W.Request
194 -> IO CheckPieces
219 -> IO CheckPieces
195−checkPieces _ _ [".hidden", "folder.png"] =
220+checkPieces _ _ [".hidden", "folder.png"] _ _ =
196 return $ SendContent "image/png" $ L.fromChunks [$(embedFile "folder.png")]
221 return $ SendContent "image/png" $ L.fromChunks [$(embedFile "folder.png")]
197−checkPieces _ _ [".hidden", "haskell.png"] =
222+checkPieces _ _ [".hidden", "haskell.png"] _ _ =
198 return $ SendContent "image/png" $ L.fromChunks [$(embedFile "haskell.png")]
223 return $ SendContent "image/png" $ L.fromChunks [$(embedFile "haskell.png")]
199−checkPieces prefix indices pieces
224+checkPieces prefix indices pieces cache req
200 | any unsafe pieces = return Forbidden
225 | any unsafe pieces = return Forbidden
201− | anyButLast null pieces =
226+ | any null $ safeInit pieces =
202 return $ Redirect $ filterButLast (not . null) pieces
227 return $ Redirect $ filterButLast (not . null) pieces
203 | otherwise = do
228 | otherwise = do
204− let fp = concat $ prefix : map ((:) '/') (map unfixPathName pieces)
229+ let fp = pathFromPieces prefix pieces
205 let (isFile, isFolder) =
230 let (isFile, isFolder) =
206 case () of
231 case () of
207 ()
232 ()
208 | null pieces -> (True, True)
233 | null pieces -> (True, True)
209 | null (last pieces) -> (False, True)
234 | null (last pieces) -> (False, True)
210 | otherwise -> (True, False)
235 | otherwise -> (True, False)
211− fe <- doesFileExist $ stripTrailingSlash fp
236+
237+ if not isFile then uncached fp isFile isFolder
238+ else
212− case (fe, isFile) of
239+ case cache of
213− (True, True) -> return $ FileResponse fp
240+ ETag ioLookup -> do
214− (True, False) -> return $ Redirect $ init pieces
241+ -- No support for If-Match
215− (False, _) -> do
242+ let mlastEtag = lookup "If-None-Match" (W.requestHeaders req)
216− de <- doesDirectoryExist fp
243+ metag <- ioLookup fp
217− if de
244+ case debug (metag, mlastEtag) of
218− then do
245+ (Just hash, Just lastHash) | hash == lastHash -> return NotModified
219− x <- checkIndices fp indices
246+ _ -> trace "ETAG: no cache match" uncached fp isFile isFolder
220− case x of
247+ Forever isStaticFile ->
221− Just index -> return $ Redirect $ setLast pieces index
248+ if isStaticFile fp (W.queryString req) &&
222− Nothing ->
249+ (isJust $ lookup "If-Modified-Since" (W.requestHeaders req)) &&
223− if isFolder
250+ (isNothing $ lookup "If-Unmodified-Since" (W.requestHeaders req))
224− then return $ DirectoryResponse fp
251+ then return NotModified
225− else return $ Redirect $ pieces ++ [""]
252+ else trace "Static: no cache match" uncached fp isFile isFolder
226− else return NotFound
253+ NoCache -> trace "NoCache" uncached fp isFile isFolder
254+
255+
227 where
256 where
257+ uncached fp isFile isFolder = do
258+ fe <- doesFileExist $ stripTrailingSlash fp
259+ case (fe, isFile) of
260+ (True, True) -> return $ FileResponse fp
261+ (True, False) -> return $ Redirect $ init pieces
262+ (False, _) -> do
263+ de <- doesDirectoryExist fp
264+ if not de
265+ then return NotFound
266+ else do
267+ x <- checkIndices fp indices
268+ case x of
269+ Just index -> return $ Redirect $ setLast pieces index
270+ Nothing ->
271+ if isFolder
272+ then return $ DirectoryResponse fp
273+ else return $ Redirect $ pieces ++ [""]
274+
228 setLast [] x = [x]
275 setLast [] x = [x]
229 setLast [""] x = [x]
276 setLast [""] x = [x]
230 setLast (a:b) x = a : setLast b x
277 setLast (a:b) x = a : setLast b x
@@ -236,30 +283,101 @@ checkPieces prefix indices pieces
236 then return $ Just i
283 then return $ Just i
237 else checkIndices fp is
284 else checkIndices fp is
238
285
239−type Listing = [String] -> FilePath -> IO L.ByteString
286+type Listing = (Pieces -> FilePath -> IO L.ByteString)
287+
288+data StaticDirListing = ListingForbidden | StaticDirListing {
289+ ssListing :: Listing
290+ , ssIndices :: [FilePath]
291+}
292+
293+defaultDirListing :: StaticDirListing
294+defaultDirListing = StaticDirListing defaultListing []
295+
296+-- IO is for development mode
297+type CheckHashParam = (FilePath -> S8.ByteString -> Bool)
298+data CacheSettings = NoCache | Forever CheckHashParam | ETag (FilePath -> IO (Maybe S8.ByteString))
299+
300+{-MaxAge S8.ByteString -}
301+{-maxAge :: Int -> CacheSettings-}
302+{-maxAge = MaxAge . S8.pack . show-}
303+
304+oneYear :: Int
305+oneYear = 60 * 60 * 24 * 365
240
306
241data StaticSettings = StaticSettings
307data StaticSettings = StaticSettings
242 { ssFolder :: FilePath
308 { ssFolder :: FilePath
243− , ssIndices :: [FilePath]
309+ , ssMkRedirect :: Pieces -> FilePath -> S8.ByteString
244− , ssListing :: Maybe Listing
245 , ssGetMimeType :: FilePath -> IO MimeType
310 , ssGetMimeType :: FilePath -> IO MimeType
311+ , ssDirListing :: StaticDirListing
312+ , ssCacheSettings :: CacheSettings
246 }
313 }
247
314
315+defaultStaticSettings :: CacheSettings -> StaticSettings
316+defaultStaticSettings isStaticFile = StaticSettings { ssFolder = "static"
317+ , ssMkRedirect = \pieces newPath -> S8.pack $ (relativeFromPieces pieces) ++ newPath
318+ , ssGetMimeType = return . defaultMimeTypeByExt
319+ , ssDirListing = defaultDirListing
320+ , ssCacheSettings = isStaticFile
321+}
322+defaultPublicSettings :: CacheSettings -> StaticSettings
323+defaultPublicSettings etags = StaticSettings { ssFolder = "public"
324+ , ssMkRedirect = \pieces newPath -> S8.pack $ (relativeFromPieces pieces) ++ newPath
325+ , ssGetMimeType = return . defaultMimeTypeByExt
326+ , ssDirListing = ListingForbidden
327+ , ssCacheSettings = etags
328+}
329+
330+
248staticApp :: StaticSettings -> W.Application
331staticApp :: StaticSettings -> W.Application
249staticApp set req = do
332staticApp set req = do
250 let pieces = W.pathInfo req
333 let pieces = W.pathInfo req
251 staticAppPieces set pieces req
334 staticAppPieces set pieces req
252
335
336+status304, statusNotModified :: W.Status
337+status304 = W.Status 304 "Not Modified"
338+statusNotModified = status304
339+
253−staticAppPieces :: StaticSettings -> [T.Text] -> W.Application
340+staticAppPieces :: StaticSettings -> Pieces -> W.Application
254staticAppPieces _ _ req
341staticAppPieces _ _ req
255 | W.requestMethod req /= "GET" = return $ W.responseLBS
342 | W.requestMethod req /= "GET" = return $ W.responseLBS
256 H.status405
343 H.status405
257 [("Content-Type", "text/plain")]
344 [("Content-Type", "text/plain")]
258 "Only GET is supported"
345 "Only GET is supported"
259−staticAppPieces (StaticSettings folder indices mlisting getmime) piecesT _ = liftIO $ do
346+staticAppPieces ss@StaticSettings{} pieces req = liftIO $ do
260− let pieces = map T.unpack piecesT -- FIXME stick with Text
347+ let cache = ssCacheSettings ss
348+ let indices = case ssDirListing ss of
349+ StaticDirListing _ is -> is
350+ ListingForbidden -> []
261− cp <- checkPieces folder indices pieces
351+ cp <- checkPieces (ssFolder ss) indices pieces cache req
262− case cp of
352+ case debug cp of
353+ FileResponse fp -> do
354+ mimetype <- (ssGetMimeType ss) fp
355+ filesize <- fileSize `fmap` getFileStatus fp
356+ ch <- setCacheHeaders cache fp
357+ return $ W.ResponseFile W.status200
358+ ( [ ("Content-Type", mimetype)
359+ , ("Content-Length", S8.pack $ show filesize)
360+ ] ++ ch ) fp Nothing
361+ NotModified ->
362+ return $ W.responseLBS statusNotModified
363+ [ ("Content-Type", "text/plain")
364+ ] "Not Modified"
365+ DirectoryResponse fp ->
366+ case ssDirListing ss of
367+ StaticDirListing f _ -> do
368+ lbs <- f pieces fp
369+ return $ W.responseLBS W.status200
370+ [ ("Content-Type", "text/html; charset=utf-8")
371+ ] lbs
372+ ListingForbidden -> return $ W.responseLBS W.status403
373+ [ ("Content-Type", "text/plain")
374+ ] "Directory listings disabled"
375+ SendContent mt lbs -> do
376+ -- TODO: set caching headers
377+ return $ W.responseLBS W.status200
378+ [ ("Content-Type", mt)
379+ -- TODO: set Content-Length
380+ ] lbs
263 Redirect pieces' -> do
381 Redirect pieces' -> do
264 let loc =
382 let loc =
265 toByteString $
383 toByteString $
@@ -275,26 +393,19 @@ staticAppPieces (StaticSettings folder indices mlisting getmime) piecesT _ = lif
275 NotFound -> return $ W.responseLBS H.status404
393 NotFound -> return $ W.responseLBS H.status404
276 [ ("Content-Type", "text/plain")
394 [ ("Content-Type", "text/plain")
277 ] "File not found"
395 ] "File not found"
278− FileResponse fp -> do
396+ where
279− mimetype <- getmime fp
397+ -- expires header: formatTime "%a, %d-%b-%Y %X GMT"
280− filesize <- fileSize `fmap` getFileStatus fp
398+ setCacheHeaders :: CacheSettings -> FilePath -> IO W.ResponseHeaders
281− return $ W.ResponseFile H.status200
399+ setCacheHeaders (Forever isStaticFile) fp = return $
282− [ ("Content-Type", mimetype)
400+ if isStaticFile fp (W.queryString req)
283− , ("Content-Length", S8.pack $ show filesize)
401+ then [("Cache-Control", S8.append "max-age=" $ S8.pack $ show oneYear)]
284− ] fp Nothing
402+ else []
285− DirectoryResponse fp ->
403+ setCacheHeaders NoCache _ = return []
404+ setCacheHeaders (ETag ioLookup) fp = do
405+ etag <- ioLookup fp
286− case mlisting of
406+ return $ case etag of
287− Just listing -> do
407+ Just hash -> [("ETag", hash)]
288− lbs <- listing pieces fp
408+ Nothing -> []
289− return $ W.responseLBS H.status200
290− [ ("Content-Type", "text/html; charset=utf-8")
291− ] lbs
292− Nothing -> return $ W.responseLBS H.status403
293− [ ("Content-Type", "text/plain")
294− ] "Directory listings disabled"
295− SendContent mt lbs -> return $ W.responseLBS H.status200
296− [ ("Content-Type", mt)
297− ] lbs
298
409
299fixPathName :: FilePath -> FilePath
410fixPathName :: FilePath -> FilePath
300#if defined(mingw32_HOST_OS)
411#if defined(mingw32_HOST_OS)
@@ -341,7 +452,7 @@ defaultListing pieces localPath = do
341 H.h1 $ showFolder $ "" : filter (not . null) pieces
452 H.h1 $ showFolder $ "" : filter (not . null) pieces
342 renderDirectoryContentsTable haskellSrc folderSrc $ catMaybes fps''
453 renderDirectoryContentsTable haskellSrc folderSrc $ catMaybes fps''
343 where
454 where
344− image x = concatMap (const "../") (drop 1 pieces) ++ ".hidden/" ++ x ++ ".png"
455+ image x = (relativeFromPieces pieces) ++ ".hidden/" ++ x ++ ".png"
345 folderSrc = image "folder"
456 folderSrc = image "folder"
346 haskellSrc = image "haskell"
457 haskellSrc = image "haskell"
347 showName "" = "root"
458 showName "" = "root"
tests/runtests.hsnew file+113
@@ -0,0 +1,113 @@
1+{-# LANGUAGE OverloadedStrings #-}
2+import Network.Wai.Application.Static
3+
4+import Test.Hspec
5+import Test.Hspec.HUnit ()
6+import Test.Hspec.QuickCheck (prop)
7+import Test.HUnit ((@?=))
8+import Distribution.Simple.Utils (isInfixOf)
9+import qualified Data.ByteString.Char8 as S8
10+
11+
12+import Network.Wai
13+import Network.Wai.Test
14+
15+import Network.Socket.Internal as Sock
16+
17+main :: IO ()
18+main = hspecX specs
19+
20+defRequest :: Request
21+defRequest = Request {
22+ queryString = ""
23+, requestMethod = "GET"
24+, pathInfo = "/index.html"
25+, requestHeaders = []
26+, serverName = "wai-test"
27+, httpVersion = "1.1"
28+, serverPort = 80
29+, isSecure = False
30+, errorHandler = putStrLn
31+, remoteHost = Sock.SockAddrUnix "/dev/null"
32+}
33+
34+specs :: IO [Spec]
35+specs = runSpecM $ do
36+ context "Pieces: pathFromPieces" $ do
37+ ti "converts to a file path" $
38+ (pathFromPieces "prefix" ["a", "bc"]) @?= "prefix/a/bc"
39+
40+ prop "each piece is in file path" $ \pieces ->
41+ all (\p -> ("/" ++ p) `isInfixOf` (pathFromPieces "root" $ pieces)) pieces
42+
43+
44+ let cacheLookup = Forever (\fp h -> fp == "tests/runtests.hs" && h == "cached")
45+ let statApp = flip runSession $ staticApp $ (defaultStaticSettings cacheLookup) {ssFolder = "tests"}
46+ context "staticApp" $ do
47+ ti "403 for unsafe paths" $ statApp $
48+ flip mapM_ ["..", "."] $ \path ->
49+ assertStatus 403 =<<
50+ request defRequest { pathInfo = path }
51+
52+ ti "200 for hidden paths" $ statApp $
53+ flip mapM_ [".hidden/folder.png", ".hidden/haskell.png"] $ \path ->
54+ assertStatus 200 =<<
55+ request defRequest { pathInfo = path }
56+
57+ ti "404 for non-existant files" $ statApp $
58+ assertStatus 404 =<<
59+ request defRequest { pathInfo = "doesNotExist" }
60+
61+ ti "301 redirect when multiple slashes" $ statApp $ do
62+ req <- request defRequest { pathInfo = "a//b/c" }
63+ assertStatus 301 req
64+ assertHeader "Location" "../../a/b/c" req
65+
66+ let absoluteApp = flip runSession $ staticApp $ (defaultStaticSettings cacheLookup) {
67+ ssFolder = "tests", ssMkRedirect = \_ u -> S8.pack $ "http://www.example.com" ++ "/" ++ u
68+ }
69+ ti "301 redirect when multiple slashes" $ absoluteApp $
70+ flip mapM_ ["/a//b/c", "a//b/c"] $ \path -> do
71+ req <- request defRequest { pathInfo = path }
72+ assertStatus 301 req
73+ assertHeader "Location" "http://www.example.com/a/b/c" req
74+
75+ context "when requesting a static asset" $ do
76+ let runtests = defRequest { pathInfo = "runtests.hs" }
77+ ti "200 when no query parameters" $ statApp $ do
78+ req <- request runtests {
79+ requestHeaders = [("If-Modified-Since", "")]
80+ }
81+ assertStatus 200 req
82+ assertNoHeader "Cache-Control" req
83+
84+ ti "200 when no cache headers sent" $ statApp $ do
85+ req <- request runtests { queryString ="cached" }
86+ assertStatus 200 req
87+ assertHeader "Cache-Control" "max-age=31536000" req
88+
89+ ti "200 when If-Unmodified-Since" $ statApp $ do
90+ req <- request runtests { queryString ="cached",
91+ requestHeaders = [("If-Unmodified-Since", "")]
92+ }
93+ assertStatus 200 req
94+ assertHeader "Cache-Control" "max-age=31536000" req
95+
96+ ti "304 when cache header and query parameter sent" $ statApp $ do
97+ req <- request runtests { queryString ="cached",
98+ requestHeaders = [("If-Modified-Since", "")]
99+ }
100+ assertStatus 304 req
101+ assertNoHeader "Cache-Control" req
102+
103+ let pubApp = flip runSession $ staticApp (defaultPublicSettings $ ETag (\_ -> return $ Just "hash")) {ssFolder = "tests"}
104+ context "staticApp when requesting a public asset - etags" $ do
105+ ti "200 when no etag parameters" $ pubApp $ do
106+ req <- request defRequest { pathInfo = "runtests.hs" }
107+ assertStatus 200 req
108+ assertHeader "Etag" "hash" req
109+
110+ ti "304 when etag parameter sent" $ pubApp $ do
111+ req <- request defRequest { pathInfo = "runtests.hs", requestHeaders = [("If-None-Match", "hash")] }
112+ assertStatus 304 req
113+ assertNoHeader "Etag" req
wai-app-static.cabal+27−1
@@ -8,11 +8,15 @@ synopsis: WAI application for static serving
8description: Also provides some helper functions and datatypes for use outside of WAI.
8description: Also provides some helper functions and datatypes for use outside of WAI.
9category: Web, Yesod
9category: Web, Yesod
10stability: Stable
10stability: Stable
11−cabal-version: >= 1.6
11+cabal-version: >= 1.8
12build-type: Simple
12build-type: Simple
13homepage: http://www.yesodweb.com/
13homepage: http://www.yesodweb.com/
14Extra-source-files: folder.png, haskell.png
14Extra-source-files: folder.png, haskell.png
15
15
16+Flag print
17+ Description: print debug info
18+ Default: True
19+
16library
20library
17 build-depends: base >= 4 && < 5
21 build-depends: base >= 4 && < 5
18 , wai >= 0.4 && < 0.5
22 , wai >= 0.4 && < 0.5
@@ -30,6 +34,28 @@ library
30 , blaze-builder >= 0.2.1.4 && < 0.4
34 , blaze-builder >= 0.2.1.4 && < 0.4
31 exposed-modules: Network.Wai.Application.Static
35 exposed-modules: Network.Wai.Application.Static
32 ghc-options: -Wall
36 ghc-options: -Wall
37+ extensions: CPP
38+
39+ if flag(print)
40+ cpp-options: -DPRINT
41+
42+test-suite runtests
43+ hs-source-dirs: tests
44+ main-is: runtests.hs
45+ type: exitcode-stdio-1.0
46+
47+ build-depends: base >= 4 && < 5
48+ , hspec
49+ , HUnit
50+ , Cabal
51+ , wai-app-static
52+ , wai
53+ , wai-test
54+ , network
55+ , bytestring
56+ , transformers
57+ , containers
58+ ghc-options: -Wall
33
59
34source-repository head
60source-repository head
35 type: git
61 type: git