diff --git a/Makefile b/Makefile index cc03c85..6a0b4db 100644 --- a/Makefile +++ b/Makefile @@ -8,13 +8,20 @@ PS_ERL_FFI = $(shell find ${PS_SRC} -type f -name \*.erl) PACKAGE_SET = $(shell jq '.set' < psc-package.json) ERL_MODULES_VERSION = $(shell jq '."erl-modules".version' < .psc-package/$(PACKAGE_SET)/.set/packages.json) -all: output +all: output docs output: $(PS_SOURCEFILES) $(PS_ERL_FFI) .psc-package .psc-package/${PACKAGE_SET}/erl-modules/${ERL_MODULES_VERSION}/scripts/gen_module_names.sh src/Stetson Stetson.ModuleNames psc-package sources | xargs purs compile '$(PS_SRC)/**/*.purs' @touch output +docs: $(PS_SOURCEFILES) $(PS_ERL_FFI) .psc-package + mkdir -p docs + psc-package sources | xargs purs docs '$(PS_SRC)/**/*.purs' \ + --docgen Stetson:docs/Stetson.md \ + --docgen Stetson.Rest:docs/Stetson.Rest.md + touch docs + .psc-package: psc-package.json psc-package install touch .psc-package diff --git a/README.md b/README.md new file mode 100644 index 0000000..ceeabf8 --- /dev/null +++ b/README.md @@ -0,0 +1,34 @@ +# purescript-erl-stetson + +Opinionated Bindings to Cowboy + +## Type-safe bindings + +Direct bindings to Cowboy's API exist in the package 'erl-cowboy', but in practise this are unwieldy to build an application with - this module seeks to provide a more functional set of behaviours around the low level bindings - more sspecifically, the more REST oriented side of things. + +So for example, a complete web server serving some static content would look like this + +## A complete web server + +```purescript + + Stetson.configure -- Start configuring Stetson + # Stetson.route "/" routeHandler -- Define a route, invoke 'routeHandler' to find out about it + # Stetson.port 8080 -- Listen on port 8080 + # Stetson.bindTo 0 0 0 0 -- And all interfaces + # Stetson.startClear "http_listener" -- Start the listener + +-- Our route handler.. +routeHandler :: StetsonHandler Unit -- Has a type of 'Unit' - this is the state that gets passed around to all callbacks +routeHandler = + Rest.handler (\req -> Rest.initResult req unit) -- Callback invoked on init, define the initial state (in this case, 'unit') + # Rest.contentTypesProvided (\req state -> Rest.result (tuple2 "text/html" writeText : nil) req state) -- Callback to provide list of handlers for different content types + # Rest.yeeha -- Finish defining the handler + where + writeText req state = do -- Callback invoked when we want to serve text/html as above + Rest.result "Hello World" req state) -- The result of providing text/html, Hello World + +``` + +An actual example with more context can be found in the [demo project](https://github.com/id3as/demo-ps) + diff --git a/docs/Stetson.Rest.md b/docs/Stetson.Rest.md new file mode 100644 index 0000000..35922fa --- /dev/null +++ b/docs/Stetson.Rest.md @@ -0,0 +1,127 @@ +## Module Stetson.Rest + +This module contains the functions necessary to define a rest handler for a route in Stetson/Cowboy +This maps pretty much 1-1 onto https://ninenines.eu/docs/en/cowboy/2.5/guide/rest_handlers/#_callbacks +Although only the handlers that we have needed so far are defined - feel free to send pull requests that add the ones you need + +#### `handler` + +``` purescript +handler :: forall state. InitHandler state -> RestHandler state +``` + +Create a cowboy REST handler with the provided Init handler and no callbacks defined + +#### `allowedMethods` + +``` purescript +allowedMethods :: forall state. (Req -> state -> Effect (RestResult (List HttpMethod) state)) -> RestHandler state -> RestHandler state +``` + +Add an allowedMethods callback to the provided RestHandler + +#### `resourceExists` + +``` purescript +resourceExists :: forall state. (Req -> state -> Effect (RestResult Boolean state)) -> RestHandler state -> RestHandler state +``` + +Add a resourceExists callback to the provided RestHandler + +#### `isAuthorized` + +``` purescript +isAuthorized :: forall state. (Req -> state -> Effect (RestResult Authorized state)) -> RestHandler state -> RestHandler state +``` + +Add an isAuthorized callback to the provided RestHandler + +#### `contentTypesAccepted` + +``` purescript +contentTypesAccepted :: forall state. (Req -> state -> Effect (RestResult (List (Tuple2 String (AcceptHandler state))) state)) -> RestHandler state -> RestHandler state +``` + +Add a contentTypesAccepted callback to the provided RestHandler + +#### `contentTypesProvided` + +``` purescript +contentTypesProvided :: forall state. (Req -> state -> Effect (RestResult (List (Tuple2 String (ProvideHandler state))) state)) -> RestHandler state -> RestHandler state +``` + +Add a contentTypesProvided callback to the provided RestHandler + +#### `deleteResource` + +``` purescript +deleteResource :: forall state. (Req -> state -> Effect (RestResult Boolean state)) -> RestHandler state -> RestHandler state +``` + +Add a deleteResource callback to the provided RestHandler + +#### `movedTemporarily` + +``` purescript +movedTemporarily :: forall state. (Req -> state -> Effect (RestResult MovedResult state)) -> RestHandler state -> RestHandler state +``` + +Add a movedTemporarily callback to the provided RestHandler + +#### `movedPermanently` + +``` purescript +movedPermanently :: forall state. (Req -> state -> Effect (RestResult MovedResult state)) -> RestHandler state -> RestHandler state +``` + +Add a movedPermanently callback to the provided RestHandler + +#### `serviceAvailable` + +``` purescript +serviceAvailable :: forall state. (Req -> state -> Effect (RestResult Boolean state)) -> RestHandler state -> RestHandler state +``` + +Add a serviceAvailable callback to the provided RestHandler + +#### `previouslyExisted` + +``` purescript +previouslyExisted :: forall state. (Req -> state -> Effect (RestResult Boolean state)) -> RestHandler state -> RestHandler state +``` + +Add a previouslyExisted callback to the provided RestHandler + +#### `forbidden` + +``` purescript +forbidden :: forall state. (Req -> state -> Effect (RestResult Boolean state)) -> RestHandler state -> RestHandler state +``` + +Add a forbidden callback to the provided RestHandler + +#### `initResult` + +``` purescript +initResult :: forall state. Req -> state -> Effect (InitResult state) +``` + +Create an init response for return from an InitHandler + +#### `result` + +``` purescript +result :: forall reply state. reply -> Req -> state -> Effect (RestResult reply state) +``` + +Create an rest response for return from a rest callback + +#### `yeeha` + +``` purescript +yeeha :: forall state. RestHandler state -> StetsonHandler state +``` + +Finish defining this rest handler, yeehaaw + + diff --git a/docs/Stetson.md b/docs/Stetson.md new file mode 100644 index 0000000..45765b6 --- /dev/null +++ b/docs/Stetson.md @@ -0,0 +1,205 @@ +## Module Stetson + +This is the entry point into the Stetson wrapper +You'll want to call Stetson.configure and then follow the types.. + +#### `RestResult` + +``` purescript +data RestResult reply state + = RestOk reply Req state +``` + +The return type of most of the callbacks invoked as part of the REST workflow + +#### `InitResult` + +``` purescript +data InitResult state + = InitOk Req state +``` + +The return type of the 'init' callback in the REST workflow + +#### `InitHandler` + +``` purescript +type InitHandler state = Req -> Effect (InitResult state) +``` + +The callback invoked to kick off the REST workflow + +#### `AcceptHandler` + +``` purescript +type AcceptHandler state = Req -> state -> Effect (RestResult Boolean state) +``` + +A callback invoked to 'accept' a specific content type + +#### `ProvideHandler` + +``` purescript +type ProvideHandler state = Req -> state -> Effect (RestResult String state) +``` + +A callback invoked to 'provide' a specific content type + +#### `RestHandler` + +``` purescript +type RestHandler state = { init :: Req -> Effect (InitResult state), allowedMethods :: Maybe (Req -> state -> Effect (RestResult (List HttpMethod) state)), resourceExists :: Maybe (Req -> state -> Effect (RestResult Boolean state)), contentTypesAccepted :: Maybe (Req -> state -> Effect (RestResult (List (Tuple2 String (AcceptHandler state))) state)), contentTypesProvided :: Maybe (Req -> state -> Effect (RestResult (List (Tuple2 String (ProvideHandler state))) state)), deleteResource :: Maybe (Req -> state -> Effect (RestResult Boolean state)), isAuthorized :: Maybe (Req -> state -> Effect (RestResult Authorized state)), movedTemporarily :: Maybe (Req -> state -> Effect (RestResult MovedResult state)), movedPermanently :: Maybe (Req -> state -> Effect (RestResult MovedResult state)), serviceAvailable :: Maybe (Req -> state -> Effect (RestResult Boolean state)), previouslyExisted :: Maybe (Req -> state -> Effect (RestResult Boolean state)), forbidden :: Maybe (Req -> state -> Effect (RestResult Boolean state)) } +``` + +A builder containing the complete set of callbacks during the rest workflow for a specific handler + +#### `HttpMethod` + +``` purescript +data HttpMethod + = GET + | POST + | HEAD + | OPTIONS + | PUT + | DELETE +``` + +or is it a verb + +##### Instances +``` purescript +Show HttpMethod +``` + +#### `Authorized` + +``` purescript +data Authorized + = Authorized + | NotAuthorized String +``` + +Return type of the isAuthorized callback + +#### `StetsonHandler` + +``` purescript +data StetsonHandler state + = Rest (RestHandler state) +``` + +#### `StaticAssetLocation` + +``` purescript +data StaticAssetLocation + = PrivDir String String + | PrivFile String String +``` + +#### `StetsonRoute` + +``` purescript +type StetsonRoute = { route :: String, moduleName :: NativeModuleName, args :: HandlerArgs } +``` + +#### `HandlerArgs` + +``` purescript +data HandlerArgs :: Type +``` + +#### `ConfiguredRoute` + +``` purescript +data ConfiguredRoute + = Stetson StetsonRoute + | Cowboy Path +``` + +#### `StetsonConfig` + +``` purescript +type StetsonConfig = { bindPort :: Int, bindAddress :: Tuple4 Int Int Int Int, streamHandlers :: Maybe (List NativeModuleName), middlewares :: Maybe (List NativeModuleName), routes :: List ConfiguredRoute } +``` + +#### `configure` + +``` purescript +configure :: StetsonConfig +``` + +Creates a blank stetson config with default settings and no routes + +#### `route` + +``` purescript +route :: forall state. String -> StetsonHandler state -> StetsonConfig -> StetsonConfig +``` + +Add a route to a StetsonConfig +value: The path this route will handle (this takes the same format as cowboy routes) +handler: The handler that will take care of this request +config: The config to add this route to +```purescript +let newConfig = Stetson.route "/items/:id" myHandler config +``` + +#### `static` + +``` purescript +static :: String -> StaticAssetLocation -> StetsonConfig -> StetsonConfig +``` + +Add a static route handler to a StetsonConfig +This can either be a file or a directory to serve a file or files from + +#### `cowboyRoutes` + +``` purescript +cowboyRoutes :: List Path -> StetsonConfig -> StetsonConfig +``` + +Introduce a list of native Erlang cowboy handlers to this config + +#### `port` + +``` purescript +port :: Int -> StetsonConfig -> StetsonConfig +``` + +Set the port that this http listener will listen to + +#### `bindTo` + +``` purescript +bindTo :: Int -> Int -> Int -> Int -> StetsonConfig -> StetsonConfig +``` + +Set the IP that this http listener will bind to (default: 0.0.0.0) + +#### `streamHandlers` + +``` purescript +streamHandlers :: List NativeModuleName -> StetsonConfig -> StetsonConfig +``` + +Supply a list of modules to act as native stream handlers in cowboy + +#### `middlewares` + +``` purescript +middlewares :: List NativeModuleName -> StetsonConfig -> StetsonConfig +``` + +Supply a list of modules to act as native middlewares in cowboy + +#### `startClear` + +``` purescript +startClear :: String -> StetsonConfig -> Effect Unit +``` + +Start the listener with the specified name + + diff --git a/src/Stetson.purs b/src/Stetson.purs index ef876db..3cbde6e 100644 --- a/src/Stetson.purs +++ b/src/Stetson.purs @@ -1,4 +1,29 @@ -module Stetson where +-- | This is the entry point into the Stetson wrapper +-- | You'll want to call Stetson.configure and then follow the types.. +module Stetson ( RestResult(..) + , InitResult(..) + , InitHandler + , AcceptHandler + , ProvideHandler + , RestHandler + , HttpMethod(..) + , Authorized(..) + , StetsonHandler(..) + , StaticAssetLocation(..) + , StetsonRoute + , HandlerArgs + , ConfiguredRoute(..) + , StetsonConfig + , configure + , route + , static + , cowboyRoutes + , port + , bindTo + , streamHandlers + , middlewares + , startClear + ) where import Prelude @@ -32,13 +57,23 @@ foreign import data HandlerArgs :: Type -- The exception is cowboy_req, as that's pretty universal across handlers and isn't too ridiculous to talk to directly -- We could go with our own req module, but that would probably just end up being 1:1 to cowboy req anyway so who needs that extra work + +-- | The return type of most of the callbacks invoked as part of the REST workflow data RestResult reply state = RestOk reply Req state + +-- | The return type of the 'init' callback in the REST workflow data InitResult state = InitOk Req state +-- | The callback invoked to kick off the REST workflow type InitHandler state = Req -> Effect (InitResult state) + +-- | A callback invoked to 'accept' a specific content type type AcceptHandler state = Req -> state -> Effect (RestResult Boolean state) + +-- | A callback invoked to 'provide' a specific content type type ProvideHandler state = Req -> state -> Effect (RestResult String state) +-- | A builder containing the complete set of callbacks during the rest workflow for a specific handler type RestHandler state = { init :: Req -> Effect (InitResult state) , allowedMethods :: Maybe (Req -> state -> Effect (RestResult (List HttpMethod) state)) @@ -54,8 +89,10 @@ type RestHandler state = { , forbidden :: Maybe (Req -> state -> Effect (RestResult Boolean state)) } +-- | or is it a verb data HttpMethod = GET | POST | HEAD | OPTIONS | PUT | DELETE +-- | Return type of the isAuthorized callback data Authorized = Authorized | NotAuthorized String instance showHttpMethod :: Show HttpMethod where @@ -89,6 +126,7 @@ type StetsonConfig = , routes :: List ConfiguredRoute } +-- | Creates a blank stetson config with default settings and no routes configure :: StetsonConfig configure = { bindPort : 8000 @@ -98,6 +136,13 @@ configure = , routes : nil } +-- | Add a route to a StetsonConfig +-- | value: The path this route will handle (this takes the same format as cowboy routes) +-- | handler: The handler that will take care of this request +-- | config: The config to add this route to +-- | ```purescript +-- | let newConfig = Stetson.route "/items/:id" myHandler config +-- | ``` route :: forall state. String -> StetsonHandler state -> StetsonConfig -> StetsonConfig route value (Rest handler) config@{ routes } = (config { routes = (Stetson { route: value @@ -105,32 +150,40 @@ route value (Rest handler) config@{ routes } = , args: unsafeCoerce handler } : routes) }) +-- | Add a static route handler to a StetsonConfig +-- | This can either be a file or a directory to serve a file or files from static :: String -> StaticAssetLocation -> StetsonConfig -> StetsonConfig static url (PrivDir app dir) config@{ routes } = (config { routes = Cowboy (Static.privDir (atom app) url dir) : routes }) static url (PrivFile app file) config@{ routes } = (config { routes = Cowboy (Static.privFile (atom app) url file) : routes }) +-- | Introduce a list of native Erlang cowboy handlers to this config cowboyRoutes :: List Path -> StetsonConfig -> StetsonConfig cowboyRoutes newRoutes config@{ routes } = (config { routes = (Cowboy <$> reverse newRoutes) <> routes }) +-- | Set the port that this http listener will listen to port :: Int -> StetsonConfig -> StetsonConfig port value config = (config { bindPort = value }) +-- | Set the IP that this http listener will bind to (default: 0.0.0.0) bindTo :: Int -> Int -> Int -> Int -> StetsonConfig -> StetsonConfig bindTo t1 t2 t3 t4 config = (config { bindAddress = tuple4 t1 t2 t3 t4 }) +-- | Supply a list of modules to act as native stream handlers in cowboy streamHandlers :: List NativeModuleName -> StetsonConfig -> StetsonConfig streamHandlers handlers config = (config { streamHandlers = Just handlers }) +-- | Supply a list of modules to act as native middlewares in cowboy middlewares :: List NativeModuleName -> StetsonConfig -> StetsonConfig middlewares mws config = (config { middlewares = Just mws }) +-- | Start the listener with the specified name startClear :: String -> StetsonConfig -> Effect Unit startClear name config@{ bindAddress, bindPort, streamHandlers, middlewares } = do let paths = createRoute <$> reverse config.routes @@ -141,7 +194,6 @@ startClear name config@{ bindAddress, bindPort, streamHandlers, middlewares } = <> List.fromFoldable (StreamHandlers <$> streamHandlers) <> List.fromFoldable (Middlewares <$> middlewares) _ <- Cowboy.startClear (atom name) transOpts protoOpts - -- info "Started HTTP listener on port ~p." $ bindPort : nil pure unit diff --git a/src/Stetson/Rest.purs b/src/Stetson/Rest.purs index a561f92..8eef19a 100644 --- a/src/Stetson/Rest.purs +++ b/src/Stetson/Rest.purs @@ -1,4 +1,23 @@ -module Stetson.Rest where +-- | This module contains the functions necessary to define a rest handler for a route in Stetson/Cowboy +-- | This maps pretty much 1-1 onto https://ninenines.eu/docs/en/cowboy/2.5/guide/rest_handlers/#_callbacks +-- | Although only the handlers that we have needed so far are defined - feel free to send pull requests that add the ones you need +module Stetson.Rest ( handler + , allowedMethods + , resourceExists + , isAuthorized + , contentTypesAccepted + , contentTypesProvided + , deleteResource + , movedTemporarily + , movedPermanently + , serviceAvailable + , previouslyExisted + , forbidden + , initResult + , result + , yeeha + ) + where import Prelude @@ -10,6 +29,7 @@ import Erl.Data.List (List) import Erl.Data.Tuple (Tuple2) import Stetson (AcceptHandler, Authorized, HttpMethod, InitHandler, InitResult(..), ProvideHandler, RestHandler, RestResult(..), StetsonHandler(..)) +-- | Create a cowboy REST handler with the provided Init handler and no callbacks defined handler :: forall state. InitHandler state -> RestHandler state handler init = { init @@ -26,44 +46,58 @@ handler init = { , forbidden: Nothing } +-- | Add an allowedMethods callback to the provided RestHandler allowedMethods :: forall state. (Req -> state -> Effect (RestResult (List HttpMethod) state)) -> RestHandler state -> RestHandler state allowedMethods fn handler = (handler { allowedMethods = Just fn }) +-- | Add a resourceExists callback to the provided RestHandler resourceExists :: forall state. (Req -> state -> Effect (RestResult Boolean state)) -> RestHandler state -> RestHandler state resourceExists fn handler = (handler { resourceExists = Just fn }) +-- | Add an isAuthorized callback to the provided RestHandler isAuthorized :: forall state. (Req -> state -> Effect (RestResult Authorized state)) -> RestHandler state -> RestHandler state isAuthorized fn handler = (handler { isAuthorized = Just fn }) +-- | Add a contentTypesAccepted callback to the provided RestHandler contentTypesAccepted :: forall state. (Req -> state -> Effect (RestResult (List (Tuple2 String (AcceptHandler state))) state)) -> RestHandler state -> RestHandler state contentTypesAccepted fn handler = (handler { contentTypesAccepted = Just fn }) +-- | Add a contentTypesProvided callback to the provided RestHandler contentTypesProvided :: forall state. (Req -> state -> Effect (RestResult (List (Tuple2 String (ProvideHandler state))) state)) -> RestHandler state -> RestHandler state contentTypesProvided fn handler = (handler { contentTypesProvided = Just fn }) +-- | Add a deleteResource callback to the provided RestHandler deleteResource :: forall state. (Req -> state -> Effect (RestResult Boolean state)) -> RestHandler state -> RestHandler state deleteResource fn handler = (handler { deleteResource = Just fn }) +-- | Add a movedTemporarily callback to the provided RestHandler movedTemporarily :: forall state. (Req -> state -> Effect (RestResult MovedResult state)) -> RestHandler state -> RestHandler state movedTemporarily fn handler = (handler { movedTemporarily = Just fn }) +-- | Add a movedPermanently callback to the provided RestHandler movedPermanently :: forall state. (Req -> state -> Effect (RestResult MovedResult state)) -> RestHandler state -> RestHandler state movedPermanently fn handler = (handler { movedPermanently = Just fn }) +-- | Add a serviceAvailable callback to the provided RestHandler serviceAvailable :: forall state. (Req -> state -> Effect (RestResult Boolean state)) -> RestHandler state -> RestHandler state serviceAvailable fn handler = (handler { serviceAvailable = Just fn }) +-- | Add a previouslyExisted callback to the provided RestHandler previouslyExisted :: forall state. (Req -> state -> Effect (RestResult Boolean state)) -> RestHandler state -> RestHandler state previouslyExisted fn handler = (handler { previouslyExisted = Just fn }) +-- | Add a forbidden callback to the provided RestHandler forbidden :: forall state. (Req -> state -> Effect (RestResult Boolean state)) -> RestHandler state -> RestHandler state forbidden fn handler = (handler { forbidden = Just fn }) +-- | Create an init response for return from an InitHandler initResult :: forall state. Req -> state -> Effect (InitResult state) initResult rq st = pure $ InitOk rq st +-- | Create an rest response for return from a rest callback result :: forall reply state. reply -> Req -> state -> Effect (RestResult reply state) result re rq st = pure $ RestOk re rq st +-- | Finish defining this rest handler, yeehaaw yeeha :: forall state. RestHandler state -> StetsonHandler state yeeha = Rest