Skip to content

Commit

Permalink
ditched redundant type parameter, CAPMANY
Browse files Browse the repository at this point in the history
  • Loading branch information
blankhart committed Aug 10, 2019
1 parent 85b4d72 commit 17cc298
Show file tree
Hide file tree
Showing 7 changed files with 101 additions and 56 deletions.
42 changes: 23 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@

## Introduction

This is a routing library for front-end web applications. It provides a Servant-style interface to the functionality in [purescript-routing](https://github.com/slamdata/purescript-routing) and [purescript-routing-duplex](https://github.com/natefaubion/purescript-routing-duplex), but abstracting away the parsing and printing of URLs.
This is a routing library for front-end web applications. It provides a [Servant](https://github.com/haskell-servant/servant)-style interface to the functionality in [purescript-routing](https://github.com/slamdata/purescript-routing) and [purescript-routing-duplex](https://github.com/natefaubion/purescript-routing-duplex), but abstracting away the parsing and printing of URLs.

This project is a port of the Servant-style router used in [Miso](https://github.com/dmjio/miso), a Haskell web framework, with Purescript adaptations. This library currently uses some of the combinators from [purescript-servant](https://github.com/f-o-a-m/purescript-servant) and some of its own based on Miso. The [purescript-trout](https://github.com/owickstrom/purescript-trout) client-server HTTP library uses similar combinators. Both `purescript-trout` and the [purescript-kushikatsu](https://github.com/justinwoo/purescript-kushikatsu) router allow named endpoints, through different DSLs.
This project is inspired by the Servant-style router used in [Miso](https://github.com/dmjio/miso), a Haskell web framework.

The library is intended to be framework-independent. Some goals are: (i) to permit nested routes in typelevel APIs; (ii) to allow users to declare named endpoints, and then associate them with linkers and handlers using Purescript's record system; and (iii) to leverage Purescript's custom error types to validate web APIs (still in progress). The project is currently in an experimental state and may be restructured in the future to accommodate different approaches to defining typelevel APIs.
This library currently uses some combinators from [purescript-servant](https://github.com/f-o-a-m/purescript-servant). The [purescript-trout](https://github.com/owickstrom/purescript-trout) client-server HTTP library uses similar combinators. The [purescript-kushikatsu](https://github.com/justinwoo/purescript-kushikatsu) router uses a different DSL involving typelevel strings (and exposes a different API to users). Future releases of this library may be based on a native DSL and adapters for compatibility with these libraries.

Some goals of this project are: (i) to allow easy integration with various web frameworks, (ii) to permit users to define web APIs with nested routes; and (iii) ultimately, to validate web APIs at the type level with custom type errors. The project is currently in an experimental state and may be significantly restructured in the future.

## Installation

Expand All @@ -24,11 +26,12 @@ yarn run:example # run the example
Usage starts by defining a typelevel API with named endpoints, as illustrated in `tests/Test/Main.purs`.

```purescript
type ReadmeApi page =
S "profile" :> CAP "username" String :> VIEW "profile" page
:<|> S "article"
:> (CAP "id" Int :> VIEW "article_id" page
:<|> S "search" :> QPs ( term :: Required String ) :> VIEW "article_search" page)
type ReadmeApi =
S "profile" :> CAP "username" String :> VIEW "profile"
:<|> S "article"
:> (CAP "id" Int :> VIEW "article_id"
:<|> S "search" :> QPs ( term :: Required String ) :> VIEW "article_search")
:<|> S "figures" :> CAPMANY "figures" Int :> VIEW "figures"
```

Each endpoint of the typelevel API must terminate in a `VIEW name page` combinator. The endpoint is uniquely specified by the `name` symbol. The handler for the endpoint must produce values of type `page`. For example, in an Elm-like framework, the `page` type might be the framework's equivalent of an `Html Msg` (see the example for an variation on this).
Expand All @@ -38,32 +41,33 @@ The library uses the typelevel API to produce the following functions:
* `Servant.Routing.Routable.mkRoutable`. This is a smart constructor used to turn the user's API into a `Routable api`. This is just a proxy for a normalized version of the type in which nested alternatives have been removed and all type operators associate to the right.

```purescript
let api = mkRoutable (RouteProxy :: RouteProxy (ExampleApi String))
let api = mkRoutable (RouteProxy :: _ ReadmeApi)
```

* `Servant.Routing.HasRouter.route`. This runs a user-supplied record of handlers for each named endpoint over a `uri`. Each endpoint `name` in the API specifies the field label for the corresponding handler. The endpoint's type fully determines the type of the handler. A handler may accept capture and query parameter arguments and must return a `page`. A `uri` can be any type with a `Servant.Routing.Location.ToLocation` instance (such as a `String`).

```purescript
let handlers =
{ profile : \username -> "Profile for " <> username
, article_id : \(id :: Int) -> "Article #" <> show id
, article_search: \{ term : Required s } -> "Searched for " <> s
}
assert $ route api handlers "/profile/blankhart" === Right "Profile for blankhart"
let handlers =
{ profile : \username -> "Profile for " <> username
, article_id : \(id :: Int) -> "Article #" <> show id
, article_search: \{ term : Required s } -> "Searched for " <> s
}
assert $ route api handlers "/profile/blankhart" === Right "Profile for blankhart"
```

* `Servant.Routing.HasLinks.allLinksWith`. This generates a record of safe link generators to the named endpoints. The link generators may accept capture and query parameter arguments and by default return a `String` representing the endpoint's URL. The default can be modified by passing in `allLinksWith` a function of type `Link -> a`, where `Link` is (currently) an alias for `String`. These functions could be used to generate messages interpreted by a web framework.

```purescript
let links = allLinksWith identity api
assert $ links.profile "blankhart" === "/profile/blankhart"
let links = allLinksWith identity api
assert $ links.profile "blankhart" === "/profile/blankhart"
assert $ links.figures [1, 2, 3] === "/figures/1/2/3"
```

These functions should satisfy the property that, for each endpoint in the user's API, running `route` on the link produced by `allLinks` always produces the same `page` as the corresponding handler.

```purescript
quickCheck $ \username ->
route api handlers (links.profile username) === Right (handlers.profile username)
quickCheck $ \username ->
route api handlers (links.profile username) === Right (handlers.profile username)
```

## Examples
Expand Down
14 changes: 6 additions & 8 deletions example/Main.purs
Original file line number Diff line number Diff line change
Expand Up @@ -37,18 +37,16 @@ data Message = Navigate UrlWrapper
-- Our routes are defined through a Servant-style typelevel API.
-- Nested routes are permitted where they make sense, as in this example
-- which branches after "posts".
type NestedApi page =
VIEW "index" page
:<|> S "posts" :> ( QPs ( sortBy :: Maybe String ) :> VIEW "postIndex" page
:<|> CAP "id" Int :> S "edit" :> VIEW "postEdit" page)
type ExampleApi =
VIEW "index"
:<|> S "posts" :> ( QPs ( sortBy :: Maybe String ) :> VIEW "postIndex"
:<|> CAP "id" Int :> S "edit" :> VIEW "postEdit")

-- Link and handler records that work with this API have the type (for some 'a')
-- { index :: a, postIndex :: { sortBy :: Maybe String } -> a, postEdit :: Int -> a }

-- The endpoints will return a renderer so that it can be cached. I.e.,
-- Here, the endpoints will return a renderer so that it can be cached. I.e.,
-- when the router is run against a URL, it returns the rendering function
-- that should be used to draw the application.
type ExampleApi = NestedApi Renderer

-- The dashboard is the basic layout common to all routes in this example.
dashboard :: Array String -> String -> Renderer
Expand All @@ -70,7 +68,7 @@ component :: H.Component Unit
component = H.component "Router" { initialize, update, render, subscriptions }
where
-- Canonicalize the typelevel API, which undoes nesting.
api = mkRoutable (RouteProxy :: RouteProxy ExampleApi)
api = mkRoutable (RouteProxy :: _ ExampleApi)

-- Define links to the endpoints in the API.
urls = allLinks api
Expand Down
9 changes: 8 additions & 1 deletion src/Servant/Routing/API.purs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@ import Servant.API (kind Route)
foreign import data ALT :: Route -> Route -> Route
infixr 3 type ALT as :<|>

--------------------------------------------------------------------------------
-- CAPMANY (combinator)
--------------------------------------------------------------------------------

-- | 'Capture all' combinator.
foreign import data CAPMANY :: Symbol -> Type -> Route

--------------------------------------------------------------------------------
-- VIEW (combinator)
--------------------------------------------------------------------------------
Expand All @@ -22,7 +29,7 @@ infixr 3 type ALT as :<|>
-- | various Elm-likes. That is, the combinator to use in building the API would
-- | (or could) be something like 'Page (View Action)'.
-- | TODO: Custom type error if Page :> _ or if QPs :> a and a /~ Page
foreign import data VIEW :: Symbol -> Type -> Route
foreign import data VIEW :: Symbol -> Route

--------------------------------------------------------------------------------
-- NIL (internal combinator)
Expand Down
42 changes: 27 additions & 15 deletions src/Servant/Routing/HasLinks.purs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import Servant.Routing.API
import Servant.Routing.IsEndpoint (class IsEndpoint)
import Servant.Routing.Routable (Routable)

import Data.Foldable (class Foldable, foldl)
import Data.Symbol (SProxy(..), class IsSymbol, reflectSymbol)
import Heterogeneous.Folding (class FoldlRecord)
import Prim.Row as Row
Expand Down Expand Up @@ -38,7 +39,7 @@ allLinksWith
=> (Link -> action)
-> Routable layout
-> links
allLinksWith toA _ = mkLinks (RouteProxy :: RouteProxy layout) toA ""
allLinksWith toAction _ = mkLinks (RouteProxy :: RouteProxy layout) toAction ""

--------------------------------------------------------------------------------
-- HasLinks
Expand All @@ -56,8 +57,8 @@ instance hasLinksPathAltNil
, IsSymbol name
)
=> HasLinks (endpoint :<|> NIL) action (Record links) where
mkLinks _ toA link = Record.insert (SProxy :: SProxy name)
(mkLinks (RouteProxy :: RouteProxy endpoint) toA link)
mkLinks _ toAction link = Record.insert (SProxy :: _ name)
(mkLinks (RouteProxy :: _ endpoint) toAction link)
{}

else instance hasLinksPathAltCons
Expand All @@ -69,19 +70,19 @@ else instance hasLinksPathAltCons
, HasLinks sublayout action (Record sublinks)
)
=> HasLinks (endpoint :<|> sublayout) action (Record links) where
mkLinks _ toA link = Record.insert (SProxy :: SProxy name)
(mkLinks (RouteProxy :: RouteProxy endpoint) toA link)
(mkLinks (RouteProxy :: RouteProxy sublayout) toA link)
mkLinks _ toAction link = Record.insert (SProxy :: _ name)
(mkLinks (RouteProxy :: _ endpoint) toAction link)
(mkLinks (RouteProxy :: _ sublayout) toAction link)

-- | Path Component
else instance hasLinksPathComponent
:: ( HasLinks sublayout action links
, IsSymbol s
)
=> HasLinks (S s :> sublayout) action links where
mkLinks _ toA link =
mkLinks (RouteProxy :: RouteProxy sublayout) toA
(appendPathSegment (reflectSymbol $ SProxy :: SProxy s) link)
mkLinks _ toAction link =
mkLinks (RouteProxy :: _ sublayout) toAction
(appendPathSegment (reflectSymbol $ SProxy :: _ s) link)

-- | Capture
else instance hasLinksCapture
Expand All @@ -90,22 +91,33 @@ else instance hasLinksCapture
, ToCapture a
)
=> HasLinks (CAP s a :> sublayout) action (a -> links) where
mkLinks _ toA link = \a ->
mkLinks (RouteProxy :: RouteProxy sublayout) toA
mkLinks _ toAction link = \a ->
mkLinks (RouteProxy :: _ sublayout) toAction
(appendPathSegment (toCapture a) link)

-- | CaptureMany
else instance hasLinksCaptureMany
:: ( HasLinks sublayout action links
, IsSymbol s
, ToCapture a
)
=> HasLinks (CAPMANY s a :> sublayout) action (Array a -> links) where
mkLinks _ toAction link = \captures ->
mkLinks (RouteProxy :: _ sublayout) toAction
(foldl (\l c -> appendPathSegment (toCapture c) l) link captures)

-- | QueryParam
else instance hasLinksQueryParam
:: ( HasLinks sublayout action links
, RowToList params paramsRL
, FoldlRecord QueryParamEntry (Array QueryParam) paramsRL params (Array QueryParam)
)
=> HasLinks (QPs params :> sublayout) action (Record params -> links) where
mkLinks _ toA link = \params ->
mkLinks (RouteProxy :: RouteProxy sublayout) toA
mkLinks _ toAction link = \params ->
mkLinks (RouteProxy :: _ sublayout) toAction
(appendQueryString (formatQueryString (QueryParams params)) link)

-- | View
else instance hasLinksView
:: ( IsSymbol sym ) => HasLinks (VIEW sym view) action action where
mkLinks _ toA link = toA link
:: ( IsSymbol sym ) => HasLinks (VIEW sym) action action where
mkLinks _ toAction link = toAction link
17 changes: 16 additions & 1 deletion src/Servant/Routing/HasRouter.purs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import Data.Array as Array
import Data.Either (Either(..), note)
import Data.Maybe (Maybe(..))
import Data.Symbol (SProxy(..), class IsSymbol, reflectSymbol)
import Data.Traversable (traverse)
import Heterogeneous.Folding (class HFoldlWithIndex, hfoldlWithIndex)
import Prim.Row as Row
import Prim.RowList (kind RowList, class RowToList)
Expand All @@ -28,6 +29,7 @@ import Type.Data.RowList (RLProxy(..))
data Router page
= RAlt (Router page) (Router page)
| RCapture (String -> Maybe (Router page))
| RCaptureMany (Array String -> Maybe (Router page))
| RQueryParam (QueryPairs -> Maybe (Router page))
| RPathComponent String (Router page)
| RView page
Expand Down Expand Up @@ -85,6 +87,16 @@ else instance hasRouterCapture
mkRouter _ capture = RCapture $ fromCapture >=> \a ->
pure $ mkRouter (RouteProxy :: RouteProxy sublayout) (capture a)

-- | CaptureMany
else instance hasRouterCaptureMany
:: ( HasRouter sublayout page handler
, IsSymbol s
, FromCapture a
)
=> HasRouter (CAPMANY s a :> sublayout) page (Array a -> handler) where
mkRouter _ captureMany = RCaptureMany $ traverse fromCapture >=> \arr ->
pure $ mkRouter (RouteProxy :: _ sublayout) (captureMany arr)

-- | QueryParam
else instance hasRouterQueryParam
:: ( HasRouter sublayout page handler
Expand All @@ -99,7 +111,7 @@ else instance hasRouterQueryParam

-- | VIEW
else instance hasRouterView
:: ( IsSymbol sym ) => HasRouter (VIEW sym view) page page where
:: ( IsSymbol sym ) => HasRouter (VIEW sym) page page where
mkRouter _ page = RView page

--------------------------------------------------------------------------------
Expand All @@ -117,6 +129,9 @@ routeLoc location@(Location loc) r = case r of
path <- Array.uncons loc.locPath
router <- capture path.head
routeLoc (Location $ loc { locPath = path.tail }) router
RCaptureMany captureMany -> do
router <- captureMany loc.locPath
routeLoc (Location $ loc { locPath = [] }) router
RQueryParam interpret -> do
router <- interpret (QueryPairs loc.locQuery)
routeLoc (Location $ loc { locQuery = [] }) router
Expand Down
4 changes: 2 additions & 2 deletions src/Servant/Routing/IsEndpoint.purs
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,10 @@ import Servant.Routing.API

-- | Example:
-- | name :: forall e n . IsEndpoint e n => IsSymbol n => RouteProxy e -> String
-- | name _ = reflectSymbol (SProxy :: SProxy n)
-- | name _ = reflectSymbol (SProxy :: _ n)
class IsEndpoint (route :: Route) (name :: Symbol) | route -> name

instance viewIsEndpoint :: IsEndpoint (VIEW name view) name
instance viewIsEndpoint :: IsEndpoint (VIEW name) name

else instance seqIsEndpoint
:: ( IsEndpoint b s )
Expand Down
29 changes: 19 additions & 10 deletions test/Test/Main.purs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ module Test.Main where
import Prelude
import Effect (Effect)
import Data.Either (Either(..))
import Data.String (joinWith)
import Data.Tuple (Tuple(..))
import Effect.Console (log)
import Servant.API
import Servant.Routing
import Test.StrongCheck
Expand All @@ -30,9 +32,9 @@ derive newtype instance bArbitrary :: Arbitrary B
-- purescript-routing tests begin

type MyRoutes =
S "foo" :> CAP "foo" N :> QPs ( welp :: Required String, b :: Required B ) :> VIEW "foo" String
:<|> S "bar" :> CAP "bar" B :> QPs ( bar :: Required String ) :> VIEW "bar" String
:<|> S "corge" :> CAP "corge" String :> VIEW "corge" String
S "foo" :> CAP "foo" N :> QPs ( welp :: Required String, b :: Required B ) :> VIEW "foo"
:<|> S "bar" :> CAP "bar" B :> QPs ( bar :: Required String ) :> VIEW "bar"
:<|> S "corge" :> CAP "corge" String :> VIEW "corge"

foo :: N -> { welp :: Required String, b :: Required B } -> String
foo (N f) { welp : Required w, b : Required (B b) } =
Expand Down Expand Up @@ -69,26 +71,30 @@ checkPurescriptRoutingTests = do

-- README tests

type ReadmeApi page =
S "profile" :> CAP "username" String :> VIEW "profile" page
type ReadmeApi =
S "profile" :> CAP "username" String :> VIEW "profile"
:<|> S "article"
:> (CAP "id" Int :> VIEW "article_id" page
:<|> S "search" :> QPs ( term :: Required String ) :> VIEW "article_search" page)
:> (CAP "id" Int :> VIEW "article_id"
:<|> S "search" :> QPs ( term :: Required String ) :> VIEW "article_search")
:<|> S "figures" :> CAPMANY "figures" Int :> VIEW "figures"

checkReadMeTests :: Effect Unit
checkReadMeTests = do

let api = mkRoutable (RouteProxy :: RouteProxy (ReadmeApi String))
let api = mkRoutable (RouteProxy :: _ ReadmeApi)

let handlers =
{ profile : \username -> "Profile for " <> username
, article_id : \(id :: Int) -> "Article #" <> show id
, article_search: \{ term : Required s } -> "Searched for " <> s
, figures: \arr -> "Figures: " <> joinWith ", " (show <$> arr)
}
assert $ route api handlers "/profile/blankhart" === Right "Profile for blankhart"
assert $ route api handlers "/figures/1/2/3/4" === Right "Figures: 1, 2, 3, 4"

let links = allLinksWith identity api
assert $ links.profile "blankhart" === "/profile/blankhart"
assert $ links.figures [1, 2, 3] === "/figures/1/2/3"

quickCheck $ \username ->
route api handlers (links.profile username) === Right (handlers.profile username)
Expand All @@ -97,5 +103,8 @@ checkReadMeTests = do

main :: Effect Unit
main = do
checkPurescriptRoutingTests
log "README tests"
checkReadMeTests
log "purescript-routing tests"
checkPurescriptRoutingTests

0 comments on commit 17cc298

Please sign in to comment.