diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ce25bb8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +/bower_components/ +/node_modules/ +/.pulp-cache/ +/output/ +/generated-docs/ +/.psc* +/.psa* diff --git a/README.md b/README.md new file mode 100644 index 0000000..2358fde --- /dev/null +++ b/README.md @@ -0,0 +1,18 @@ +# purescript-hyper-routing-server + +The `purescript-hyper-routing-server` lets you build Hyper web servers on top +of the [purescript-hyper-routing](https://github.com/owickstrom/purescript-hyper-routing) +API for routing types. + +## Usage + +For the documentation on how to use this package, please head over to the +extensions section in the Hyper documentation, and the part on [Servers for +Routing +Types](http://hyper.wickstrom.tech/extensions/type-level-routing/servers-for-routing-types.html). + +There are also [runnable examples in this repository](examples/). + +## API Documentation + +This library's API documentation is published [on Pursuit](https://pursuit.purescript.org/packages/purescript-hyper-routing-server). diff --git a/bower.json b/bower.json new file mode 100644 index 0000000..cc3a786 --- /dev/null +++ b/bower.json @@ -0,0 +1,25 @@ +{ + "name": "purescript-hyper-routing-server", + "license": "MPL-2.0", + "repository": { + "type": "git", + "url": "git://github.com/owickstrom/purescript-hyper-routing-server.git" + }, + "ignore": [ + "**/.*", + "node_modules", + "bower_components", + "output" + ], + "dependencies": { + "purescript-prelude": "^2.5.0", + "purescript-console": "^2.0.0", + "purescript-hyper": "^0.4.2", + "purescript-hyper-routing": "^0.4.2" + }, + "devDependencies": { + "purescript-psci-support": "^2.0.0", + "purescript-spec": "^0.12.4", + "purescript-spec-discovery": "^0.4.0" + } +} diff --git a/examples/Routing.purs b/examples/Routing.purs new file mode 100644 index 0000000..de6e0f4 --- /dev/null +++ b/examples/Routing.purs @@ -0,0 +1,107 @@ +module Examples.Routing where + +import Prelude +import Control.IxMonad ((:*>)) +import Control.Monad.Aff (Aff) +import Control.Monad.Aff.AVar (AVAR) +import Control.Monad.Eff (Eff) +import Control.Monad.Eff.Console (CONSOLE) +import Control.Monad.Eff.Exception (EXCEPTION) +import Control.Monad.Error.Class (throwError) +import Control.Monad.Except (ExceptT) +import Data.Argonaut (class EncodeJson, gEncodeJson, jsonEmptyObject, (:=), (~>)) +import Data.Array (find, (..)) +import Data.Foldable (traverse_) +import Data.Generic (class Generic) +import Data.Maybe (Maybe(..), maybe) +import Data.MediaType.Common (textHTML) +import Hyper.Node.Server (defaultOptions, runServer) +import Hyper.Response (closeHeaders, contentType, respond, writeStatus) +import Hyper.Routing (type (:/), type (:<|>), type (:>), Capture, (:<|>)) +import Hyper.Routing.ContentType.HTML (class EncodeHTML, HTML, linkTo) +import Hyper.Routing.ContentType.JSON (JSON) +import Hyper.Routing.Links (linksTo) +import Hyper.Routing.Method (Get) +import Hyper.Routing.Router (RoutingError(..), router) +import Hyper.Status (statusNotFound) +import Node.HTTP (HTTP) +import Text.Smolder.HTML (h1, li, nav, p, section, ul) +import Text.Smolder.Markup (text) +import Type.Proxy (Proxy(..)) + +type PostID = Int + +newtype Post = Post { id :: PostID + , title :: String + } + +derive instance genericPost :: Generic Post + +instance encodeJsonPost :: EncodeJson Post where + encodeJson (Post { id, title }) = + "id" := id + ~> "title" := title + ~> jsonEmptyObject + +instance encodeHTMLPost :: EncodeHTML Post where + encodeHTML (Post { id: postId, title}) = + case linksTo site of + allPostsUri :<|> _ -> + section do + h1 (text title) + p (text "Contents...") + nav (linkTo allPostsUri (text "All Posts")) + +newtype PostsView = PostsView (Array Post) + +derive instance genericPostsView :: Generic PostsView + +instance encodeJsonPostsView :: EncodeJson PostsView where + encodeJson = gEncodeJson + +instance encodeHTMLPostsView :: EncodeHTML PostsView where + encodeHTML (PostsView posts) = + case linksTo site of + _ :<|> getPostUri -> + let postLink (Post { id: postId, title }) = + li (linkTo (getPostUri postId) (text title)) + in section do + h1 (text "Posts") + ul (traverse_ postLink posts) + +type Site = Get (HTML :<|> JSON) PostsView + :<|> "posts" :/ Capture "id" PostID :> Get (HTML :<|> JSON) Post + +site :: Proxy Site +site = Proxy + +type AppM e a = ExceptT RoutingError (Aff e) a + +-- This would likely be a database query in +-- a real app: +allPosts :: forall e. AppM e (Array Post) +allPosts = pure (map (\i -> Post { id: i, title: "Post #" <> show i }) (1..10)) + +postsView :: forall e. AppM e PostsView +postsView = PostsView <$> allPosts + +viewPost :: forall e. PostID -> AppM e Post +viewPost postId = + find (\(Post p) -> p.id == postId) <$> allPosts >>= + case _ of + Just post -> pure post + -- You can throw 404 Not Found in here as well. + Nothing -> throwError (HTTPError { status: statusNotFound + , message: Just "Post not found." + }) + +main :: forall e. Eff (http :: HTTP, console :: CONSOLE, err :: EXCEPTION, avar :: AVAR | e) Unit +main = + runServer defaultOptions {} siteRouter + where + siteRouter = router site (postsView :<|> viewPost) onRoutingError + onRoutingError status msg = do + writeStatus status + :*> contentType textHTML + :*> closeHeaders + :*> respond (maybe "" id msg) diff --git a/examples/RoutingReaderT.purs b/examples/RoutingReaderT.purs new file mode 100644 index 0000000..c3cdc82 --- /dev/null +++ b/examples/RoutingReaderT.purs @@ -0,0 +1,46 @@ +module Examples.RoutingReaderT where + +import Prelude +import Control.IxMonad ((:*>)) +import Control.Monad.Aff (Aff) +import Control.Monad.Eff (Eff) +import Control.Monad.Eff.Console (CONSOLE) +import Control.Monad.Except (ExceptT) +import Control.Monad.Reader (ReaderT, ask, runReaderT) +import Data.Maybe (fromMaybe) +import Hyper.Node.Server (defaultOptionsWithLogging, runServer') +import Hyper.Response (closeHeaders, respond, writeStatus) +import Hyper.Routing.ContentType.HTML (class EncodeHTML, HTML) +import Hyper.Routing.Method (Get) +import Hyper.Routing.Router (RoutingError, router) +import Node.HTTP (HTTP) +import Text.Smolder.HTML (p) +import Text.Smolder.Markup (text) +import Type.Proxy (Proxy(..)) + +data Greeting = Greeting String + +type Site = Get HTML Greeting + +instance encodeHTMLGreeting :: EncodeHTML Greeting where + encodeHTML (Greeting g) = p (text g) + +runAppM ∷ ∀ e a. String -> ReaderT String (Aff e) a → (Aff e) a +runAppM = flip runReaderT + +site :: Proxy Site +site = Proxy + +greet :: forall m. Monad m => ExceptT RoutingError (ReaderT String m) Greeting +greet = Greeting <$> ask + +main :: forall e. Eff (console :: CONSOLE, http :: HTTP | e) Unit +main = + let app = router site greet onRoutingError + + onRoutingError status msg = + writeStatus status + :*> closeHeaders + :*> respond (fromMaybe "" msg) + + in runServer' defaultOptionsWithLogging {} (runAppM "Hello") app diff --git a/src/Hyper/Routing/Router.purs b/src/Hyper/Routing/Router.purs new file mode 100644 index 0000000..b460cbf --- /dev/null +++ b/src/Hyper/Routing/Router.purs @@ -0,0 +1,291 @@ +module Hyper.Routing.Router + ( RoutingError(..) + , class Router + , route + , router + ) where + +import Prelude +import Data.HTTP.Method as Method +import Data.StrMap as StrMap +import Control.IxMonad (ibind) +import Control.Monad.Error.Class (throwError) +import Control.Monad.Except (ExceptT, runExceptT) +import Data.Array (elem, filter, null, uncons) +import Data.Either (Either(..)) +import Data.Generic.Rep (class Generic) +import Data.Generic.Rep.Eq (genericEq) +import Data.Generic.Rep.Show (genericShow) +import Data.HTTP.Method (CustomMethod, Method) +import Data.Maybe (Maybe(..)) +import Data.MediaType.Common (textPlain) +import Data.StrMap (StrMap) +import Data.String (Pattern(..), split) +import Data.Symbol (class IsSymbol, SProxy(..), reflectSymbol) +import Data.Traversable (traverse) +import Data.Tuple (Tuple(..)) +import Hyper.Conn (Conn) +import Hyper.ContentNegotiation (AcceptHeader, NegotiationResult(..), negotiateContent, parseAcceptHeader) +import Hyper.Middleware (Middleware, lift') +import Hyper.Middleware.Class (getConn) +import Hyper.Response (class Response, class ResponseWriter, ResponseEnded, StatusLineOpen, closeHeaders, contentType, end, respond, writeStatus) +import Hyper.Routing (type (:<|>), type (:>), Capture, CaptureAll, Handler, Lit, Raw, (:<|>)) +import Hyper.Routing.ContentType (class AllMimeRender, allMimeRender) +import Hyper.Routing.PathPiece (class FromPathPiece, fromPathPiece) +import Hyper.Status (Status, statusBadRequest, statusMethodNotAllowed, statusNotAcceptable, statusNotFound, statusOK) +import Type.Proxy (Proxy(..)) + +type Method' = Either Method CustomMethod + +type RoutingContext = { path :: Array String + , method :: Method' + } + +data RoutingError + = HTTPError { status :: Status + , message :: Maybe String + } + +derive instance genericRoutingError :: Generic RoutingError _ + +instance eqRoutingError :: Eq RoutingError where + eq = genericEq + +instance showRoutingError :: Show RoutingError where + show = genericShow + +class Router e h r | e -> h, e -> r where + route :: Proxy e -> RoutingContext -> h -> Either RoutingError r + +instance routerAltE :: (Router e1 h1 out, Router e2 h2 out) + => Router (e1 :<|> e2) (h1 :<|> h2) out where + route _ context (h1 :<|> h2) = + case route (Proxy :: Proxy e1) context h1 of + Left err1 -> + case route (Proxy :: Proxy e2) context h2 of + -- The Error that's thrown depends on the Errors' HTTP codes. + Left err2 -> throwError (selectError err1 err2) + Right handler -> pure handler + Right handler -> pure handler + where + fallbackStatuses = [statusNotFound, statusMethodNotAllowed] + selectError (HTTPError errL) (HTTPError errR) = + case Tuple errL.status errR.status of + Tuple s1 s2 + | s1 `elem` fallbackStatuses && s2 == statusNotFound -> HTTPError errL + | s1 /= statusNotFound && s2 `elem` fallbackStatuses -> HTTPError errL + | otherwise -> HTTPError errR + + +instance routerLit :: ( Router e h out + , IsSymbol lit + ) + => Router (Lit lit :> e) h out where + route _ ctx r = + case uncons ctx.path of + Just { head, tail } | head == expectedSegment -> + route (Proxy :: Proxy e) ctx { path = tail} r + Just _ -> throwError (HTTPError { status: statusNotFound + , message: Nothing + }) + Nothing -> throwError (HTTPError { status: statusNotFound + , message: Nothing + }) + where expectedSegment = reflectSymbol (SProxy :: SProxy lit) + +instance routerCapture :: ( Router e h out + , FromPathPiece v + ) + => Router (Capture c v :> e) (v -> h) out where + route _ ctx r = + case uncons ctx.path of + Nothing -> throwError (HTTPError { status: statusNotFound + , message: Nothing + }) + Just { head, tail } -> + case fromPathPiece head of + Left err -> throwError (HTTPError { status: statusBadRequest + , message: Just err + }) + Right x -> route (Proxy :: Proxy e) ctx { path = tail } (r x) + + +instance routerCaptureAll :: ( Router e h out + , FromPathPiece v + ) + => Router (CaptureAll c v :> e) (Array v -> h) out where + route _ ctx r = + case traverse fromPathPiece ctx.path of + Left err -> throwError (HTTPError { status: statusBadRequest + , message: Just err + }) + Right xs -> route (Proxy :: Proxy e) ctx { path = [] } (r xs) + + +routeEndpoint :: forall e r method. + (IsSymbol method) + => Proxy e + -> RoutingContext + -> r + -> SProxy method + -> Either RoutingError r +routeEndpoint _ context r methodProxy = do + unless (null context.path) $ + throwError (HTTPError { status: statusNotFound + , message: Nothing + }) + + let expectedMethod = Method.fromString (reflectSymbol methodProxy) + unless (expectedMethod == context.method) $ + throwError (HTTPError { status: statusMethodNotAllowed + , message: Just ("Method " + <> show context.method + <> " did not match " + <> show expectedMethod + <> ".") + }) + pure r + +getAccept :: StrMap String -> Either String (Maybe AcceptHeader) +getAccept m = + case StrMap.lookup "accept" m of + Just a -> Just <$> parseAcceptHeader a + Nothing -> pure Nothing + +instance routerHandler :: ( Monad m + , ResponseWriter rw m wb + , Response wb m r + , IsSymbol method + , AllMimeRender body ct r + ) + => Router + (Handler method ct body) + (ExceptT RoutingError m body) + (Middleware + m + { request :: { method :: Either Method CustomMethod, url :: String, headers :: StrMap String | req } + , response :: { writer :: rw StatusLineOpen | res } + , components :: c + } + { request :: { method :: Either Method CustomMethod, url :: String, headers :: StrMap String | req } + , response :: { writer :: rw ResponseEnded | res } + , components :: c + } + Unit) + where + route proxy context action = do + let handler = lift' (runExceptT action) `ibind` + case _ of + Left (HTTPError { status }) -> do + writeStatus status + contentType textPlain + closeHeaders + end + Right body -> do + conn ← getConn + case getAccept conn.request.headers of + Left err -> do + writeStatus statusBadRequest + contentType textPlain + closeHeaders + end + Right parsedAccept -> do + case negotiateContent parsedAccept (allMimeRender (Proxy :: Proxy ct) body) of + Match (Tuple ct rendered) -> do + writeStatus statusOK + contentType ct + closeHeaders + respond rendered + Default (Tuple ct rendered) -> do + writeStatus statusOK + contentType ct + closeHeaders + respond rendered + NotAcceptable _ -> do + writeStatus statusNotAcceptable + contentType textPlain + closeHeaders + end + routeEndpoint proxy context handler (SProxy :: SProxy method) + where bind = ibind + +instance routerRaw :: (IsSymbol method) + => Router + (Raw method) + (Middleware + m + { request :: { method :: Either Method CustomMethod, url :: String | req } + , response :: { writer :: rw StatusLineOpen | res } + , components :: c + } + { request :: { method :: Either Method CustomMethod, url :: String | req } + , response :: { writer :: rw ResponseEnded | res } + , components :: c + } + Unit) + (Middleware + m + { request :: { method :: Either Method CustomMethod, url :: String | req } + , response :: { writer :: rw StatusLineOpen | res } + , components :: c + } + { request :: { method :: Either Method CustomMethod, url :: String | req } + , response :: { writer :: rw ResponseEnded | res } + , components :: c + } + Unit) + where + route proxy context r = + routeEndpoint proxy context r (SProxy :: SProxy method) + + +router + :: forall s r m req res c rw. + ( Monad m + , Router s r (Middleware + m + (Conn { method :: Method', url :: String | req } { writer :: rw StatusLineOpen | res } c) + (Conn { method :: Method', url :: String | req } { writer :: rw ResponseEnded | res } c) + Unit) + ) => + Proxy s + -> r + -> (Status + -> Maybe String + -> Middleware + m + (Conn { method :: Method', url :: String | req } { writer :: rw StatusLineOpen | res } c) + (Conn { method :: Method', url :: String | req } { writer :: rw ResponseEnded | res } c) + Unit) + -> Middleware + m + (Conn { method :: Method', url :: String | req } { writer :: rw StatusLineOpen | res } c) + (Conn { method :: Method', url :: String | req } { writer :: rw ResponseEnded | res } c) + Unit + +router site handler onRoutingError = do + handler' + -- Run the routing to get a handler. + -- route (Proxy :: Proxy s) ctx handler + -- Then, if successful, run the handler, possibly also generating an HTTPError. + -- # either catch runHandler + where + splitUrl = filter ((/=) "") <<< split (Pattern "/") + context conn = { path: splitUrl conn.request.url + , method: conn.request.method + } + catch (HTTPError { status, message }) = + onRoutingError status message + + handler' ∷ Middleware + m + (Conn { method :: Method', url :: String | req } { writer :: rw StatusLineOpen | res } c) + (Conn { method :: Method', url :: String | req } { writer :: rw ResponseEnded | res } c) + Unit + handler' = do + conn ← getConn + case route site (context conn) handler of + Left err → catch err + Right h → h + + bind = ibind diff --git a/test/Hyper/Routing/RouterSpec.purs b/test/Hyper/Routing/RouterSpec.purs new file mode 100644 index 0000000..b2dcb7b --- /dev/null +++ b/test/Hyper/Routing/RouterSpec.purs @@ -0,0 +1,120 @@ +module Hyper.Routing.RouterSpec (spec) where + +import Prelude +import Data.StrMap as StrMap +import Control.IxMonad (ibind) +import Data.Either (Either(..)) +import Data.HTTP.Method (CustomMethod, Method(..)) +import Data.Maybe (Maybe(..), maybe) +import Data.MediaType.Common (textPlain) +import Data.StrMap (StrMap) +import Data.String (joinWith) +import Data.Tuple (Tuple(..)) +import Hyper.Conn (Conn) +import Hyper.Middleware (Middleware, evalMiddleware) +import Hyper.Response (class Response, contentType, headers, respond, class ResponseWriter, ResponseEnded, StatusLineOpen, closeHeaders, writeStatus) +import Hyper.Routing ((:<|>)) +import Hyper.Routing.Router (router) +import Hyper.Routing.TestSite (Home(..), User(..), UserID(..), WikiPage(..), testSite) +import Hyper.Status (statusBadRequest, statusMethodNotAllowed, statusOK) +import Hyper.Test.TestServer (TestResponseWriter(..), testHeaders, testServer, testStatus, testStringBody) +import Test.Spec (Spec, describe, it) +import Test.Spec.Assertions (shouldEqual) + +home :: forall m. Monad m => m Home +home = pure Home + +profile :: forall m. Monad m => UserID -> m User +profile userId = pure (User userId) + +friends :: forall m. Monad m => UserID -> m (Array User) +friends (UserID uid) = + pure [ User (UserID "foo") + , User (UserID "bar") + ] + +wiki :: forall m. Monad m => Array String -> m WikiPage +wiki segments = pure (WikiPage (joinWith "/" segments)) + +about :: forall m req res c rw rb. + ( Monad m + , ResponseWriter rw m rb + , Response rb m String + ) + => Middleware + m + (Conn { method :: Either Method CustomMethod, url :: String | req } { writer :: rw StatusLineOpen | res } c) + (Conn { method :: Either Method CustomMethod, url :: String | req } { writer :: rw ResponseEnded | res } c) + Unit +about = do + writeStatus statusOK + contentType textPlain + closeHeaders + respond "This is a test." + where bind = ibind + +spec :: forall e. Spec e Unit +spec = + describe "Hyper.Routing.Router" do + let userHandlers userId = profile userId :<|> friends userId + handlers = home + :<|> userHandlers + :<|> wiki + :<|> about + + onRoutingError status msg = do + writeStatus status + headers [] + respond (maybe "" id msg) + where bind = ibind + + makeRequestWithHeaders method path headers = + let conn = { request: { method: Left method + , url: path + , headers: headers + } + , response: { writer: TestResponseWriter } + , components: {} + } + app = router testSite handlers onRoutingError + in evalMiddleware app conn + # testServer + + makeRequest method path = + makeRequestWithHeaders method path (StrMap.empty :: StrMap String) + + describe "router" do + it "matches root" do + conn <- makeRequest GET "/" + testStringBody conn `shouldEqual` "

Home

" + + it "considers Accept header for multi-content-type resources" do + conn <- makeRequestWithHeaders GET "/" (StrMap.singleton "accept" "application/json") + testStatus conn `shouldEqual` Just statusOK + testStringBody conn `shouldEqual` "{}" + + it "validates based on custom Capture instance" do + conn <- makeRequest GET "/users/ /profile" + testStatus conn `shouldEqual` Just statusBadRequest + testStringBody conn `shouldEqual` "UserID must not be blank." + + it "matches nested routes" do + conn <- makeRequest GET "/users/owi/profile" + testStringBody conn `shouldEqual` "{\"userId\":\"owi\"}" + + it "supports arrays of JSON values" do + conn <- makeRequest GET "/users/owi/friends" + testStringBody conn `shouldEqual` "[{\"userId\":\"foo\"},{\"userId\":\"bar\"}]" + + it "matches CaptureAll route" do + conn <- makeRequest GET "/wiki/foo/bar/baz.txt" + testStringBody conn `shouldEqual` "Viewing page: foo/bar/baz.txt" + + it "matches Raw route" do + conn <- makeRequest GET "/about" + testHeaders conn `shouldEqual` [ Tuple "Content-Type" "text/plain" ] + testStringBody conn `shouldEqual` "This is a test." + + it "checks HTTP method" do + conn <- makeRequest POST "/" + testStatus conn `shouldEqual` Just statusMethodNotAllowed diff --git a/test/Hyper/Routing/TestSite.purs b/test/Hyper/Routing/TestSite.purs new file mode 100644 index 0000000..ba78845 --- /dev/null +++ b/test/Hyper/Routing/TestSite.purs @@ -0,0 +1,58 @@ +module Hyper.Routing.TestSite where + +import Prelude +import Data.Argonaut (class EncodeJson, jsonEmptyObject, (:=), (~>)) +import Data.Either (Either(..)) +import Data.String (trim) +import Hyper.Routing (type (:/), type (:<|>), type (:>), Capture, CaptureAll, Raw) +import Hyper.Routing.ContentType.HTML (HTML, class EncodeHTML) +import Hyper.Routing.ContentType.JSON (JSON) +import Hyper.Routing.Method (Get) +import Hyper.Routing.PathPiece (class FromPathPiece, class ToPathPiece) +import Text.Smolder.HTML (h1) +import Text.Smolder.Markup (text) +import Type.Proxy (Proxy(..)) + +data Home = Home + +instance encodeJsonHome :: EncodeJson Home where + encodeJson Home = jsonEmptyObject + +instance encodeHTMLHome :: EncodeHTML Home where + encodeHTML Home = h1 (text "Home") + +newtype UserID = UserID String + +instance fromPathPieceUserID :: FromPathPiece UserID where + fromPathPiece s = + case trim s of + "" -> Left "UserID must not be blank." + s' -> Right (UserID s') + +instance toPathPieceUserID :: ToPathPiece UserID where + toPathPiece (UserID s) = s + +data User = User UserID + +instance encodeUser :: EncodeJson User where + encodeJson (User (UserID userId)) = + "userId" := userId + ~> jsonEmptyObject + +data WikiPage = WikiPage String + +instance encodeHTMLWikiPage :: EncodeHTML WikiPage where + encodeHTML (WikiPage title) = text ("Viewing page: " <> title) + +type TestSite = + Get (HTML :<|> JSON) Home + -- nested routes with capture + :<|> "users" :/ Capture "user-id" UserID :> ("profile" :/ Get JSON User + :<|> "friends" :/ Get JSON (Array User)) + -- capture all + :<|> "wiki" :/ CaptureAll "segments" String :> Get HTML WikiPage + -- raw middleware + :<|> "about" :/ Raw "GET" + +testSite :: Proxy TestSite +testSite = Proxy diff --git a/test/Main.purs b/test/Main.purs new file mode 100644 index 0000000..e4f3691 --- /dev/null +++ b/test/Main.purs @@ -0,0 +1,11 @@ +module Test.Main where + +import Prelude +import Control.Monad.Eff (Eff) +import Node.FS (FS) +import Test.Spec.Discovery (discover) +import Test.Spec.Reporter.Console (consoleReporter) +import Test.Spec.Runner (RunnerEffects, run) + +main :: Eff (RunnerEffects (fs :: FS)) Unit +main = discover "Hyper\\.Routing\\..*Spec" >>= run [consoleReporter]