packages feed

mig-0.2.1.0: src/Mig/Core/Class/Url.hs

module Mig.Core.Class.Url (
  Url (..),
  UrlOf,
  renderUrl,
  ToUrl (..),
) where

import Data.Aeson (ToJSON (..))
import Data.Bifunctor
import Data.Kind
import Data.Map.Strict (Map)
import Data.Map.Strict qualified as Map
import Data.Maybe
import Data.Proxy
import Data.String
import Data.Text (Text)
import Data.Text qualified as Text
import GHC.TypeLits
import Mig.Core.Api (Path (..), PathItem (..), flatApi, fromFlatApi)
import Mig.Core.Class.Route (Route (..))
import Mig.Core.Server (Server (..), getServerPaths)
import Mig.Core.Types.Info (RouteInfo, routeHasCapture, routeHasOptionalQuery, routeHasQuery, routeHasQueryFlag)
import Mig.Core.Types.Pair
import Mig.Core.Types.Route
import Safe (headMay)
import Web.HttpApiData

-- | Url-template type.
data Url = Url
  { path :: Path
  -- ^ relative path
  , queries :: [(Text, Text)]
  -- ^ queries in the URL
  , captures :: Map Text Text
  -- ^ map of captures
  }

instance ToJSON Url where
  toJSON = toJSON . renderUrl @Text

{-| Render URL to string-like value.

TODO: use Text.Builder
-}
renderUrl :: (IsString a) => Url -> a
renderUrl url =
  fromString $ Text.unpack $ appendQuery $ mappend "/" $ Text.intercalate "/" $ fmap fromPathItem url.path.unPath
  where
    fromPathItem :: PathItem -> Text
    fromPathItem = \case
      StaticPath text -> text
      CapturePath name -> fromMaybe ("{" <> name <> "}") $ Map.lookup name url.captures

    appendQuery = case url.queries of
      [] -> id
      _ -> \res -> mconcat [res, "?", query]

    query = Text.intercalate "&" $ fmap (\(name, val) -> mconcat [name, "=", val]) url.queries

-------------------------------------------------------------------------------------
-- render routes to safe URLs

-- | Converts route type to URL function
type family UrlOf a :: Type where
  UrlOf (Send method m a) = Url
  UrlOf (Query name value -> b) = (Query name value -> UrlOf b)
  UrlOf (Optional name value -> b) = (Optional name value -> UrlOf b)
  UrlOf (Capture name value -> b) = (Capture name value -> UrlOf b)
  UrlOf (QueryFlag name -> b) = (QueryFlag name -> UrlOf b)
  UrlOf (Header name value -> b) = UrlOf b
  UrlOf (OptionalHeader name value -> b) = UrlOf b
  UrlOf (Body media value -> b) = UrlOf b
  UrlOf (Cookie value -> b) = UrlOf b
  UrlOf (PathInfo -> b) = UrlOf b
  UrlOf (FullPathInfo -> b) = UrlOf b
  UrlOf (RawRequest -> b) = UrlOf b
  UrlOf (IsSecure -> b) = UrlOf b
  UrlOf (a, b) = (UrlOf a, UrlOf b)
  UrlOf (a, b, c) = (UrlOf a, UrlOf b, UrlOf c)
  UrlOf (a, b, c, d) = (UrlOf a, UrlOf b, UrlOf c, UrlOf d)
  UrlOf (a, b, c, d, e) = (UrlOf a, UrlOf b, UrlOf c, UrlOf d, UrlOf e)
  UrlOf (a, b, c, d, e, f) = (UrlOf a, UrlOf b, UrlOf c, UrlOf d, UrlOf e, UrlOf f)
  UrlOf (a :| b) = UrlOf a :| UrlOf b

{-| Converts server to safe url. We can use it to generate
safe URL constructors to be used in HTML templates
An example of how we can create safe URL's. Note
that order of URL's should be the same as in server definition:

> type GreetingRoute = Get Html
> type BlogPostRoute = Optional "id" BlogPostId -> Get Html
> type ListPostsRoute = Get Html
>
> data Routes = Routes
>   { greeting :: GreetingRoute
>   , blogPost :: BlogPostRoute
>   , listPosts :: ListPostsRoute
>   }
>
> -- URLs
>
> data Urls = Urls
>   { greeting :: UrlOf GreetingRoute
>   , blogPost :: UrlOf BlogPostRoute
>   , listPosts :: UrlOf ListPostsRoute
>   }
>
> {\-| Site URL's
> URL's should be listed in the same order as they appear in the server
> -\}
> urls :: Urls
> urls = Urls{..}
>   where
>     greeting
>       :| blogPost
>       :| listPosts
>         toUrl (server undefined)
-}
class ToUrl a where
  toUrl :: Server m -> a
  mapUrl :: (Url -> Url) -> a -> a
  urlArity :: Int

instance (ToUrl a, ToUrl b) => ToUrl (a :| b) where
  toUrl api = a :| b
    where
      (a, b) = toUrl api
  mapUrl f (a :| b) = (mapUrl f a :| mapUrl f b)
  urlArity = urlArity @(a, b)

instance (ToUrl a, ToUrl b) => ToUrl (a, b) where
  toUrl (Server api) = (toUrl (Server apiA), toUrl (Server apiB))
    where
      (apiA, apiB) = bimap fromFlatApi fromFlatApi $ Prelude.splitAt (urlArity @a) (flatApi api)

  mapUrl f (a, b) = (mapUrl f a, mapUrl f b)
  urlArity = urlArity @a + urlArity @b

instance (ToUrl a, ToUrl b, ToUrl c) => ToUrl (a, b, c) where
  toUrl server = fromPair $ toUrl @(a, (b, c)) server
    where
      fromPair (a, (b, c)) = (a, b, c)

  mapUrl f (a, b, c) = (mapUrl f a, mapUrl f b, mapUrl f c)
  urlArity = urlArity @a + urlArity @b + urlArity @c

instance (ToUrl a, ToUrl b, ToUrl c, ToUrl d) => ToUrl (a, b, c, d) where
  toUrl server = fromPair $ toUrl @(a, (b, c, d)) server
    where
      fromPair (a, (b, c, d)) = (a, b, c, d)

  mapUrl f (a, b, c, d) = (mapUrl f a, mapUrl f b, mapUrl f c, mapUrl f d)
  urlArity = urlArity @a + urlArity @b + urlArity @c + urlArity @d

instance ToUrl Url where
  toUrl server = case getServerPaths server of
    url : _ -> Url url [] mempty
    _ -> Url mempty mempty mempty

  mapUrl f a = f a
  urlArity = 1

-- query

instance (KnownSymbol sym, ToHttpApiData a, ToUrl b) => ToUrl (Query sym a -> b) where
  toUrl server = \(Query val) ->
    whenOrError (hasQuery (getName @sym) server) noQuery $
      mapUrl (insertQuery (getName @sym) (toUrlPiece val)) (toUrl @b server)
    where
      noQuery = noInputMessage ("query with name: " <> getName @sym) server

  mapUrl f a = \query -> mapUrl f (a query)
  urlArity = urlArity @b

insertQuery :: Text -> Text -> Url -> Url
insertQuery name val url = url{queries = (name, val) : url.queries}

hasQuery :: Text -> Server m -> Bool
hasQuery name = hasInput (routeHasQuery name)

-- optional query

instance (KnownSymbol sym, ToHttpApiData a, ToUrl b) => ToUrl (Optional sym a -> b) where
  toUrl server = \(Optional mVal) ->
    whenOrError (hasOptionalQuery (getName @sym) server) noOptionalQuery $
      mapUrl (maybe id (insertQuery (getName @sym) . toUrlPiece) mVal) (toUrl @b server)
    where
      noOptionalQuery = noInputMessage ("optional query with name: " <> getName @sym) server

  mapUrl f a = \query -> mapUrl f (a query)
  urlArity = urlArity @b

hasOptionalQuery :: Text -> Server m -> Bool
hasOptionalQuery name = hasInput (routeHasOptionalQuery name)

-- query flag

instance (KnownSymbol sym, ToUrl b) => ToUrl (QueryFlag sym -> b) where
  toUrl server = \(QueryFlag val) ->
    whenOrError (hasQueryFlag (getName @sym) server) noQueryFlag $
      mapUrl (insertQuery (getName @sym) (toUrlPiece val)) (toUrl @b server)
    where
      noQueryFlag = noInputMessage ("query flag with name: " <> getName @sym) server

  mapUrl f a = \query -> mapUrl f (a query)
  urlArity = urlArity @b

hasQueryFlag :: Text -> Server m -> Bool
hasQueryFlag name = hasInput (routeHasQueryFlag name)

-- capture

instance (KnownSymbol sym, ToHttpApiData a, ToUrl b) => ToUrl (Capture sym a -> b) where
  toUrl server = \(Capture val) ->
    whenOrError (hasCapture (getName @sym) server) noCapture $
      mapUrl (insertCapture (getName @sym) (toUrlPiece val)) (toUrl @b server)
    where
      noCapture = noInputMessage ("Capture with name: " <> getName @sym) server

  mapUrl f a = \capture -> mapUrl f (a capture)
  urlArity = urlArity @b

insertCapture :: Text -> Text -> Url -> Url
insertCapture name val url = url{captures = Map.insert name val url.captures}

hasCapture :: Text -> Server m -> Bool
hasCapture name = hasInput (routeHasCapture name)

-------------------------------------------------------------------------------------
-- utils

getName :: forall sym a. (KnownSymbol sym, IsString a) => a
getName = fromString (symbolVal (Proxy @sym))

hasInput :: (RouteInfo -> Bool) -> Server m -> Bool
hasInput check (Server api) =
  maybe False (check . (.info) . snd) $ headMay $ flatApi api

noInputMessage :: String -> Server m -> String
noInputMessage item (Server api) =
  unlines
    [ unwords ["Server has no", item, "at route", route]
    , "Check the order of routes on the left side of toUrl expression"
    ]
  where
    route = maybe "unknown" (Text.unpack . toUrlPiece . fst) $ headMay (flatApi api)

whenOrError :: Bool -> String -> a -> a
whenOrError cond message a
  | cond = a
  | otherwise = error message