mig-0.2.0.1: src/Mig/Core/Server.hs
{-# LANGUAGE UndecidableInstances #-}
-- | Server definition
module Mig.Core.Server (
Server (..),
FindRoute (..),
treeApiStrategy,
plainApiStrategy,
mapServerFun,
mapResponse,
fromServer,
fromServerWithCache,
fillCaptures,
addTag,
setDescription,
setSummary,
mapRouteInfo,
staticFiles,
describeInputs,
atPath,
filterPath,
getServerPaths,
addPathLink,
) where
import Control.Applicative
import Control.Monad
import Control.Monad.IO.Class
import Data.ByteString (ByteString)
import Data.ByteString.Lazy qualified as BL
import Data.List qualified as List
import Data.Map.Strict (Map)
import Data.Map.Strict qualified as Map
import Data.Maybe
import Data.String
import Data.Text (Text)
import Data.Text qualified as Text
import Safe (atMay, headMay)
import System.FilePath (takeExtension)
import Web.HttpApiData
import Mig.Core.Api (Api, fromNormalApi, toNormalApi)
import Mig.Core.Api qualified as Api
import Mig.Core.Api.NormalForm.TreeApi qualified as TreeApi
import Mig.Core.Class.MediaType
import Mig.Core.Class.Response (IsResp (..), Resp (..))
import Mig.Core.Class.Route
import Mig.Core.Server.Cache
import Mig.Core.ServerFun (ServerFun)
import Mig.Core.Types (Request (..), Response, setContent)
import Mig.Core.Types.Info (RouteInfo (..), RouteInput (..), describeInfoInputs, setOutputMedia)
import Mig.Core.Types.Info qualified as Describe (Describe (..))
import Mig.Core.Types.Route
{-| Server type. It is a function fron request to response.
Some servers does not return valid value. We use it to find right path.
Example:
> server :: Server IO
> server =
> "api/v1" /.
> [ "foo" /. handleFoo
> , "bar" /. handleBar
> ]
>
> handleFoo :: Query "name" Int -> Get IO (Resp Json Text)
> handleBar :: Post Json IO Text
Note that server is monoid and it can be constructed with Monoid functions and
path constructor @(/.)@. To pass inputs for handler we can use special newtype wrappers:
* @Query@ - for required query parameters
* @Optional@ - for optional query parameters
* @QueryFlag@ - for boolean query flags
* @Capture@ - for parsing elements of URI
* @Header@ - for parsing headers
* @OptionalHeader@ - for parsing optional headers
* @Body@ - fot request-body input
and other request types.
To distinguish by HTTP-method we use corresponding constructors: Get, Post, Put, etc.
Let's discuss the structure of the constructor. Let's take Get for example:
> type Get m a = Send GET m a
> newtype Send method m a = Send (m a)
Let's look at the arguments of he type
* @method@ - type tag of the HTTP-method (GET, POST, PUT, DELETE, etc.)
* @m@ - underlying server monad
* @a@ - response type. It should be convertible to the type of the response (see @IsResp@ class).
-}
newtype Server m = Server {unServer :: Api (Route m)}
deriving newtype (Semigroup, Monoid)
-- | Applies server function to all routes
mapServerFun :: (ServerFun m -> ServerFun n) -> Server m -> Server n
mapServerFun f (Server server) = Server $ fmap (\x -> Route x.info (f x.run)) server
-- | Mapps response of the server
mapResponse :: (Functor m) => (Response -> Response) -> Server m -> Server m
mapResponse f = mapServerFun $ \fun -> fmap (fmap f) . fun
{-| API route finder strategy. The API can be transformed to some normal form
for faster route lookup. So far we have two normal forms.
One is plain Api type as it is. And another one is tree-structure
where path switches are encoded with Map's.
-}
data FindRoute normalForm m = FindRoute
{ toNormalForm :: Api (Route m) -> normalForm (Route m)
, getPath :: [Text] -> normalForm (Route m) -> Maybe (Route m, Api.CaptureMap)
}
-- | Use TreeApi normal form. Path switches are encoded as Maps.
treeApiStrategy :: FindRoute TreeApi.TreeApi m
treeApiStrategy =
FindRoute
{ toNormalForm = TreeApi.toTreeApi
, getPath = TreeApi.getPath
}
-- | Use plain api type. Just Api type as it is.
plainApiStrategy :: FindRoute Api.Api m
plainApiStrategy =
FindRoute
{ toNormalForm = id
, getPath = Api.getPath
}
{-| Converts server to server function. Server function can be used to implement low-level handlers
in various server-libraries.
-}
fromServer :: forall m normalForm. (Monad m) => FindRoute normalForm m -> Server m -> ServerFun m
fromServer strategy (Server server) = \req -> do
case getRoute req of
Just (routes, captureMap) -> routes.run req{capture = captureMap}
Nothing -> pure Nothing
where
serverNormal = fmap strategy.toNormalForm $ toNormalApi (fillCaptures server)
getRoute req = do
api <- fromNormalApi req.method (getMediaType "Accept" req) (getMediaType "Content-Type" req) serverNormal
strategy.getPath req.path api
getMediaType name req = fromMaybe "*/*" $ Map.lookup name req.headers
{-| Converts server to server function. Server function can be used to implement low-level handlers
in various server-libraries. This function also uses LRU-cache to cache fetching of
the routes
-}
fromServerWithCache :: forall m normalForm. (MonadIO m) => FindRoute normalForm m -> RouteCache m -> Server m -> ServerFun m
fromServerWithCache strategy cache (Server server) = \req -> do
mRoute <- liftIO $ withCache cache getRouteCache (getCacheKey req)
case mRoute of
Just (CacheValue captureMap routes) -> routes.run req{capture = captureMap}
Nothing -> pure Nothing
where
serverNormal = fmap strategy.toNormalForm $ toNormalApi (fillCaptures server)
getRouteCache :: CacheKey -> Maybe (CacheValue m)
getRouteCache key = do
api <- fromNormalApi key.method key.outputType key.inputType serverNormal
uncurry (flip CacheValue) <$> strategy.getPath key.path api
getCacheKey :: Request -> CacheKey
getCacheKey req =
CacheKey
{ inputType = getMediaType "Content-Type"
, outputType = getMediaType "Accept"
, method = req.method
, path = req.path
}
where
getMediaType name = fromMaybe "*/*" $ Map.lookup name req.headers
{-| Substitutes all stars * for corresponding names in captures
if there are more captures in the route than in the path it adds
additional captures from the route to the path
-}
fillCaptures :: Api (Route m) -> Api (Route m)
fillCaptures = go mempty 0
where
go pathSoFar n = \case
Api.WithPath path api ->
let (pathNext, m) = goPath (pathSoFar <> path) n path api
in Api.WithPath pathNext (go (pathSoFar <> path) m api)
Api.Append a b -> Api.Append (go pathSoFar n a) (go pathSoFar n b)
Api.Empty -> Api.Empty
Api.HandleRoute a -> goRoute pathSoFar n a
goPath :: Api.Path -> Int -> Api.Path -> Api (Route m) -> (Api.Path, Int)
goPath pathSoFar n (Api.Path path) api = case path of
[] -> (Api.Path path, n)
Api.CapturePath "*" : rest ->
let (nextRest, m) = goPath pathSoFar (n + 1) (Api.Path rest) api
in case getCaptureName n api of
Just name -> (Api.Path [Api.CapturePath name] <> nextRest, m)
Nothing -> error $ "No capture argument for start in path " <> Text.unpack (toUrlPiece pathSoFar) <> " at the index: " <> show n
a : rest ->
let (nextRest, m) = goPath pathSoFar n (Api.Path rest) api
in (Api.Path [a] <> nextRest, m)
goRoute pathSoFar pathCaptureCount route
| missingCapturesCount > 0 = withMissingCaptures pathSoFar [pathCaptureCount .. routeCaptureCount - 1] (Api.HandleRoute route)
| otherwise = Api.HandleRoute route
where
missingCapturesCount = routeCaptureCount - pathCaptureCount
routeCaptureCount = captureCount route.info
withMissingCaptures pathSoFar indexes route =
Api.WithPath (Api.Path $ Api.CapturePath <$> names) route
where
names =
fromMaybe (error $ "Not enough captures at path: " <> Text.unpack (toUrlPiece pathSoFar)) $
mapM (\index -> getCaptureName index route) indexes
captureCount routeInfo = List.foldl' count 0 routeInfo.inputs
where
count res inp = case inp.content of
CaptureInput _ _ -> 1 + res
_ -> res
getCaptureName :: Int -> Api (Route m) -> Maybe Text
getCaptureName index = \case
Api.Append a _b -> rec a
Api.Empty -> Nothing
Api.WithPath _ a -> rec a
Api.HandleRoute a -> mapMaybe (toCapture . Describe.content) a.info.inputs `atMay` index
where
rec = getCaptureName index
toCapture :: RouteInput -> Maybe Text
toCapture = \case
CaptureInput name _ -> Just name
_ -> Nothing
-- | Adds tag to the route
addTag :: Text -> Server m -> Server m
addTag tag = mapRouteInfo (insertTag tag)
-- | Sets description of the route
setDescription :: Text -> Server m -> Server m
setDescription desc = mapRouteInfo $ \info -> info{description = desc}
-- | Sets summary of the route
setSummary :: Text -> Server m -> Server m
setSummary val = mapRouteInfo $ \info -> info{summary = val}
-- | Maps over route API-information
mapRouteInfo :: (RouteInfo -> RouteInfo) -> Server m -> Server m
mapRouteInfo f (Server srv) = Server $ fmap (\route -> route{info = f route.info}) srv
-- | Adds OpenApi tag to the route
insertTag :: Text -> RouteInfo -> RouteInfo
insertTag tag info = info{tags = tag : info.tags}
{-| Appends descriptiton for the inputs. It passes pairs for @(input-name, input-description)@.
special name request-body is dedicated to request body input
nd raw-input is dedicated to raw input
-}
describeInputs :: [(Text, Text)] -> Server m -> Server m
describeInputs descs = mapRouteInfo (describeInfoInputs descs)
{-| Serves static files. The file path is a path to where to server the file.
The media-type is derived from the extension. There is a special case if we need
to server the file from the rooot of the server we can omit everything from the path
but keep extension. Otherwise it is not able to derive the media type.
It is convenient to use it with function @embedRecursiveDir@ from the library @file-embed@ or @file-embed-lzma@.
-}
staticFiles :: forall m. (MonadIO m) => [(FilePath, ByteString)] -> Server m
staticFiles files =
Server $ foldMap (uncurry serveFile) files
where
serveFile path content =
unServer . mapRouteInfo (setOutputMedia media) . Server $
( if headMay path == Just '.'
then id
else ((fromString path) `Api.WithPath`)
)
(Api.HandleRoute (toRoute (getFile media content)))
where
media = getMediaType path
getFile :: MediaType -> ByteString -> Get m (Resp AnyMedia BL.ByteString)
getFile ty fileContent =
Send $
pure $
addHeaders (setContent ty) $
ok $
BL.fromStrict fileContent
getMediaType :: FilePath -> MediaType
getMediaType path =
fromMaybe "application/octet-stream" $
Map.lookup (takeExtension path) extToMimeMap
extToMimeMap :: Map String MediaType
extToMimeMap =
Map.fromList
[ (".aac", "audio/aac") -- AAC audio
, (".abw", "application/x-abiword") -- AbiWord document
, (".arc", "application/x-freearc") -- Archive document (multiple files embedded)
, (".avif", "image/avif") -- AVIF image
, (".avi", "video/x-msvideo") -- AVI: Audio Video Interleave
, (".azw", "application/vnd.amazon.ebook") -- Amazon Kindle eBook format
, (".bin", "application/octet-stream") -- Any kind of binary data
, (".bmp", "image/bmp") -- Windows OS/2 Bitmap Graphics
, (".bz", "application/x-bzip") -- BZip archive
, (".bz2", "application/x-bzip2") -- BZip2 archive
, (".cda", "application/x-cdf") -- CD audio
, (".csh", "application/x-csh") -- C-Shell script
, (".css", "text/css") -- Cascading Style Sheets (CSS)
, (".csv", "text/csv") -- Comma-separated values (CSV)
, (".doc", "application/msword") -- Microsoft Word
, (".docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document") -- Microsoft Word (OpenXML)
, (".eot", "application/vnd.ms-fontobject") -- MS Embedded OpenType fonts
, (".epub", "application/epub+zip") -- Electronic publication (EPUB)
, (".gz", "application/gzip") -- GZip Compressed Archive
, (".gif", "image/gif") -- Graphics Interchange Format (GIF)
, (".htm", "text/html") -- , .html HyperText Markup Language (HTML)
, (".ico", "image/vnd.microsoft.icon") -- Icon format
, (".ics", "text/calendar") -- iCalendar format
, (".jar", "application/java-archive") -- Java Archive (JAR)
, (".jpeg", "image/jpeg") -- JPEG images
, (".jpg", "image/jpeg") -- JPEG images
, (".js", "text/javascript") -- JavaScript (Specifications: HTML and RFC 9239)
, (".json", "application/json") -- JSON format
, (".jsonld", "application/ld+json") -- JSON-LD format
, (".mid", "audio/midi") -- Musical Instrument Digital Interface (MIDI) , audio/x-midi
, (".midi", "audio/midi") -- Musical Instrument Digital Interface (MIDI) , audio/x-midi
, (".mjs", "text/javascript") -- JavaScript module
, (".mp3", "audio/mpeg") -- MP3 audio
, (".mp4", "video/mp4") -- MP4 video
, (".mpeg", "video/mpeg") -- MPEG Video
, (".mpkg", "application/vnd.apple.installer+xml") -- Apple Installer Package
, (".odp", "application/vnd.oasis.opendocument.presentation") -- OpenDocument presentation document
, (".ods", "application/vnd.oasis.opendocument.spreadsheet") -- OpenDocument spreadsheet document
, (".odt", "application/vnd.oasis.opendocument.text") -- OpenDocument text document
, (".oga", "audio/ogg") -- OGG audio
, (".ogv", "video/ogg") -- OGG video
, (".ogx", "application/ogg") -- OGG
, (".opus", "audio/opus") -- Opus audio
, (".otf", "font/otf") -- OpenType font
, (".png", "image/png") -- Portable Network Graphics
, (".pdf", "application/pdf") -- Adobe Portable Document Format (PDF)
, (".php", "application/x-httpd-php") -- Hypertext Preprocessor (Personal Home Page)
, (".ppt", "application/vnd.ms-powerpoint") -- Microsoft PowerPoint
, (".pptx", "application/vnd.openxmlformats-officedocument.presentationml.presentation") -- Microsoft PowerPoint (OpenXML)
, (".rar", "application/vnd.rar") -- RAR archive
, (".rtf", "application/rtf") -- Rich Text Format (RTF)
, (".sh", "application/x-sh") -- Bourne shell script
, (".svg", "image/svg+xml") -- Scalable Vector Graphics (SVG)
, (".tar", "application/x-tar") -- Tape Archive (TAR)
, (".tif", "image/tiff") -- Tagged Image File Format (TIFF)
, (".tiff", "image/tiff") -- Tagged Image File Format (TIFF)
, (".ts", "video/mp2t") -- MPEG transport stream
, (".ttf", "font/ttf") -- TrueType Font
, (".txt", "text/plain") -- Text, (generally ASCII or ISO 8859-n)
, (".vsd", "application/vnd.visio") -- Microsoft Visio
, (".wav", "audio/wav") -- Waveform Audio Format
, (".weba", "audio/webm") -- WEBM audio
, (".webm", "video/webm") -- WEBM video
, (".webp", "image/webp") -- WEBP image
, (".woff", "font/woff") -- Web Open Font Format (WOFF)
, (".woff2", "font/woff2") -- Web Open Font Format (WOFF)
, (".xhtml", "application/xhtml+xml") -- XHTML
, (".xls", "application/vnd.ms-excel") -- Microsoft Excel
, (".xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") -- Microsoft Excel (OpenXML)
, (".xml", "application/xml") -- XML is recommended as of RFC 7303 (section 4.1), but text/xml is still used sometimes. You can assign a specific MIME type to a file with .xml extension depending on how its contents are meant to be interpreted. For instance, an Atom feed is application/atom+xml, but application/xml serves as a valid default.
, (".xul", "application/vnd.mozilla.xul+xml") -- XUL
, (".zip", "application/zip") -- ZIP archive
, (".3gp", "video/3gpp") -- 3GPP audio/video container ; audio/3gpp if it doesn't contain video
, (".3g2", "video/3gpp2") -- 3GPP2 audio/video container ; audio/3gpp2 if it doesn't contain video
, (".7z", "application/x-7z-compressed") -- 7-zip archive
]
{- i wonder what is analog of this function?
-- | Handle errors
handleError ::(Exception a, MonadCatch m) => (a -> Server m) -> Server m -> Server m
handleError handler (Server act) = Server $ \req ->
(act req) `catch` (\err -> unServer (handler err) req)
-}
{-| Sub-server for a server on given path
it might be usefule to emulate links from one route to another within the server
or reuse part of the server inside another server.
-}
atPath :: forall m. Api.Path -> Server m -> Server m
atPath rootPath rootServer = maybe mempty Server $ find rootPath rootServer.unServer
where
find :: Api.Path -> Api (Route m) -> Maybe (Api (Route m))
find (Api.Path path) server = case path of
[] -> Just server
_ ->
case server of
Api.Empty -> Nothing
Api.HandleRoute _ -> Nothing
Api.Append a b -> find (Api.Path path) a <|> find (Api.Path path) b
Api.WithPath (Api.Path pathB) serverB ->
flip find serverB =<< matchPath pathB path
matchPath :: [Api.PathItem] -> [Api.PathItem] -> Maybe Api.Path
matchPath prefix path = case prefix of
[] -> Just (Api.Path path)
prefixHead : prefixTail -> do
(pathHead, pathTail) <- List.uncons path
guard (prefixHead == pathHead)
matchPath prefixTail pathTail
filterPath :: (Api.Path -> Bool) -> Server m -> Server m
filterPath cond (Server a) =
Server (Api.fromFlatApi $ filter (cond . fst) $ Api.flatApi a)
-- | Returns a list of all paths in the server
getServerPaths :: Server m -> [Api.Path]
getServerPaths (Server a) = fmap fst $ Api.flatApi (fillCaptures a)
{-| Links one route of the server to another
so that every call to first path is redirected to the second path
-}
addPathLink :: Api.Path -> Api.Path -> Server m -> Server m
addPathLink from to server =
server <> Server (Api.WithPath from (atPath to server).unServer)