Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Payload HTTP server backend recipe #226

Merged
merged 7 commits into from
Sep 9, 2020
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ Running a web-compatible recipe:
| :heavy_check_mark: | :heavy_check_mark: ([try](https://try.ps.ai/?github=JordanMartinez/purescript-cookbook/master/recipes/MemoizeFibonacci/src/Main.purs)) | [MemoizeFibonacci](recipes/MemoizeFibonacci) | This recipe demonstrates correct and incorrect use of the [`memoize`](https://pursuit.purescript.org/packages/purescript-memoize/docs/Data.Function.Memoize#v:memoize) function by calculating the fibonacci sequence. |
| | :heavy_check_mark: ([try](https://try.ps.ai/?github=JordanMartinez/purescript-cookbook/master/recipes/NumbersHalogenHooks/src/Main.purs)) | [NumbersHalogenHooks](recipes/NumbersHalogenHooks) | A Halogen port of the ["Random - Numbers" Elm Example](https://elm-lang.org/examples/numbers). |
| | :heavy_check_mark: ([try](https://try.ps.ai/?github=JordanMartinez/purescript-cookbook/master/recipes/NumbersReactHooks/src/Main.purs)) | [NumbersReactHooks](recipes/NumbersReactHooks) | A React port of the ["Random - Numbers" Elm Example](https://elm-lang.org/examples/numbers). |
| :heavy_check_mark: | | [PayloadHttpApiNode](recipes/PayloadHttpApiNode) | Implements a simple 'quote' API using the [payload](https://github.com/hoodunit/purescript-payload) |
| | :heavy_check_mark: ([try](https://try.ps.ai/?github=JordanMartinez/purescript-cookbook/master/recipes/PositionsHalogenHooks/src/Main.purs)) | [PositionsHalogenHooks](recipes/PositionsHalogenHooks) | A Halogen port of the ["Random - Positions" Elm Example](https://elm-lang.org/examples/positions). |
| | :heavy_check_mark: ([try](https://try.ps.ai/?github=JordanMartinez/purescript-cookbook/master/recipes/PositionsReactHooks/src/Main.purs)) | [PositionsReactHooks](recipes/PositionsReactHooks) | A React port of the ["Random - Positions" Elm Example](https://elm-lang.org/examples/positions). |
| :heavy_check_mark: | | [RandomNumberGameNode](recipes/RandomNumberGameNode) | This recipe shows how to build a "guess the random number" game using a custom `AppM` monad via the `ReaderT` design pattern and `Aff`, storing the game state in a mutable variable via a `Ref`. |
Expand Down
27 changes: 26 additions & 1 deletion packages.dhall
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,31 @@ let upstream =

let overrides = {=}

let additions = {=}
let additions =
{ payload =
{ dependencies =
[ "aff"
, "affjax"
, "console"
, "debug"
, "effect"
, "foreign-generic"
, "node-fs"
, "node-fs-aff"
, "node-http"
, "prelude"
, "psci-support"
, "record"
, "simple-json"
, "stringutils"
, "test-unit"
, "typelevel-prelude"
]
, repo =
"https://github.com/hoodunit/purescript-payload"
, version =
"v0.3.0"
}
}

in upstream // overrides // additions
13 changes: 13 additions & 0 deletions recipes/PayloadHttpApiNode/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/bower_components/
/node_modules/
/.pulp-cache/
/output/
/generated-docs/
/.psc-package/
/.psc*
/.purs*
/.psa*
/.spago
/web-dist/
/prod-dist/
/prod/
27 changes: 27 additions & 0 deletions recipes/PayloadHttpApiNode/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# PayloadHttpApiNode

Implements a simple 'quote' API using the [payload](https://github.com/hoodunit/purescript-payload)
HTTP backend.
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this is on line 4, it won't be included when you run make readme. Remove the newline and let the text wrap.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, could you add something in its description that says this has similar design principles as Haskell's Servant?


## Expected Behavior:

### Node.js

HTTP server is started. You can call the API using your favorite HTTP client.
This example uses [httpie](https://httpie.org/):
```sh
# get all quotes
http get 'http://localhost:3000/quote'

# get the default initial quote
http get 'http://localhost:3000/quote/1'

# add a quote
echo "This is a new quote" | http post 'http://localhost:3000/quote'

# retrieve it
http get 'http://localhost:3000/quote/1'

# get all quotes again
http get 'http://localhost:3000/quote'
```
2 changes: 2 additions & 0 deletions recipes/PayloadHttpApiNode/nodeSupported.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
This file just indicates that the node backend is supported.
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This file should be named nodeSupportedSkipCI.md. Otherwise, CI will run this code and it will never terminate.

It is used for CI and autogeneration purposes.
5 changes: 5 additions & 0 deletions recipes/PayloadHttpApiNode/spago.dhall
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{ name = "PayloadHttpApiNode"
, dependencies = [ "console", "effect", "avar", "ordered-collections", "payload" ]
, packages = ../../packages.dhall
, sources = [ "recipes/PayloadHttpApiNode/src/**/*.purs" ]
}
124 changes: 124 additions & 0 deletions recipes/PayloadHttpApiNode/src/Main.purs
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
module PayloadHttpApiNode.Main where

import Prelude

import Data.Either (Either(..))
import Data.Map (Map)
import Data.Map as Map
import Data.Maybe (Maybe(..))
import Data.Tuple (Tuple(..))
import Effect (Effect)
import Effect.AVar (AVar)
import Effect.AVar as EffVar
import Effect.Aff (Aff)
import Effect.Aff.AVar as AffVar
import Payload.ResponseTypes (Failure(..))
import Payload.Server as Payload
import Payload.Spec (GET, Spec(Spec), POST)

-- | This is used as a return type for whenever we don't have something
-- | meaningful to return.
type StatusCodeResponse =
{ statusCode :: Int
, statusMessage :: String
}

-- | Our API is all about quotes, and this is how we represent a single quote.
type Quote =
{ text :: String
, id :: Int
}

-- | This fully describes our API as a big record under the 'Spec' constructor.
-- |
-- | Each label represents an endpoint in our quote API:
-- | 'quote' is a GET-by-id endpoint. We match the URL by the <id> parameter.
-- | 'addQUote' is a POST-to-insert a new quote. The body is just a string.
-- | 'getAll' is a GET everything in our quote 'database'.
-- |
-- | The response type is also encoded in each endpoint.
-- | Note that the implementation is trivial: this only represents type-level
-- | information used downstream.
spec
:: Spec
{ quote :: GET "/quote/<id>"
{ params :: { id :: Int }
, response :: String
}
, addQuote :: POST "/quote"
{ body :: String
, response :: StatusCodeResponse
}
, getAll :: GET "/quote"
{ response :: Array Quote
}
}
spec = Spec

-- | This is where everything comes together: we create a record of HANDLERS
-- | for each of the routes we defined in 'spec'.
-- | The types must follow the definitions, so for example 'quote' must take
-- | a record containing "params :: { id :: Int }".
-- |
-- | Note that we are taking an 'AVar (Map Int String)'. We do this in order to
-- | keep state across calls.
-- |
-- | AVars are variables that you can read and update within an 'Aff' context.
-- | The 'AVar' created in 'main' is threaded through 'handlers' to each
-- | individual handler.
handlers :: _
handlers quotes =
{ quote: quote quotes
, addQuote: addQuote quotes
, getAll: getAll quotes
}

-- | Main entry point: we just create a new 'AVar' (fake "database") and
-- | launch the payload server.
main :: Effect Unit
main = do
quotes <- EffVar.new $ Map.singleton 1 "This is a quote"
Payload.launch spec (handlers quotes)

-- | This represents our application's state. It's essentially our database
-- | (except it gets reset when application gets restarted).
type State = AVar (Map Int String)

-- | This is the handler for getting a quote by id.
-- | We start by reading the "database".
-- | If we can't find the requested 'id', then we just return an error.
-- | Otherwise, we return the requested quote.
quote :: State -> { params :: { id :: Int } } -> Aff (Either Failure String)
quote st { params : {id} } = do
quotes <- AffVar.read st
case Map.lookup id quotes of
Nothing -> pure $ Left $ Forward ""
Just v -> pure (pure v)

-- | Adding a quote requires us to 'block' reads/writes to our 'database', which
-- | is precisely what 'take' does.
-- | We then look for the highest id in the 'database' and increment it by one.
-- | We finish off by 'unblocking' and pushing our updated 'database'.
addQuote :: State -> { body :: String } -> Aff StatusCodeResponse
addQuote st { body } = do
quotes <- AffVar.take st
id <- case Map.findMax quotes of
Nothing -> pure 1
Just { key } -> pure (key + 1)
AffVar.put (Map.insert id body quotes) st
pure { statusCode: 200, statusMessage: body }

-- | We start by reading the current 'database'. Since we can't easily encode
-- | a Map structure directly, we transform it to an Array by calling
-- | 'toUnfoldable' (the map works over 'Aff').
-- | Our type now is 'Aff (Array (Tuple Int String))'. Unfortunately, 'Tuples'
-- | are also not trivial to encode, so we also need to 'map' over the
-- | Array and change all Tuples to records.
-- |
-- | We end up returning all data in the 'database' as an array of records.
getAll :: forall r. State -> { |r } -> Aff (Array Quote)
getAll st _ = map tupleToRecord <<< Map.toUnfoldable <$> AffVar.read st
where
tupleToRecord :: Tuple Int String -> Quote
tupleToRecord (Tuple id text) = {id, text}