Skip to content

Commit c5ac5e1

Browse files
authored
Merge pull request #233 from BennyFranciscus/add-scotty
Add Scotty: Haskell web framework on Warp (first Haskell entry!)
2 parents c93da4a + 4f39e62 commit c5ac5e1

16 files changed

Lines changed: 420 additions & 0 deletions

File tree

frameworks/scotty/Dockerfile

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
FROM haskell:9.8-slim AS builder
2+
3+
# Fix archived bullseye repos and install build dependencies
4+
RUN sed -i 's|deb.debian.org|archive.debian.org|g' /etc/apt/sources.list && \
5+
sed -i '/bullseye-updates/d' /etc/apt/sources.list && \
6+
sed -i '/bullseye-security/d' /etc/apt/sources.list && \
7+
apt-get -o Acquire::Check-Valid-Until=false update && \
8+
apt-get install -y --no-install-recommends --allow-downgrades \
9+
libsqlite3-0=3.34.1-3 libsqlite3-dev libpq-dev zlib1g-dev pkg-config && \
10+
rm -rf /var/lib/apt/lists/*
11+
12+
WORKDIR /build
13+
14+
COPY scotty-bench.cabal .
15+
RUN cabal update && \
16+
cabal build --only-dependencies \
17+
--constraint='postgresql-libpq-configure < 0.11'
18+
19+
COPY Main.hs .
20+
RUN cabal build \
21+
--constraint='postgresql-libpq-configure < 0.11' && \
22+
cp $(cabal list-bin scotty-bench) /build/scotty-bench
23+
24+
FROM debian:bookworm-slim
25+
26+
RUN apt-get update && \
27+
apt-get install -y --no-install-recommends libpq5 libsqlite3-0 zlib1g libgmp10 && \
28+
rm -rf /var/lib/apt/lists/*
29+
30+
WORKDIR /app
31+
COPY --from=builder /build/scotty-bench /app/scotty-bench
32+
33+
EXPOSE 8080
34+
35+
CMD ["/app/scotty-bench"]

frameworks/scotty/Main.hs

Lines changed: 336 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,336 @@
1+
{-# LANGUAGE OverloadedStrings #-}
2+
{-# LANGUAGE ScopedTypeVariables #-}
3+
{-# LANGUAGE BangPatterns #-}
4+
5+
module Main where
6+
7+
import Web.Scotty
8+
import Network.Wai (Request, rawQueryString, requestMethod, requestHeaders, getRequestBodyChunk)
9+
import Network.Wai.Handler.Warp (defaultSettings, setPort)
10+
import Network.HTTP.Types.Status (status404, status500)
11+
import Network.HTTP.Types.Method (methodPost)
12+
13+
import qualified Data.ByteString as BS
14+
import qualified Data.ByteString.Lazy as BL
15+
import qualified Data.ByteString.Char8 as BC
16+
import qualified Data.ByteString.Lazy.Char8 as BLC
17+
import qualified Data.Text as T
18+
import qualified Data.Text.Lazy as TL
19+
import qualified Data.Text.Encoding as TE
20+
import Data.Aeson (FromJSON, Value(..), encode, eitherDecodeStrict, object, (.=), (.:))
21+
import qualified Data.Aeson as Aeson
22+
import qualified Data.Aeson.Types as Aeson
23+
24+
import qualified Codec.Compression.GZip as GZip
25+
import qualified Codec.Compression.Zlib as Zlib
26+
27+
import qualified Database.SQLite.Simple as SQLite
28+
import qualified Database.PostgreSQL.Simple as PG
29+
30+
import Data.IORef
31+
import Data.Maybe (fromMaybe, mapMaybe)
32+
import Data.Char (isSpace)
33+
import qualified Data.Map.Strict as Map
34+
import System.Environment (lookupEnv)
35+
import System.Directory (doesFileExist, doesDirectoryExist, listDirectory)
36+
import System.FilePath (takeExtension, (</>))
37+
import Control.Monad (when, forM)
38+
import Control.Monad.IO.Class (liftIO)
39+
import Control.Exception (try, SomeException, bracket)
40+
import Text.Read (readMaybe)
41+
42+
-- Dataset item as loaded from JSON
43+
data DatasetItem = DatasetItem
44+
{ diId :: !Int
45+
, diName :: !T.Text
46+
, diCategory :: !T.Text
47+
, diPrice :: !Double
48+
, diQuantity :: !Int
49+
, diActive :: !Bool
50+
, diTags :: ![T.Text]
51+
, diRating :: !RatingVal
52+
} deriving (Show)
53+
54+
data RatingVal = RatingVal
55+
{ rvScore :: !Double
56+
, rvCount :: !Int
57+
} deriving (Show)
58+
59+
instance FromJSON RatingVal where
60+
parseJSON = Aeson.withObject "RatingVal" $ \v ->
61+
RatingVal <$> v .: "score" <*> v .: "count"
62+
63+
instance FromJSON DatasetItem where
64+
parseJSON = Aeson.withObject "DatasetItem" $ \v ->
65+
DatasetItem
66+
<$> v .: "id"
67+
<*> v .: "name"
68+
<*> v .: "category"
69+
<*> v .: "price"
70+
<*> v .: "quantity"
71+
<*> v .: "active"
72+
<*> v .: "tags"
73+
<*> v .: "rating"
74+
75+
-- Build processed JSON Value from DatasetItem (with total field)
76+
processedItemValue :: DatasetItem -> Value
77+
processedItemValue di = object
78+
[ "id" .= diId di
79+
, "name" .= diName di
80+
, "category" .= diCategory di
81+
, "price" .= diPrice di
82+
, "quantity" .= diQuantity di
83+
, "active" .= diActive di
84+
, "tags" .= diTags di
85+
, "rating" .= object ["score" .= rvScore (diRating di), "count" .= rvCount (diRating di)]
86+
, "total" .= (fromIntegral (round (diPrice di * fromIntegral (diQuantity di) * 100) :: Int) / 100.0 :: Double)
87+
]
88+
89+
-- Parse query string: "?a=1&b=2" -> sum of integer values
90+
parseQuerySum :: BS.ByteString -> Int
91+
parseQuerySum qs =
92+
let qs' = if not (BS.null qs) && BS.head qs == 63 {- '?' -} then BS.drop 1 qs else qs
93+
pairs = BC.split '&' qs'
94+
parseVal pair = case BC.split '=' pair of
95+
[_, v] -> readMaybe (BC.unpack v) :: Maybe Int
96+
_ -> Nothing
97+
in sum $ mapMaybe parseVal pairs
98+
99+
-- MIME type lookup
100+
mimeForExt :: String -> BS.ByteString
101+
mimeForExt ".css" = "text/css"
102+
mimeForExt ".js" = "application/javascript"
103+
mimeForExt ".html" = "text/html"
104+
mimeForExt ".woff2" = "font/woff2"
105+
mimeForExt ".svg" = "image/svg+xml"
106+
mimeForExt ".webp" = "image/webp"
107+
mimeForExt ".json" = "application/json"
108+
mimeForExt _ = "application/octet-stream"
109+
110+
main :: IO ()
111+
main = do
112+
-- Load dataset
113+
datasetPath <- fromMaybe "/data/dataset.json" <$> lookupEnv "DATASET_PATH"
114+
datasetItems <- do
115+
exists <- doesFileExist datasetPath
116+
if exists
117+
then do
118+
raw <- BS.readFile datasetPath
119+
case eitherDecodeStrict raw of
120+
Right items -> return (items :: [DatasetItem])
121+
Left _ -> return []
122+
else return []
123+
124+
-- Pre-compute large JSON payload for compression endpoint
125+
largePayload <- do
126+
exists <- doesFileExist "/data/dataset-large.json"
127+
if exists
128+
then do
129+
raw <- BS.readFile "/data/dataset-large.json"
130+
case eitherDecodeStrict raw of
131+
Right items -> do
132+
let processed = map processedItemValue (items :: [DatasetItem])
133+
resp = encode $ object ["items" .= processed, "count" .= length processed]
134+
return (Just (BL.toStrict resp))
135+
Left _ -> return Nothing
136+
else return Nothing
137+
138+
-- Load static files into memory
139+
staticCache <- do
140+
let dir = "/data/static"
141+
exists <- doesDirectoryExist dir
142+
if exists
143+
then do
144+
files <- listDirectory dir
145+
entries <- forM files $ \name -> do
146+
content <- BS.readFile (dir </> name)
147+
let ct = mimeForExt (takeExtension name)
148+
return (name, (content, ct))
149+
return (Map.fromList entries)
150+
else return Map.empty
151+
152+
-- SQLite connection (read-only)
153+
dbRef <- newIORef (Nothing :: Maybe SQLite.Connection)
154+
do
155+
exists <- doesFileExist "/data/benchmark.db"
156+
when exists $ do
157+
conn <- SQLite.open "/data/benchmark.db"
158+
SQLite.execute_ conn "PRAGMA mmap_size=268435456"
159+
writeIORef dbRef (Just conn)
160+
161+
-- Postgres URL
162+
pgUrl <- lookupEnv "DATABASE_URL"
163+
164+
let opts = Options 0 (setPort 8080 defaultSettings) False
165+
166+
scottyOpts opts $ do
167+
168+
-- Pipeline test: GET /pipeline -> "ok"
169+
get "/pipeline" $ do
170+
setHeader "Server" "scotty"
171+
text "ok"
172+
173+
-- Baseline HTTP/1.1: GET|POST /baseline11
174+
let handleBaseline = do
175+
req <- request
176+
let qSum = parseQuerySum (rawQueryString req)
177+
bodySum <- if requestMethod req == methodPost
178+
then do
179+
b <- body
180+
let trimmed = BLC.dropWhile isSpace b
181+
return $ fromMaybe 0 (readMaybe (BLC.unpack trimmed) :: Maybe Int)
182+
else return 0
183+
setHeader "Server" "scotty"
184+
text $ TL.pack $ show (qSum + bodySum)
185+
186+
get "/baseline11" handleBaseline
187+
post "/baseline11" handleBaseline
188+
189+
-- Baseline HTTP/2: GET /baseline2
190+
get "/baseline2" $ do
191+
req <- request
192+
let qSum = parseQuerySum (rawQueryString req)
193+
setHeader "Server" "scotty"
194+
text $ TL.pack $ show qSum
195+
196+
-- JSON processing: GET /json
197+
get "/json" $ do
198+
let items = map processedItemValue datasetItems
199+
resp = encode $ object ["items" .= items, "count" .= length items]
200+
setHeader "Server" "scotty"
201+
setHeader "Content-Type" "application/json"
202+
raw resp
203+
204+
-- Compression: GET /compression
205+
get "/compression" $ do
206+
case largePayload of
207+
Nothing -> do
208+
status status500
209+
text "No dataset"
210+
Just payload -> do
211+
req <- request
212+
let ae = fromMaybe "" $ lookup "Accept-Encoding" (requestHeaders req)
213+
setHeader "Server" "scotty"
214+
setHeader "Content-Type" "application/json"
215+
if "deflate" `BS.isInfixOf` ae
216+
then do
217+
setHeader "Content-Encoding" "deflate"
218+
raw $ Zlib.compressWith
219+
Zlib.defaultCompressParams { Zlib.compressLevel = Zlib.bestSpeed }
220+
(BL.fromStrict payload)
221+
else if "gzip" `BS.isInfixOf` ae
222+
then do
223+
setHeader "Content-Encoding" "gzip"
224+
raw $ GZip.compressWith
225+
GZip.defaultCompressParams { GZip.compressLevel = GZip.bestSpeed }
226+
(BL.fromStrict payload)
227+
else raw (BL.fromStrict payload)
228+
229+
-- Upload: POST /upload -> byte count (streaming to avoid buffering entire body)
230+
post "/upload" $ do
231+
req <- request
232+
totalBytes <- liftIO $ countBodyBytes req
233+
setHeader "Server" "scotty"
234+
text $ TL.pack $ show totalBytes
235+
236+
-- SQLite DB: GET /db
237+
get "/db" $ do
238+
mConn <- liftIO $ readIORef dbRef
239+
case mConn of
240+
Nothing -> do
241+
setHeader "Server" "scotty"
242+
setHeader "Content-Type" "application/json"
243+
raw "{\"items\":[],\"count\":0}"
244+
Just conn -> do
245+
minP <- paramWithDefault "min" 10.0
246+
maxP <- paramWithDefault "max" 50.0
247+
rows <- liftIO $ SQLite.query conn
248+
"SELECT id, name, category, price, quantity, active, tags, rating_score, rating_count FROM items WHERE price BETWEEN ? AND ? LIMIT 50"
249+
(minP :: Double, maxP :: Double)
250+
let items = map sqliteRowToValue rows
251+
resp = encode $ object ["items" .= items, "count" .= length items]
252+
setHeader "Server" "scotty"
253+
setHeader "Content-Type" "application/json"
254+
raw resp
255+
256+
-- Async DB (PostgreSQL): GET /async-db
257+
get "/async-db" $ do
258+
setHeader "Server" "scotty"
259+
setHeader "Content-Type" "application/json"
260+
case pgUrl of
261+
Nothing -> raw "{\"items\":[],\"count\":0}"
262+
Just url -> do
263+
minP <- paramWithDefault "min" 10.0
264+
maxP <- paramWithDefault "max" 50.0
265+
result <- liftIO $ try $ bracket
266+
(PG.connectPostgreSQL (BC.pack url))
267+
PG.close
268+
(\conn -> PG.query conn
269+
"SELECT id, name, category, price, quantity, active, tags::text, rating_score, rating_count FROM items WHERE price BETWEEN ? AND ? LIMIT 50"
270+
(minP :: Double, maxP :: Double))
271+
case result of
272+
Left (_ :: SomeException) -> raw "{\"items\":[],\"count\":0}"
273+
Right rows -> do
274+
let items = map pgRowToValue rows
275+
raw $ encode $ object ["items" .= items, "count" .= length items]
276+
277+
-- Static files: GET /static/:filename
278+
get "/static/:filename" $ do
279+
filename <- pathParam "filename" :: ActionM T.Text
280+
let key = T.unpack filename
281+
case Map.lookup key staticCache of
282+
Just (content, ct) -> do
283+
setHeader "Server" "scotty"
284+
setHeader "Content-Type" (TL.fromStrict (TE.decodeUtf8 ct))
285+
raw (BL.fromStrict content)
286+
Nothing -> do
287+
status status404
288+
text "Not Found"
289+
290+
-- Stream request body and count bytes without buffering
291+
countBodyBytes :: Request -> IO Int
292+
countBodyBytes req = go 0
293+
where
294+
go !acc = do
295+
chunk <- getRequestBodyChunk req
296+
if BS.null chunk
297+
then return acc
298+
else go (acc + BS.length chunk)
299+
300+
-- Helper: get query parameter with default
301+
paramWithDefault :: String -> Double -> ActionM Double
302+
paramWithDefault name def = do
303+
mv <- queryParamMaybe (TL.pack name)
304+
case mv of
305+
Nothing -> return def
306+
Just v -> return $ fromMaybe def (readMaybe (TL.unpack v) :: Maybe Double)
307+
308+
-- Convert SQLite row to JSON Value
309+
sqliteRowToValue :: (Int, T.Text, T.Text, Double, Int, Int, T.Text, Double, Int) -> Value
310+
sqliteRowToValue (rid, name, category, price, quantity, active, tagsJson, rScore, rCount) =
311+
let tags = fromMaybe ([] :: [T.Text]) (Aeson.decodeStrict (TE.encodeUtf8 tagsJson))
312+
in object
313+
[ "id" .= rid
314+
, "name" .= name
315+
, "category" .= category
316+
, "price" .= price
317+
, "quantity" .= quantity
318+
, "active" .= (active == 1)
319+
, "tags" .= tags
320+
, "rating" .= object ["score" .= rScore, "count" .= rCount]
321+
]
322+
323+
-- Convert PostgreSQL row to JSON Value
324+
pgRowToValue :: (Int, T.Text, T.Text, Double, Int, Bool, T.Text, Double, Int) -> Value
325+
pgRowToValue (rid, name, category, price, quantity, active, tagsJson, rScore, rCount) =
326+
let tags = fromMaybe ([] :: [Value]) (Aeson.decodeStrict (TE.encodeUtf8 tagsJson))
327+
in object
328+
[ "id" .= rid
329+
, "name" .= name
330+
, "category" .= category
331+
, "price" .= price
332+
, "quantity" .= quantity
333+
, "active" .= active
334+
, "tags" .= tags
335+
, "rating" .= object ["score" .= rScore, "count" .= rCount]
336+
]

frameworks/scotty/meta.json

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{
2+
"display_name": "Scotty",
3+
"language": "Haskell",
4+
"type": "framework",
5+
"engine": "warp",
6+
"description": "Scotty web framework on Warp, a lightweight Haskell framework inspired by Ruby's Sinatra.",
7+
"repo": "https://github.com/scotty-web/scotty",
8+
"enabled": true,
9+
"tests": [
10+
"baseline",
11+
"pipelined",
12+
"noisy",
13+
"limited-conn",
14+
"json",
15+
"upload",
16+
"compression",
17+
"mixed",
18+
"async-db",
19+
"static"
20+
]
21+
}

0 commit comments

Comments
 (0)