Skip to content

Commit e10fad7

Browse files
Vladimir CiobanuVladimir Ciobanu
andauthored
Payload HTTP server backend recipe (#226)
* Payload HTTP server backend. * Address review comments. * Add a few details about payload. * Use package-set instead. * Whoops. * Restore package.json Co-authored-by: Vladimir Ciobanu <[email protected]>
1 parent 65a73f7 commit e10fad7

File tree

6 files changed

+174
-0
lines changed

6 files changed

+174
-0
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@ Running a web-compatible recipe:
129129
| :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. |
130130
| | :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). |
131131
| | :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). |
132+
| :heavy_check_mark: | | [PayloadHttpApiNode](recipes/PayloadHttpApiNode) | Implements a simple 'quote' API using the [payload](https://github.com/hoodunit/purescript-payload) HTTP backend. |
132133
| | :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). |
133134
| | :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). |
134135
| :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`. |

recipes/PayloadHttpApiNode/.gitignore

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
/bower_components/
2+
/node_modules/
3+
/.pulp-cache/
4+
/output/
5+
/generated-docs/
6+
/.psc-package/
7+
/.psc*
8+
/.purs*
9+
/.psa*
10+
/.spago
11+
/web-dist/
12+
/prod-dist/
13+
/prod/

recipes/PayloadHttpApiNode/README.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# PayloadHttpApiNode
2+
3+
Implements a simple 'quote' API using the [payload](https://github.com/hoodunit/purescript-payload) HTTP backend.
4+
5+
Payload uses type-level information in order to guide the user into creating
6+
safe handlers for the defined routes. The approach is similar to
7+
[servant](https://www.servant.dev/), but using Purescript's advantages such as
8+
row types.
9+
10+
## Expected Behavior:
11+
12+
### Node.js
13+
14+
HTTP server is started. You can call the API using your favorite HTTP client.
15+
This example uses [httpie](https://httpie.org/):
16+
```sh
17+
# get all quotes
18+
http get 'http://localhost:3000/quote'
19+
20+
# get the default initial quote
21+
http get 'http://localhost:3000/quote/1'
22+
23+
# add a quote
24+
echo "This is a new quote" | http post 'http://localhost:3000/quote'
25+
26+
# retrieve it
27+
http get 'http://localhost:3000/quote/1'
28+
29+
# get all quotes again
30+
http get 'http://localhost:3000/quote'
31+
```

recipes/PayloadHttpApiNode/nodeSupportedSkipCI.md

Whitespace-only changes.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{ name = "PayloadHttpApiNode"
2+
, dependencies = [ "console", "effect", "avar", "ordered-collections", "payload" ]
3+
, packages = ../../packages.dhall
4+
, sources = [ "recipes/PayloadHttpApiNode/src/**/*.purs" ]
5+
}
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
module PayloadHttpApiNode.Main where
2+
3+
import Prelude
4+
5+
import Data.Either (Either(..))
6+
import Data.Map (Map)
7+
import Data.Map as Map
8+
import Data.Maybe (Maybe(..))
9+
import Data.Tuple (Tuple(..))
10+
import Effect (Effect)
11+
import Effect.AVar (AVar)
12+
import Effect.AVar as EffVar
13+
import Effect.Aff (Aff)
14+
import Effect.Aff.AVar as AffVar
15+
import Payload.ResponseTypes (Failure(..))
16+
import Payload.Server as Payload
17+
import Payload.Spec (GET, Spec(Spec), POST)
18+
19+
-- | This is used as a return type for whenever we don't have something
20+
-- | meaningful to return.
21+
type StatusCodeResponse =
22+
{ statusCode :: Int
23+
, statusMessage :: String
24+
}
25+
26+
-- | Our API is all about quotes, and this is how we represent a single quote.
27+
type Quote =
28+
{ text :: String
29+
, id :: Int
30+
}
31+
32+
-- | This fully describes our API as a big record under the 'Spec' constructor.
33+
-- |
34+
-- | Each label represents an endpoint in our quote API:
35+
-- | 'quote' is a GET-by-id endpoint. We match the URL by the <id> parameter.
36+
-- | 'addQUote' is a POST-to-insert a new quote. The body is just a string.
37+
-- | 'getAll' is a GET everything in our quote 'database'.
38+
-- |
39+
-- | The response type is also encoded in each endpoint.
40+
-- | Note that the implementation is trivial: this only represents type-level
41+
-- | information used downstream.
42+
spec
43+
:: Spec
44+
{ quote :: GET "/quote/<id>"
45+
{ params :: { id :: Int }
46+
, response :: String
47+
}
48+
, addQuote :: POST "/quote"
49+
{ body :: String
50+
, response :: StatusCodeResponse
51+
}
52+
, getAll :: GET "/quote"
53+
{ response :: Array Quote
54+
}
55+
}
56+
spec = Spec
57+
58+
-- | This is where everything comes together: we create a record of HANDLERS
59+
-- | for each of the routes we defined in 'spec'.
60+
-- | The types must follow the definitions, so for example 'quote' must take
61+
-- | a record containing "params :: { id :: Int }".
62+
-- |
63+
-- | Note that we are taking an 'AVar (Map Int String)'. We do this in order to
64+
-- | keep state across calls.
65+
-- |
66+
-- | AVars are variables that you can read and update within an 'Aff' context.
67+
-- | The 'AVar' created in 'main' is threaded through 'handlers' to each
68+
-- | individual handler.
69+
handlers :: _
70+
handlers quotes =
71+
{ quote: quote quotes
72+
, addQuote: addQuote quotes
73+
, getAll: getAll quotes
74+
}
75+
76+
-- | Main entry point: we just create a new 'AVar' (fake "database") and
77+
-- | launch the payload server.
78+
main :: Effect Unit
79+
main = do
80+
quotes <- EffVar.new $ Map.singleton 1 "This is a quote"
81+
Payload.launch spec (handlers quotes)
82+
83+
-- | This represents our application's state. It's essentially our database
84+
-- | (except it gets reset when application gets restarted).
85+
type State = AVar (Map Int String)
86+
87+
-- | This is the handler for getting a quote by id.
88+
-- | We start by reading the "database".
89+
-- | If we can't find the requested 'id', then we just return an error.
90+
-- | Otherwise, we return the requested quote.
91+
quote :: State -> { params :: { id :: Int } } -> Aff (Either Failure String)
92+
quote st { params : {id} } = do
93+
quotes <- AffVar.read st
94+
case Map.lookup id quotes of
95+
Nothing -> pure $ Left $ Forward ""
96+
Just v -> pure (pure v)
97+
98+
-- | Adding a quote requires us to 'block' reads/writes to our 'database', which
99+
-- | is precisely what 'take' does.
100+
-- | We then look for the highest id in the 'database' and increment it by one.
101+
-- | We finish off by 'unblocking' and pushing our updated 'database'.
102+
addQuote :: State -> { body :: String } -> Aff StatusCodeResponse
103+
addQuote st { body } = do
104+
quotes <- AffVar.take st
105+
id <- case Map.findMax quotes of
106+
Nothing -> pure 1
107+
Just { key } -> pure (key + 1)
108+
AffVar.put (Map.insert id body quotes) st
109+
pure { statusCode: 200, statusMessage: body }
110+
111+
-- | We start by reading the current 'database'. Since we can't easily encode
112+
-- | a Map structure directly, we transform it to an Array by calling
113+
-- | 'toUnfoldable' (the map works over 'Aff').
114+
-- | Our type now is 'Aff (Array (Tuple Int String))'. Unfortunately, 'Tuples'
115+
-- | are also not trivial to encode, so we also need to 'map' over the
116+
-- | Array and change all Tuples to records.
117+
-- |
118+
-- | We end up returning all data in the 'database' as an array of records.
119+
getAll :: forall r. State -> { |r } -> Aff (Array Quote)
120+
getAll st _ = map tupleToRecord <<< Map.toUnfoldable <$> AffVar.read st
121+
where
122+
tupleToRecord :: Tuple Int String -> Quote
123+
tupleToRecord (Tuple id text) = {id, text}
124+

0 commit comments

Comments
 (0)