├── .dtp
├── soakbean.gif
└── soakbean.jpg
├── Dockerfile
├── README.md
├── make
├── middleware
├── blacklisturl.lua
└── json.lua
├── soakbean.com
└── src
├── .init.lua
├── .lua
├── json.lua
└── soakbean.lua
├── data.lua
├── index.html
└── tests.html
/.dtp/soakbean.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/coderofsalvation/soakbean/d669e21e1afcc3cf69cb93661ad74515e24d8601/.dtp/soakbean.gif
--------------------------------------------------------------------------------
/.dtp/soakbean.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/coderofsalvation/soakbean/d669e21e1afcc3cf69cb93661ad74515e24d8601/.dtp/soakbean.jpg
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | # syntax=docker/dockerfile:1
2 | FROM scratch
3 | ADD soakbean.com /
4 |
5 | CMD ["/soakbean.com"]
6 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | ## Reactive plugnplay middleware for redbean
4 |
5 | Write beautiful ~2.3MB [redbean (docker) apps](https://redbean.dev) like this [.init.lua](src/.init.lua):
6 |
7 | ## Beautiful micro stack
8 |
9 |
10 |
11 | * re-use middleware functions across redbean projects
12 | * reactive programming (write less code)
13 | * easy express-style routing
14 | * easily adapt to redbean API changes
15 |
16 | ## Syntactic sugar
17 |
18 | ```lua
19 | app = require("soakbean") {
20 | bin = "./soakbean.com",
21 | opts = { my_cli_arg=0 },
22 | cmd={
23 | -- runtask = {file="sometask.lua", info="description of cli cmd"}
24 | },
25 | title = 'SOAKBEAN - a buddy of redbean',
26 | }
27 |
28 | app.url['^/data'] = '/data.lua' -- setup custom file endpoint
29 | --
30 | app.get('^/', app.template('index.html') ) -- alias for app.tpl( LoadAsset('index.html'), app )
31 | -- also see app.post app.put and so on
32 | app --
33 | .use( require("json").middleware() ) -- try plug'n'play json API middleware
34 | .use( app.router( app.url ) ) -- try url router
35 | .use( app.response() ) -- try serve app response (if any)
36 | .use( function(req,next) Route() end) -- fallback default redbean fileserver
37 |
38 | function OnHttpRequest() app.run() end
39 | ```
40 |
41 | Just run it straight from the repository using [redbean.com](https://redbean.dev) itself:
42 |
43 | ```
44 | $ git clone https://github.com/coderofsalvation/soakbean && cd src
45 | $ redbean.com -D . --my-cli-arg=abc
46 | ```
47 |
48 | Then you can easily package the current directory into your own (renamed redbean) COM-file:
49 |
50 | ```
51 | $ sed -i 's/NAME=soakbean/NAME=yourapp/g'
52 | $ ./make all
53 | $ ./yourapp.com --my-cli-arg=abc
54 | ```
55 |
56 | > Profit!
57 |
58 |
59 | ## Getting started
60 |
61 | | Lazy | Recommended | Docker |
62 | |-|-|-|
63 | | download [soakbean.com](https://github.com/coderofsalvation/soakbean/raw/master/soakbean.com), add html (and/or lua) files using a zip-filemanager, and run `./soakbean.com` | download [redbean.com](https://redbean.dev) and run it in the `src` folder of this repo (see above cmdline) | clone this repo and run `./make docker` and surf to `http://localhost:8080` |
64 |
65 | > middleware: copy [middleware](middleware) functions to `src/.lua`-folder where needed
66 |
67 | ## Cute simple backend<->frontend traffic
68 |
69 | Just look at how cute this [index.html](src/index.html) combines serverside templating with RESTful & DOM-reactive templating:
70 |
71 | ```
72 |
${title} <-- evaluated serverside -->
73 | <-- evaluated clientside -->
74 | <-- evaluated clientside using REST call to server -->
75 |
76 | ```
77 |
78 |
79 | ## Middleware functions
80 |
81 | You can easily manipulate the http-request flow, using existing middleware functions:
82 |
83 | ```lua
84 | app.use(
85 | require("blacklisturl")({
86 | "^/secret/",
87 | "^/me-fainting-next-to-justinbieber.mp4"
88 | })
89 | )
90 | ```
91 |
92 | > make sure you copy [middleware/blacklisturl.lua](middleware/blacklisturl.lua) to [src/.lua](src/.lua)
93 |
94 | or just write ad-hoc middleware:
95 |
96 | ```lua
97 | app.use( function(req,res,next)
98 | res.status(200)
99 | res.header('content-type','text/html')
100 | res.body('hello world')
101 | next() -- comment this to prevent further middleware altering body, status headers e.g.
102 | end)
103 | ```
104 |
105 | ```lua
106 | app.use( function(req,res,next)
107 | if !req.loggedin && req.url:match("^/mydata") then
108 | res.status(403)
109 | else next()
110 | end)
111 | ```
112 |
113 |
114 | > WANTED: please contribute your [middleware](middleware) functions by pushing repositories with nameconvention `soakbean-middleware-`. Everybody loves (re)using battle-tested middleware.
115 |
116 | ## req & res object
117 |
118 | | key | type | alias for redbean |
119 | |-|-|-|
120 | | `req.method` | string | `GetMethod()` |
121 | | `req.url` | string | `GetPath()` |
122 | | `req.param` | table | `GetParams()` |
123 | | `req.host` | string | `GetHost()` |
124 | | `req.header` | table | `GetHeaders()` |
125 | | `req.protocol` | string | `GetScheme()` |
126 | | `req.body` | table or string | |
127 | | `res.body(value)` | string | `Write(value) including auto-encoding (json e.g.)` |
128 | | `res.status(code)` | int | `SetStatus(code)` |
129 | | `res.header(type,value)` | string,string | `SetHeader(type,value)` |
130 |
131 | ## Simple template evaluation
132 |
133 | index.html
134 | ```
135 | ${title}
136 | ```
137 |
138 | lua
139 | ```
140 | app.title = "hello world"
141 | app.get('^/', app.template('index.html') )
142 | ```
143 |
144 | > NOTE: this is basically serving the output of `app.tpl( LoadAsset('index.html'), app )`
145 |
146 | ## Reactive programming
147 |
148 | #### react to variable changes:
149 |
150 | ```lua
151 | app.on("foo", function(k,v)
152 | print("appname changed: " .. v)
153 | end)
154 |
155 | app.foo = "flop" -- output: appname changed: flop
156 | app.foo = "bar" -- output: appname changed: bar
157 | ```
158 |
159 | #### react to function calls
160 |
161 | ```lua
162 | app.on('foobar', function(a)
163 | print("!")
164 | end)
165 |
166 | app.foobar = function(a)
167 | print('foobar')
168 | end
169 |
170 | app.foobar() -- output: foobar!
171 | ```
172 |
173 | #### react to router patterns
174 |
175 | ```lua
176 | app.url['^/foo'] = '/somefile.lua'
177 |
178 | app.on('^/foo', function(a,b)
179 | -- do something
180 | end)
181 | ```
182 |
183 | #### react to luafile endpoint execution
184 |
185 | ```lua
186 | app.url['^/foo'] = '/somefile.lua'
187 |
188 | app.on('somefile.lua', function(a,b)
189 | -- do something
190 | end)
191 | ```
192 |
193 | #### react to response code/header/body changes
194 |
195 | ```lua
196 | app.on('res.status', print )
197 | app.on('res.body' , print )
198 | app.on('res.header', function(k,v)
199 | print(k .. " => " .. v)
200 | end)
201 | ```
202 |
203 | > NOTE: above is handy for debugging a flow. Use `app.use(..)` for more control.
204 |
205 | ## Roadmap / Scope
206 |
207 | * scope is backend, not frontend
208 | * http auth (*)
209 | * middleware: sqlite user sessions (*)
210 | * middleware: sqlite tiny job queue (*)
211 | * middleware: sqlite tiny rule engine (*)
212 | * middleware: sqlite CRUD middleware (endpoints + sqlite schema derived from jsonschema) (*)
213 |
214 | \* = please contribute! =]
215 |
--------------------------------------------------------------------------------
/make:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | REDBEAN_STATIC=redbean.com
3 | VERSION=2.2
4 | NAME=soakbean
5 |
6 | to_elf(){
7 | zip -d $NAME.com .ape
8 | zip -sf $NAME.com
9 | ./$NAME.com -h &>/dev/null
10 | }
11 |
12 | all(){
13 | set -x
14 | [[ ! -f $REDBEAN_STATIC ]] && wget https://redbean.dev/redbean-${VERSION}.com -O $REDBEAN_STATIC
15 | [[ -f $REDBEAN_STATIC ]] && cp $REDBEAN_STATIC $NAME.com
16 | cp middleware/json.lua src/.lua/.
17 | cd src
18 | zip -q -r ../$NAME.com * .lua .init.lua
19 | chmod 755 ../$NAME.com
20 | set +x
21 | cd -
22 | ls -lah $NAME.com
23 | }
24 |
25 | docker(){
26 | docker=$(which docker)
27 | to_elf
28 | set -x
29 | $docker build . -t $NAME && \
30 | $docker images | grep $NAME && \
31 | $docker run -p 8080:8080 $NAME
32 | }
33 |
34 | [[ ! -n $1 ]] && { echo "usage: ./make all && ./soakbean.com"; exit 0; }
35 | "$@"
36 |
--------------------------------------------------------------------------------
/middleware/blacklisturl.lua:
--------------------------------------------------------------------------------
1 | -- blocks certain url patterns
2 | -- usage:
3 | -- app.use( require("middleware/blacklisturl")({"^/foo","/^bar"}) )
4 | --
5 | return function(urls)
6 | return function(req,res,next)
7 | for k,url in pairs(urls) do
8 | if req.url:match(url) then return SetStatus(403) end
9 | end
10 | next()
11 | end
12 | end
13 |
--------------------------------------------------------------------------------
/middleware/json.lua:
--------------------------------------------------------------------------------
1 | --
2 | -- json.lua
3 | --
4 | -- Copyright (c) 2020 rxi
5 | --
6 | -- Permission is hereby granted, free of charge, to any person obtaining a copy of
7 | -- this software and associated documentation files (the "Software"), to deal in
8 | -- the Software without restriction, including without limitation the rights to
9 | -- use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
10 | -- of the Software, and to permit persons to whom the Software is furnished to do
11 | -- so, subject to the following conditions:
12 | --
13 | -- The above copyright notice and this permission notice shall be included in all
14 | -- copies or substantial portions of the Software.
15 | --
16 | -- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 | -- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 | -- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 | -- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 | -- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 | -- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 | -- SOFTWARE.
23 | --
24 |
25 | local json = { _version = "0.1.2" }
26 |
27 | -------------------------------------------------------------------------------
28 | -- Encode
29 | -------------------------------------------------------------------------------
30 |
31 | local encode
32 |
33 | local escape_char_map = {
34 | [ "\\" ] = "\\",
35 | [ "\"" ] = "\"",
36 | [ "\b" ] = "b",
37 | [ "\f" ] = "f",
38 | [ "\n" ] = "n",
39 | [ "\r" ] = "r",
40 | [ "\t" ] = "t",
41 | }
42 |
43 | local escape_char_map_inv = { [ "/" ] = "/" }
44 | for k, v in pairs(escape_char_map) do
45 | escape_char_map_inv[v] = k
46 | end
47 |
48 |
49 | local function escape_char(c)
50 | return "\\" .. (escape_char_map[c] or string.format("u%04x", c:byte()))
51 | end
52 |
53 |
54 | local function encode_nil(val)
55 | return "null"
56 | end
57 |
58 |
59 | local function encode_table(val, stack)
60 | local res = {}
61 | stack = stack or {}
62 |
63 | -- Circular reference?
64 | if stack[val] then error("circular reference") end
65 |
66 | stack[val] = true
67 |
68 | if rawget(val, 1) ~= nil or next(val) == nil then
69 | -- Treat as array -- check keys are valid and it is not sparse
70 | local n = 0
71 | for k in pairs(val) do
72 | if type(k) ~= "number" then
73 | error("invalid table: mixed or invalid key types")
74 | end
75 | n = n + 1
76 | end
77 | if n ~= #val then
78 | error("invalid table: sparse array")
79 | end
80 | -- Encode
81 | for i, v in ipairs(val) do
82 | table.insert(res, encode(v, stack))
83 | end
84 | stack[val] = nil
85 | return "[" .. table.concat(res, ",") .. "]"
86 |
87 | else
88 | -- Treat as an object
89 | for k, v in pairs(val) do
90 | if type(k) ~= "string" then
91 | error("invalid table: mixed or invalid key types")
92 | end
93 | table.insert(res, encode(k, stack) .. ":" .. encode(v, stack))
94 | end
95 | stack[val] = nil
96 | return "{" .. table.concat(res, ",") .. "}"
97 | end
98 | end
99 |
100 |
101 | local function encode_string(val)
102 | return '"' .. val:gsub('[%z\1-\31\\"]', escape_char) .. '"'
103 | end
104 |
105 |
106 | local function encode_number(val)
107 | -- Check for NaN, -inf and inf
108 | if val ~= val or val <= -math.huge or val >= math.huge then
109 | error("unexpected number value '" .. tostring(val) .. "'")
110 | end
111 | return string.format("%.14g", val)
112 | end
113 |
114 |
115 | local type_func_map = {
116 | [ "nil" ] = encode_nil,
117 | [ "table" ] = encode_table,
118 | [ "string" ] = encode_string,
119 | [ "number" ] = encode_number,
120 | [ "boolean" ] = tostring,
121 | }
122 |
123 |
124 | encode = function(val, stack)
125 | local t = type(val)
126 | local f = type_func_map[t]
127 | if f then
128 | return f(val, stack)
129 | end
130 | error("unexpected type '" .. t .. "'")
131 | end
132 |
133 |
134 | function json.encode(val)
135 | return ( encode(val) )
136 | end
137 |
138 |
139 | -------------------------------------------------------------------------------
140 | -- Decode
141 | -------------------------------------------------------------------------------
142 |
143 | local parse
144 |
145 | local function create_set(...)
146 | local res = {}
147 | for i = 1, select("#", ...) do
148 | res[ select(i, ...) ] = true
149 | end
150 | return res
151 | end
152 |
153 | local space_chars = create_set(" ", "\t", "\r", "\n")
154 | local delim_chars = create_set(" ", "\t", "\r", "\n", "]", "}", ",")
155 | local escape_chars = create_set("\\", "/", '"', "b", "f", "n", "r", "t", "u")
156 | local literals = create_set("true", "false", "null")
157 |
158 | local literal_map = {
159 | [ "true" ] = true,
160 | [ "false" ] = false,
161 | [ "null" ] = nil,
162 | }
163 |
164 |
165 | local function next_char(str, idx, set, negate)
166 | for i = idx, #str do
167 | if set[str:sub(i, i)] ~= negate then
168 | return i
169 | end
170 | end
171 | return #str + 1
172 | end
173 |
174 |
175 | local function decode_error(str, idx, msg)
176 | local line_count = 1
177 | local col_count = 1
178 | for i = 1, idx - 1 do
179 | col_count = col_count + 1
180 | if str:sub(i, i) == "\n" then
181 | line_count = line_count + 1
182 | col_count = 1
183 | end
184 | end
185 | error( string.format("%s at line %d col %d", msg, line_count, col_count) )
186 | end
187 |
188 |
189 | local function codepoint_to_utf8(n)
190 | -- http://scripts.sil.org/cms/scripts/page.php?site_id=nrsi&id=iws-appendixa
191 | local f = math.floor
192 | if n <= 0x7f then
193 | return string.char(n)
194 | elseif n <= 0x7ff then
195 | return string.char(f(n / 64) + 192, n % 64 + 128)
196 | elseif n <= 0xffff then
197 | return string.char(f(n / 4096) + 224, f(n % 4096 / 64) + 128, n % 64 + 128)
198 | elseif n <= 0x10ffff then
199 | return string.char(f(n / 262144) + 240, f(n % 262144 / 4096) + 128,
200 | f(n % 4096 / 64) + 128, n % 64 + 128)
201 | end
202 | error( string.format("invalid unicode codepoint '%x'", n) )
203 | end
204 |
205 |
206 | local function parse_unicode_escape(s)
207 | local n1 = tonumber( s:sub(1, 4), 16 )
208 | local n2 = tonumber( s:sub(7, 10), 16 )
209 | -- Surrogate pair?
210 | if n2 then
211 | return codepoint_to_utf8((n1 - 0xd800) * 0x400 + (n2 - 0xdc00) + 0x10000)
212 | else
213 | return codepoint_to_utf8(n1)
214 | end
215 | end
216 |
217 |
218 | local function parse_string(str, i)
219 | local res = ""
220 | local j = i + 1
221 | local k = j
222 |
223 | while j <= #str do
224 | local x = str:byte(j)
225 |
226 | if x < 32 then
227 | decode_error(str, j, "control character in string")
228 |
229 | elseif x == 92 then -- `\`: Escape
230 | res = res .. str:sub(k, j - 1)
231 | j = j + 1
232 | local c = str:sub(j, j)
233 | if c == "u" then
234 | local hex = str:match("^[dD][89aAbB]%x%x\\u%x%x%x%x", j + 1)
235 | or str:match("^%x%x%x%x", j + 1)
236 | or decode_error(str, j - 1, "invalid unicode escape in string")
237 | res = res .. parse_unicode_escape(hex)
238 | j = j + #hex
239 | else
240 | if not escape_chars[c] then
241 | decode_error(str, j - 1, "invalid escape char '" .. c .. "' in string")
242 | end
243 | res = res .. escape_char_map_inv[c]
244 | end
245 | k = j + 1
246 |
247 | elseif x == 34 then -- `"`: End of string
248 | res = res .. str:sub(k, j - 1)
249 | return res, j + 1
250 | end
251 |
252 | j = j + 1
253 | end
254 |
255 | decode_error(str, i, "expected closing quote for string")
256 | end
257 |
258 |
259 | local function parse_number(str, i)
260 | local x = next_char(str, i, delim_chars)
261 | local s = str:sub(i, x - 1)
262 | local n = tonumber(s)
263 | if not n then
264 | decode_error(str, i, "invalid number '" .. s .. "'")
265 | end
266 | return n, x
267 | end
268 |
269 |
270 | local function parse_literal(str, i)
271 | local x = next_char(str, i, delim_chars)
272 | local word = str:sub(i, x - 1)
273 | if not literals[word] then
274 | decode_error(str, i, "invalid literal '" .. word .. "'")
275 | end
276 | return literal_map[word], x
277 | end
278 |
279 |
280 | local function parse_array(str, i)
281 | local res = {}
282 | local n = 1
283 | i = i + 1
284 | while 1 do
285 | local x
286 | i = next_char(str, i, space_chars, true)
287 | -- Empty / end of array?
288 | if str:sub(i, i) == "]" then
289 | i = i + 1
290 | break
291 | end
292 | -- Read token
293 | x, i = parse(str, i)
294 | res[n] = x
295 | n = n + 1
296 | -- Next token
297 | i = next_char(str, i, space_chars, true)
298 | local chr = str:sub(i, i)
299 | i = i + 1
300 | if chr == "]" then break end
301 | if chr ~= "," then decode_error(str, i, "expected ']' or ','") end
302 | end
303 | return res, i
304 | end
305 |
306 |
307 | local function parse_object(str, i)
308 | local res = {}
309 | i = i + 1
310 | while 1 do
311 | local key, val
312 | i = next_char(str, i, space_chars, true)
313 | -- Empty / end of object?
314 | if str:sub(i, i) == "}" then
315 | i = i + 1
316 | break
317 | end
318 | -- Read key
319 | if str:sub(i, i) ~= '"' then
320 | decode_error(str, i, "expected string for key")
321 | end
322 | key, i = parse(str, i)
323 | -- Read ':' delimiter
324 | i = next_char(str, i, space_chars, true)
325 | if str:sub(i, i) ~= ":" then
326 | decode_error(str, i, "expected ':' after key")
327 | end
328 | i = next_char(str, i + 1, space_chars, true)
329 | -- Read value
330 | val, i = parse(str, i)
331 | -- Set
332 | res[key] = val
333 | -- Next token
334 | i = next_char(str, i, space_chars, true)
335 | local chr = str:sub(i, i)
336 | i = i + 1
337 | if chr == "}" then break end
338 | if chr ~= "," then decode_error(str, i, "expected '}' or ','") end
339 | end
340 | return res, i
341 | end
342 |
343 |
344 | local char_func_map = {
345 | [ '"' ] = parse_string,
346 | [ "0" ] = parse_number,
347 | [ "1" ] = parse_number,
348 | [ "2" ] = parse_number,
349 | [ "3" ] = parse_number,
350 | [ "4" ] = parse_number,
351 | [ "5" ] = parse_number,
352 | [ "6" ] = parse_number,
353 | [ "7" ] = parse_number,
354 | [ "8" ] = parse_number,
355 | [ "9" ] = parse_number,
356 | [ "-" ] = parse_number,
357 | [ "t" ] = parse_literal,
358 | [ "f" ] = parse_literal,
359 | [ "n" ] = parse_literal,
360 | [ "[" ] = parse_array,
361 | [ "{" ] = parse_object,
362 | }
363 |
364 |
365 | parse = function(str, idx)
366 | local chr = str:sub(idx, idx)
367 | local f = char_func_map[chr]
368 | if f then
369 | return f(str, idx)
370 | end
371 | decode_error(str, idx, "unexpected character '" .. chr .. "'")
372 | end
373 |
374 |
375 | function json.decode(str)
376 | if type(str) ~= "string" then
377 | error("expected argument of type string, got " .. type(str))
378 | end
379 | local res, idx = parse(str, next_char(str, 1, space_chars, true))
380 | idx = next_char(str, idx, space_chars, true)
381 | if idx <= #str then
382 | decode_error(str, idx, "trailing garbage")
383 | end
384 | return res
385 | end
386 |
387 | function json.middleware()
388 | local json_response = function(response)
389 | return function()
390 | return function(req,res,next)
391 | if type(res._body) == "table" then
392 | res.header('content-type',"application/json")
393 | res.body( json.encode(res._body) )
394 | end
395 | response(req,res,next)
396 | end
397 | end
398 | end
399 | sb.response = json_response(sb.response())
400 | return function(req,res,next)
401 | if req.method ~= "GET" and req.header['Content-Type']:match("application/json") and GetPayload():sub(0,1) == "{" then
402 | req.body = json.decode( GetPayload() )
403 | end
404 | next()
405 | end
406 | end
407 |
408 | return json
409 |
--------------------------------------------------------------------------------
/soakbean.com:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/coderofsalvation/soakbean/d669e21e1afcc3cf69cb93661ad74515e24d8601/soakbean.com
--------------------------------------------------------------------------------
/src/.init.lua:
--------------------------------------------------------------------------------
1 | package.path = package.path .. ";.lua/?.lua"
2 | package.path = package.path .. ";src/.lua/?.lua"
3 | package.path = package.path .. ";middleware/?.lua"
4 |
5 | json = require "json"
6 |
7 | -- special script called by main redbean process at startup
8 | HidePath('/usr/share/zoneinfo/')
9 | HidePath('/usr/share/ssl/')
10 |
11 | -- create
12 | app = require("soakbean") {
13 | bin = "./redbean.com",
14 | opts = {
15 | my_cli_arg=1
16 | },
17 | cmd={
18 | -- runtask = {file="sometask.lua", info="description of cli cmd"}
19 | },
20 | title = 'SOAKBEAN - a buddy of redbean',
21 | subtitle = 'SOAKBEAN makes redbean programming easy',
22 | notes = {'🤩 express-style programming', '🖧 easy routings', '♻ re-use middleware functions'}
23 | }
24 |
25 | app.url['^/data'] = '/data.lua' -- setup custom file endpoint
26 |
27 | app.get('^/', app.template('index.html') ) -- alias for app.tpl( LoadAsset('index.html'), app )
28 |
29 | app.post('^/save', function(req,res,next) -- setup inline POST endpoint
30 | -- also .get(), .put(), .delete(), .options()
31 | app.cache = req.body -- middleware auto-decodes json
32 | res.status(200)
33 | res.body({cache=app.cache}) -- middleware auto-encodes json
34 | next()
35 | end)
36 |
37 | app --
38 | .use( require("json").middleware() ) -- try plug'n'play json API middleware
39 | .use( app.router( app.url ) ) -- try url router
40 | .use( app.response() ) -- try serve app response (if any)
41 | .use( function(req,next) Route() end) -- fallback default redbean fileserver
42 |
43 | function OnHttpRequest() app.run() end
44 |
--------------------------------------------------------------------------------
/src/.lua/json.lua:
--------------------------------------------------------------------------------
1 | --
2 | -- json.lua
3 | --
4 | -- Copyright (c) 2020 rxi
5 | --
6 | -- Permission is hereby granted, free of charge, to any person obtaining a copy of
7 | -- this software and associated documentation files (the "Software"), to deal in
8 | -- the Software without restriction, including without limitation the rights to
9 | -- use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
10 | -- of the Software, and to permit persons to whom the Software is furnished to do
11 | -- so, subject to the following conditions:
12 | --
13 | -- The above copyright notice and this permission notice shall be included in all
14 | -- copies or substantial portions of the Software.
15 | --
16 | -- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 | -- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 | -- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 | -- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 | -- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 | -- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 | -- SOFTWARE.
23 | --
24 |
25 | local json = { _version = "0.1.2" }
26 |
27 | -------------------------------------------------------------------------------
28 | -- Encode
29 | -------------------------------------------------------------------------------
30 |
31 | local encode
32 |
33 | local escape_char_map = {
34 | [ "\\" ] = "\\",
35 | [ "\"" ] = "\"",
36 | [ "\b" ] = "b",
37 | [ "\f" ] = "f",
38 | [ "\n" ] = "n",
39 | [ "\r" ] = "r",
40 | [ "\t" ] = "t",
41 | }
42 |
43 | local escape_char_map_inv = { [ "/" ] = "/" }
44 | for k, v in pairs(escape_char_map) do
45 | escape_char_map_inv[v] = k
46 | end
47 |
48 |
49 | local function escape_char(c)
50 | return "\\" .. (escape_char_map[c] or string.format("u%04x", c:byte()))
51 | end
52 |
53 |
54 | local function encode_nil(val)
55 | return "null"
56 | end
57 |
58 |
59 | local function encode_table(val, stack)
60 | local res = {}
61 | stack = stack or {}
62 |
63 | -- Circular reference?
64 | if stack[val] then error("circular reference") end
65 |
66 | stack[val] = true
67 |
68 | if rawget(val, 1) ~= nil or next(val) == nil then
69 | -- Treat as array -- check keys are valid and it is not sparse
70 | local n = 0
71 | for k in pairs(val) do
72 | if type(k) ~= "number" then
73 | error("invalid table: mixed or invalid key types")
74 | end
75 | n = n + 1
76 | end
77 | if n ~= #val then
78 | error("invalid table: sparse array")
79 | end
80 | -- Encode
81 | for i, v in ipairs(val) do
82 | table.insert(res, encode(v, stack))
83 | end
84 | stack[val] = nil
85 | return "[" .. table.concat(res, ",") .. "]"
86 |
87 | else
88 | -- Treat as an object
89 | for k, v in pairs(val) do
90 | if type(k) ~= "string" then
91 | error("invalid table: mixed or invalid key types")
92 | end
93 | table.insert(res, encode(k, stack) .. ":" .. encode(v, stack))
94 | end
95 | stack[val] = nil
96 | return "{" .. table.concat(res, ",") .. "}"
97 | end
98 | end
99 |
100 |
101 | local function encode_string(val)
102 | return '"' .. val:gsub('[%z\1-\31\\"]', escape_char) .. '"'
103 | end
104 |
105 |
106 | local function encode_number(val)
107 | -- Check for NaN, -inf and inf
108 | if val ~= val or val <= -math.huge or val >= math.huge then
109 | error("unexpected number value '" .. tostring(val) .. "'")
110 | end
111 | return string.format("%.14g", val)
112 | end
113 |
114 |
115 | local type_func_map = {
116 | [ "nil" ] = encode_nil,
117 | [ "table" ] = encode_table,
118 | [ "string" ] = encode_string,
119 | [ "number" ] = encode_number,
120 | [ "boolean" ] = tostring,
121 | }
122 |
123 |
124 | encode = function(val, stack)
125 | local t = type(val)
126 | local f = type_func_map[t]
127 | if f then
128 | return f(val, stack)
129 | end
130 | error("unexpected type '" .. t .. "'")
131 | end
132 |
133 |
134 | function json.encode(val)
135 | return ( encode(val) )
136 | end
137 |
138 |
139 | -------------------------------------------------------------------------------
140 | -- Decode
141 | -------------------------------------------------------------------------------
142 |
143 | local parse
144 |
145 | local function create_set(...)
146 | local res = {}
147 | for i = 1, select("#", ...) do
148 | res[ select(i, ...) ] = true
149 | end
150 | return res
151 | end
152 |
153 | local space_chars = create_set(" ", "\t", "\r", "\n")
154 | local delim_chars = create_set(" ", "\t", "\r", "\n", "]", "}", ",")
155 | local escape_chars = create_set("\\", "/", '"', "b", "f", "n", "r", "t", "u")
156 | local literals = create_set("true", "false", "null")
157 |
158 | local literal_map = {
159 | [ "true" ] = true,
160 | [ "false" ] = false,
161 | [ "null" ] = nil,
162 | }
163 |
164 |
165 | local function next_char(str, idx, set, negate)
166 | for i = idx, #str do
167 | if set[str:sub(i, i)] ~= negate then
168 | return i
169 | end
170 | end
171 | return #str + 1
172 | end
173 |
174 |
175 | local function decode_error(str, idx, msg)
176 | local line_count = 1
177 | local col_count = 1
178 | for i = 1, idx - 1 do
179 | col_count = col_count + 1
180 | if str:sub(i, i) == "\n" then
181 | line_count = line_count + 1
182 | col_count = 1
183 | end
184 | end
185 | error( string.format("%s at line %d col %d", msg, line_count, col_count) )
186 | end
187 |
188 |
189 | local function codepoint_to_utf8(n)
190 | -- http://scripts.sil.org/cms/scripts/page.php?site_id=nrsi&id=iws-appendixa
191 | local f = math.floor
192 | if n <= 0x7f then
193 | return string.char(n)
194 | elseif n <= 0x7ff then
195 | return string.char(f(n / 64) + 192, n % 64 + 128)
196 | elseif n <= 0xffff then
197 | return string.char(f(n / 4096) + 224, f(n % 4096 / 64) + 128, n % 64 + 128)
198 | elseif n <= 0x10ffff then
199 | return string.char(f(n / 262144) + 240, f(n % 262144 / 4096) + 128,
200 | f(n % 4096 / 64) + 128, n % 64 + 128)
201 | end
202 | error( string.format("invalid unicode codepoint '%x'", n) )
203 | end
204 |
205 |
206 | local function parse_unicode_escape(s)
207 | local n1 = tonumber( s:sub(1, 4), 16 )
208 | local n2 = tonumber( s:sub(7, 10), 16 )
209 | -- Surrogate pair?
210 | if n2 then
211 | return codepoint_to_utf8((n1 - 0xd800) * 0x400 + (n2 - 0xdc00) + 0x10000)
212 | else
213 | return codepoint_to_utf8(n1)
214 | end
215 | end
216 |
217 |
218 | local function parse_string(str, i)
219 | local res = ""
220 | local j = i + 1
221 | local k = j
222 |
223 | while j <= #str do
224 | local x = str:byte(j)
225 |
226 | if x < 32 then
227 | decode_error(str, j, "control character in string")
228 |
229 | elseif x == 92 then -- `\`: Escape
230 | res = res .. str:sub(k, j - 1)
231 | j = j + 1
232 | local c = str:sub(j, j)
233 | if c == "u" then
234 | local hex = str:match("^[dD][89aAbB]%x%x\\u%x%x%x%x", j + 1)
235 | or str:match("^%x%x%x%x", j + 1)
236 | or decode_error(str, j - 1, "invalid unicode escape in string")
237 | res = res .. parse_unicode_escape(hex)
238 | j = j + #hex
239 | else
240 | if not escape_chars[c] then
241 | decode_error(str, j - 1, "invalid escape char '" .. c .. "' in string")
242 | end
243 | res = res .. escape_char_map_inv[c]
244 | end
245 | k = j + 1
246 |
247 | elseif x == 34 then -- `"`: End of string
248 | res = res .. str:sub(k, j - 1)
249 | return res, j + 1
250 | end
251 |
252 | j = j + 1
253 | end
254 |
255 | decode_error(str, i, "expected closing quote for string")
256 | end
257 |
258 |
259 | local function parse_number(str, i)
260 | local x = next_char(str, i, delim_chars)
261 | local s = str:sub(i, x - 1)
262 | local n = tonumber(s)
263 | if not n then
264 | decode_error(str, i, "invalid number '" .. s .. "'")
265 | end
266 | return n, x
267 | end
268 |
269 |
270 | local function parse_literal(str, i)
271 | local x = next_char(str, i, delim_chars)
272 | local word = str:sub(i, x - 1)
273 | if not literals[word] then
274 | decode_error(str, i, "invalid literal '" .. word .. "'")
275 | end
276 | return literal_map[word], x
277 | end
278 |
279 |
280 | local function parse_array(str, i)
281 | local res = {}
282 | local n = 1
283 | i = i + 1
284 | while 1 do
285 | local x
286 | i = next_char(str, i, space_chars, true)
287 | -- Empty / end of array?
288 | if str:sub(i, i) == "]" then
289 | i = i + 1
290 | break
291 | end
292 | -- Read token
293 | x, i = parse(str, i)
294 | res[n] = x
295 | n = n + 1
296 | -- Next token
297 | i = next_char(str, i, space_chars, true)
298 | local chr = str:sub(i, i)
299 | i = i + 1
300 | if chr == "]" then break end
301 | if chr ~= "," then decode_error(str, i, "expected ']' or ','") end
302 | end
303 | return res, i
304 | end
305 |
306 |
307 | local function parse_object(str, i)
308 | local res = {}
309 | i = i + 1
310 | while 1 do
311 | local key, val
312 | i = next_char(str, i, space_chars, true)
313 | -- Empty / end of object?
314 | if str:sub(i, i) == "}" then
315 | i = i + 1
316 | break
317 | end
318 | -- Read key
319 | if str:sub(i, i) ~= '"' then
320 | decode_error(str, i, "expected string for key")
321 | end
322 | key, i = parse(str, i)
323 | -- Read ':' delimiter
324 | i = next_char(str, i, space_chars, true)
325 | if str:sub(i, i) ~= ":" then
326 | decode_error(str, i, "expected ':' after key")
327 | end
328 | i = next_char(str, i + 1, space_chars, true)
329 | -- Read value
330 | val, i = parse(str, i)
331 | -- Set
332 | res[key] = val
333 | -- Next token
334 | i = next_char(str, i, space_chars, true)
335 | local chr = str:sub(i, i)
336 | i = i + 1
337 | if chr == "}" then break end
338 | if chr ~= "," then decode_error(str, i, "expected '}' or ','") end
339 | end
340 | return res, i
341 | end
342 |
343 |
344 | local char_func_map = {
345 | [ '"' ] = parse_string,
346 | [ "0" ] = parse_number,
347 | [ "1" ] = parse_number,
348 | [ "2" ] = parse_number,
349 | [ "3" ] = parse_number,
350 | [ "4" ] = parse_number,
351 | [ "5" ] = parse_number,
352 | [ "6" ] = parse_number,
353 | [ "7" ] = parse_number,
354 | [ "8" ] = parse_number,
355 | [ "9" ] = parse_number,
356 | [ "-" ] = parse_number,
357 | [ "t" ] = parse_literal,
358 | [ "f" ] = parse_literal,
359 | [ "n" ] = parse_literal,
360 | [ "[" ] = parse_array,
361 | [ "{" ] = parse_object,
362 | }
363 |
364 |
365 | parse = function(str, idx)
366 | local chr = str:sub(idx, idx)
367 | local f = char_func_map[chr]
368 | if f then
369 | return f(str, idx)
370 | end
371 | decode_error(str, idx, "unexpected character '" .. chr .. "'")
372 | end
373 |
374 |
375 | function json.decode(str)
376 | if type(str) ~= "string" then
377 | error("expected argument of type string, got " .. type(str))
378 | end
379 | local res, idx = parse(str, next_char(str, 1, space_chars, true))
380 | idx = next_char(str, idx, space_chars, true)
381 | if idx <= #str then
382 | decode_error(str, idx, "trailing garbage")
383 | end
384 | return res
385 | end
386 |
387 | function json.middleware()
388 | local json_response = function(response)
389 | return function()
390 | return function(req,res,next)
391 | if type(res._body) == "table" then
392 | res.header('content-type',"application/json")
393 | res.body( json.encode(res._body) )
394 | end
395 | response(req,res,next)
396 | end
397 | end
398 | end
399 | sb.response = json_response(sb.response())
400 | return function(req,res,next)
401 | if req.method ~= "GET" and req.header['Content-Type']:match("application/json") and GetPayload():sub(0,1) == "{" then
402 | req.body = json.decode( GetPayload() )
403 | end
404 | next()
405 | end
406 | end
407 |
408 | return json
409 |
--------------------------------------------------------------------------------
/src/.lua/soakbean.lua:
--------------------------------------------------------------------------------
1 | local json = require "json"
2 |
3 | function error(str) print("[error] " .. str) end
4 |
5 | function keys(table) local i = 0 ; for k, v in pairs(table) do i = i + 1 end ; return i end
6 |
7 | sb = {}
8 | sb = {
9 |
10 | middleware={},
11 | handler={},
12 | data={},
13 | charset="utf-8",
14 |
15 | __index=function(self,k,v)
16 | if sb.data[k] then return sb.data[k] end
17 | if sb[k] then return sb[k] end
18 | return rawget(self,k)
19 | end,
20 |
21 | __newindex=function(self,k,v)
22 | if type(v) == "function" then
23 | sb.data[k] = (function(k,v)
24 | return function(a,b,c,d,e,f)
25 | v(a,b,c,d,e,f)
26 | sb.pub(k,v)
27 | end
28 | end)(k,v)
29 | else
30 | sb.data[k] = v
31 | sb.pub(k,v)
32 | end
33 | end,
34 |
35 | on = function(k,f)
36 | sb.handler[k] = sb.handler[k] or {}
37 | table.insert( sb.handler[k], f )
38 | return sb.app
39 | end,
40 |
41 | pub = function(k,v)
42 | if sb.handler[k] ~= nil then
43 | for i,handler in pairs(sb.handler[k]) do
44 | coroutine.wrap(handler)(v,k)
45 | end
46 | end
47 | end,
48 |
49 | -- print( util.tpl("${name} is ${value}", {name = "foo", value = "bar"}) )
50 | -- "foo is bar"
51 | tpl = function(s,tab)
52 | return (s:gsub('($%b{})', function(w) return tab[w:sub(3, -2)] or w end))
53 | end,
54 |
55 | write = function(str)
56 | Write( sb.app.tpl(str,sb.app.data) )
57 | end,
58 |
59 | job = function(app)
60 | return function(cfg)
61 | -- app.
62 | return app
63 | end
64 | end,
65 |
66 | request = function(method, app)
67 | return function(path,f)
68 | app.use( function(req,res,next)
69 | if req.url:match(path) and req.method == method then
70 | f(req,res,next)
71 | else next() end
72 | end)
73 | end
74 | end,
75 |
76 | use = function(f)
77 | table.insert(sb.middleware,f)
78 | return sb.app
79 | end,
80 |
81 | useDefaults = function(app)
82 | app.use( function(req,res,next)
83 | app.pub(req.url,{req=req,res=res})
84 | next()
85 | end)
86 | end,
87 |
88 | run = function(req)
89 | local next = function() end
90 | local k = 0
91 | local req = {
92 | param={},
93 | method=GetMethod(),
94 | host=GetHost(),
95 | header=GetHeaders(),
96 | url=GetPath(),
97 | protocol=GetScheme(),
98 | body={}
99 | }
100 | local res={
101 | _status=nil,
102 | _header={},
103 | _body=""
104 | }
105 | local params = GetParams()
106 | if params ~= nil then
107 | for i,p in pairs(params) do req.param[ p[1] ] = p[2] end
108 | end
109 | res.status = function(status) res._status = status ; sb.pub('res.status',status) end
110 | res.body = function(v) res._body = v ; sb.pub('req.body' ,body) end
111 | res.header = function(k,v)
112 | if v == nil then return res._header[k] end
113 | res._header[k] = v
114 | sb.pub('res.header',{key=k,value=v})
115 | end
116 | res.header("content-type","text/html")
117 | -- run middleware
118 | next = function()
119 | k = k+1
120 | if type(sb.middleware[k]) == "function" then sb.middleware[k](req,res,next) end
121 | end
122 | next()
123 | end,
124 |
125 | json = function()
126 | local json_response = function(response)
127 | return function()
128 | return function(req,res,next)
129 | if type(res._body) == "table" then
130 | res.header('content-type',"application/json")
131 | res.body( json.encode(res._body) )
132 | end
133 | response(req,res,next)
134 | end
135 | end
136 | end
137 | sb.response = json_response(sb.response())
138 | return function(req,res,next)
139 | if req.method ~= "GET" and req.header['Content-Type']:match("application/json") and GetPayload():sub(0,1) == "{" then
140 | req.body = json.decode( GetPayload() )
141 | end
142 | next()
143 | end
144 | end,
145 |
146 | response = function()
147 | return function(req,res,next)
148 | if res._body ~= nil and res._status ~= nil and res._header['content-type'] ~= nil then
149 | SetStatus(res._status)
150 | for k,v in pairs(res._header) do
151 | if k == "content-type" then v = v .. "; charset=" .. sb.charset end
152 | SetHeader(k,v)
153 | end
154 | if type(res._body) == "string" then
155 | Write( res._body )
156 | else print("[ERROR] res.body is not a string (HINT: use json middleware)") end
157 | else next() end
158 | end
159 | end,
160 |
161 | router = function(router)
162 | return function(req,res,next)
163 | for p1, p2 in pairs(router) do
164 | if GetPath():match(p1) then
165 | if type(p2) == "string" then
166 | print("router: " .. p1 .. " => " .. p2)
167 | RoutePath(p2)
168 | end
169 | if type(p2) == "function" then p2(req,res,next) end
170 | sb.pub(p1,req)
171 | sb.pub(p2,req)
172 | return true
173 | end
174 | end
175 | next()
176 | end
177 | end,
178 |
179 | template = function(file)
180 | return function(req,res,next)
181 | res.status(200)
182 | res.header('content-type','text/html')
183 | res.body( sb.app.tpl( LoadAsset(file), sb.app ) )
184 | next()
185 | end
186 | end,
187 |
188 | init = function(app)
189 | for k,v in pairs(argv) do
190 | app.opts[ v:gsub("=.*","") ] = v:gsub(".*=","")
191 | end
192 | if( keys(app.cmd) > 0 ) then sb.runcmd(app) end
193 | end,
194 |
195 | runcmd = function(app)
196 | for k,v in pairs(app.opts) do
197 | if app.cmd[k] then
198 | local file = app.cmd[k].file
199 | return require( file:sub(0,-5) )(app,argv)
200 | end
201 | end
202 | print("\nUsage: " .. app.bin .. " [opts]\n\n")
203 | for k,v in pairs(app.cmd) do
204 | print("\t" .. app.bin .. " " .. k .. "\t\t" .. v.info )
205 | end
206 | print("")
207 | os.exit()
208 | end
209 |
210 | }
211 |
212 | return function(data)
213 | local app = {}
214 | setmetatable(app,sb)
215 | sb.app = app
216 | sb.data = data
217 | sb.data.url = {}
218 | if data.url == nil then sb.data.url = {} end
219 | sb.get = sb.request('GET', app)
220 | sb.post = sb.request('POST', app)
221 | sb.put = sb.request('PUT', app)
222 | sb.options = sb.request('OPTIONS', app)
223 | sb.delete = sb.request('DELETE', app)
224 | sb.init(app)
225 | sb.useDefaults(app)
226 | return app
227 | end
228 |
--------------------------------------------------------------------------------
/src/data.lua:
--------------------------------------------------------------------------------
1 | -- most usecases will just do fine with the global soakbean app in .init.lua
2 | -- however, file-endpoints can initialize separated soakbean apps
3 | -- or just use direct Redbean calls.
4 | local json = require("json")
5 | local app2 = require("soakbean") -- in theory you could setup a separate instance here
6 |
7 | SetStatus(200)
8 | SetHeader('Content-Type', 'application/json; charset=utf-8')
9 | local data = {}
10 | for k,v in pairs({"notes","title"}) do data[v] = app[v] end
11 | Write( json.encode( data ) )
12 |
--------------------------------------------------------------------------------
/src/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | ${title}
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 | 
14 |
15 |
16 |
17 |
${title}
18 |
19 |
20 |
21 |
22 |
23 | Read
the documentation here.
24 |
25 |
26 |
27 |
28 |
29 |
38 |
39 |
40 |
41 |
--------------------------------------------------------------------------------
/src/tests.html:
--------------------------------------------------------------------------------
1 |
2 |
3 | soakbean tests
4 |
5 |
6 |
7 |
76 |
77 |
78 |
--------------------------------------------------------------------------------