|
| 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 | + ] |
0 commit comments