@rip-lang/api 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +776 -0
- package/api.rip +564 -0
- package/middleware.rip +394 -0
- package/package.json +43 -0
package/api.rip
ADDED
|
@@ -0,0 +1,564 @@
|
|
|
1
|
+
# ==============================================================================
|
|
2
|
+
# @rip-lang/api — Pure Rip API Framework
|
|
3
|
+
# ==============================================================================
|
|
4
|
+
#
|
|
5
|
+
# A zero-dependency, Hono-compatible API framework written entirely in Rip.
|
|
6
|
+
# Provides Sinatra-style elegance with AsyncLocalStorage-powered context.
|
|
7
|
+
#
|
|
8
|
+
# Public exports:
|
|
9
|
+
# DSL: get, post, put, patch, del, all, use, prefix
|
|
10
|
+
# Server: start, startHandler, fetch, App
|
|
11
|
+
# Filters: before, after
|
|
12
|
+
# Handlers: onError, notFound
|
|
13
|
+
# Validation: read, validators, registerValidator, getValidator
|
|
14
|
+
# Context: ctx, session, env
|
|
15
|
+
# Utilities: isBlank, toName, toPhone
|
|
16
|
+
# Dev: resetGlobals
|
|
17
|
+
#
|
|
18
|
+
# ==============================================================================
|
|
19
|
+
|
|
20
|
+
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
21
|
+
|
|
22
|
+
# ==============================================================================
|
|
23
|
+
# Module State
|
|
24
|
+
# ==============================================================================
|
|
25
|
+
|
|
26
|
+
_ = null # Match capture holder for Rip's =~
|
|
27
|
+
_errorHandler = null # Custom error handler
|
|
28
|
+
_notFoundHandler = null # Custom 404 handler
|
|
29
|
+
_beforeFilters = [] # Before request filters
|
|
30
|
+
_afterFilters = [] # After request filters
|
|
31
|
+
_routes = [] # Route definitions
|
|
32
|
+
_middlewares = [] # Global middleware functions
|
|
33
|
+
_prefix = '' # Current route prefix for grouping
|
|
34
|
+
|
|
35
|
+
export resetGlobals = ->
|
|
36
|
+
_errorHandler = null
|
|
37
|
+
_notFoundHandler = null
|
|
38
|
+
_beforeFilters = []
|
|
39
|
+
_afterFilters = []
|
|
40
|
+
_routes = []
|
|
41
|
+
_middlewares = []
|
|
42
|
+
_prefix = ''
|
|
43
|
+
|
|
44
|
+
# AsyncLocalStorage for request context (enables sync read() in handlers)
|
|
45
|
+
export requestContext = new AsyncLocalStorage()
|
|
46
|
+
|
|
47
|
+
# ==============================================================================
|
|
48
|
+
# Router: Pattern Compiler
|
|
49
|
+
# ==============================================================================
|
|
50
|
+
|
|
51
|
+
compilePattern = (path) ->
|
|
52
|
+
keys = []
|
|
53
|
+
path = path.replace /\?$/, ''
|
|
54
|
+
escaped = path.replace /[.+^${}()|[\]\\]/g, '\\$&'
|
|
55
|
+
regex = escaped.replace /:(\w+)(?:\{([^}]+)\})?/g, (match, key, pattern) ->
|
|
56
|
+
keys.push key
|
|
57
|
+
"(#{pattern or '[^/]+'})"
|
|
58
|
+
regex = regex.replace /(?<![\\*])\*(?!\*)/g, '(.*)'
|
|
59
|
+
{ regex: new RegExp("^#{regex}/?$"), keys }
|
|
60
|
+
|
|
61
|
+
# ==============================================================================
|
|
62
|
+
# Router: Route Matching
|
|
63
|
+
# ==============================================================================
|
|
64
|
+
|
|
65
|
+
matchRoute = (method, pathname) ->
|
|
66
|
+
for route in _routes
|
|
67
|
+
continue if route.middleware
|
|
68
|
+
continue unless route.method is method or route.method is 'ALL'
|
|
69
|
+
match = pathname.match route.regex
|
|
70
|
+
if match
|
|
71
|
+
params = {}
|
|
72
|
+
for key, i in route.keys
|
|
73
|
+
params[key] = decodeURIComponent(match[i + 1]) if match[i + 1]?
|
|
74
|
+
return { handler: route.handler, params, route }
|
|
75
|
+
null
|
|
76
|
+
|
|
77
|
+
# ==============================================================================
|
|
78
|
+
# Context Factory
|
|
79
|
+
# ==============================================================================
|
|
80
|
+
|
|
81
|
+
createContext = (req, params = {}) ->
|
|
82
|
+
url = new URL(req.url)
|
|
83
|
+
out = new Headers()
|
|
84
|
+
|
|
85
|
+
ctx =
|
|
86
|
+
json: (data, status = 200, headers = {}) ->
|
|
87
|
+
for k, v of headers then out.set(k, v)
|
|
88
|
+
out.set 'Content-Type', 'application/json'
|
|
89
|
+
new Response JSON.stringify(data), { status, headers: out }
|
|
90
|
+
|
|
91
|
+
text: (str, status = 200, headers = {}) ->
|
|
92
|
+
for k, v of headers then out.set(k, v)
|
|
93
|
+
out.set 'Content-Type', 'text/plain; charset=UTF-8'
|
|
94
|
+
new Response str, { status, headers: out }
|
|
95
|
+
|
|
96
|
+
html: (str, status = 200, headers = {}) ->
|
|
97
|
+
for k, v of headers then out.set(k, v)
|
|
98
|
+
out.set 'Content-Type', 'text/html; charset=UTF-8'
|
|
99
|
+
new Response str, { status, headers: out }
|
|
100
|
+
|
|
101
|
+
redirect: (location, status = 302) ->
|
|
102
|
+
new Response null, { status, headers: { Location: String(location) } }
|
|
103
|
+
|
|
104
|
+
body: (data, status = 200, headers = {}) ->
|
|
105
|
+
for k, v of headers then out.set(k, v)
|
|
106
|
+
new Response data, { status, headers: out }
|
|
107
|
+
|
|
108
|
+
header: (name, value, opts = {}) ->
|
|
109
|
+
if value?
|
|
110
|
+
if opts.append then out.append(name, value) else out.set(name, value)
|
|
111
|
+
return
|
|
112
|
+
out.get(name)
|
|
113
|
+
|
|
114
|
+
session: {}
|
|
115
|
+
|
|
116
|
+
req:
|
|
117
|
+
raw: req
|
|
118
|
+
method: req.method
|
|
119
|
+
url: req.url
|
|
120
|
+
path: url.pathname
|
|
121
|
+
param: (key) -> if key? then params[key] else { ...params }
|
|
122
|
+
query: (key) -> if key? then url.searchParams.get(key) else Object.fromEntries(url.searchParams)
|
|
123
|
+
header: (key) -> if key? then req.headers.get(key) else Object.fromEntries(req.headers)
|
|
124
|
+
json: -> req.json()
|
|
125
|
+
text: -> req.text()
|
|
126
|
+
formData: -> req.formData()
|
|
127
|
+
parseBody: (opts = {}) ->
|
|
128
|
+
ct = req.headers.get('content-type') or ''
|
|
129
|
+
if ct =~ /json/i
|
|
130
|
+
req.json!
|
|
131
|
+
else if ct =~ /form/i
|
|
132
|
+
fd = req.formData!
|
|
133
|
+
Object.fromEntries(fd)
|
|
134
|
+
else
|
|
135
|
+
{}
|
|
136
|
+
|
|
137
|
+
ctx
|
|
138
|
+
|
|
139
|
+
# ==============================================================================
|
|
140
|
+
# Middleware Composition (Koa-style)
|
|
141
|
+
# ==============================================================================
|
|
142
|
+
|
|
143
|
+
compose = (middlewares, beforeFilters, afterFilters, handler) ->
|
|
144
|
+
(c) ->
|
|
145
|
+
index = -1
|
|
146
|
+
dispatch = (i) ->
|
|
147
|
+
if i <= index
|
|
148
|
+
throw new Error 'next() called multiple times'
|
|
149
|
+
index = i
|
|
150
|
+
|
|
151
|
+
# Run middlewares first
|
|
152
|
+
if i < middlewares.length
|
|
153
|
+
fn = middlewares[i]
|
|
154
|
+
result = fn.call! c, c, -> dispatch!(i + 1)
|
|
155
|
+
c._response = result if result instanceof Response
|
|
156
|
+
return
|
|
157
|
+
|
|
158
|
+
# After middlewares, run before filters, handler, after filters
|
|
159
|
+
if i is middlewares.length
|
|
160
|
+
# Run before filters
|
|
161
|
+
for filter in beforeFilters
|
|
162
|
+
result = filter.call!(c, c)
|
|
163
|
+
if result instanceof Response
|
|
164
|
+
c._response = result
|
|
165
|
+
return
|
|
166
|
+
|
|
167
|
+
# Run handler
|
|
168
|
+
result = handler.call!(c, c)
|
|
169
|
+
c._response = result if result instanceof Response
|
|
170
|
+
|
|
171
|
+
# Run after filters
|
|
172
|
+
for filter in afterFilters
|
|
173
|
+
filter.call!(c, c)
|
|
174
|
+
|
|
175
|
+
dispatch!(0)
|
|
176
|
+
c
|
|
177
|
+
|
|
178
|
+
# ==============================================================================
|
|
179
|
+
# Smart Response Wrapper
|
|
180
|
+
# ==============================================================================
|
|
181
|
+
|
|
182
|
+
smart = (fn) ->
|
|
183
|
+
(c, next) ->
|
|
184
|
+
try
|
|
185
|
+
result = if fn.length > 0 then fn.call!(c, c) else fn.call!(c)
|
|
186
|
+
return result if result instanceof Response
|
|
187
|
+
return c.json(result) if result? and typeof result is 'object'
|
|
188
|
+
if typeof result is 'string'
|
|
189
|
+
type = if result.trimStart().startsWith('<') then 'html' else 'text'
|
|
190
|
+
return c[type](result)
|
|
191
|
+
return c.text(String(result)) if typeof result in ['number', 'boolean']
|
|
192
|
+
if result is null or result is undefined
|
|
193
|
+
return new Response(null, { status: 204 })
|
|
194
|
+
result
|
|
195
|
+
catch err
|
|
196
|
+
console.error 'Handler error:', err
|
|
197
|
+
status = err?.status or 500
|
|
198
|
+
message = err?.message or 'Internal Server Error'
|
|
199
|
+
new Response message, { status, headers: { 'Content-Type': 'text/plain' } }
|
|
200
|
+
|
|
201
|
+
# ==============================================================================
|
|
202
|
+
# DSL: Route Registration
|
|
203
|
+
# ==============================================================================
|
|
204
|
+
|
|
205
|
+
addRoute = (method, path, ...handlers) ->
|
|
206
|
+
fullPath = "#{_prefix}#{path}"
|
|
207
|
+
if fullPath.includes(':') and fullPath.endsWith('?')
|
|
208
|
+
basePath = fullPath.slice(0, -1).replace(/\/:[^\/]+$/, '')
|
|
209
|
+
addRoute method, basePath, ...handlers if basePath and basePath isnt fullPath.slice(0, -1)
|
|
210
|
+
fullPath = fullPath.slice(0, -1)
|
|
211
|
+
{ regex, keys } = compilePattern(fullPath)
|
|
212
|
+
for handler in handlers
|
|
213
|
+
_routes.push { method: method.toUpperCase(), regex, keys, handler: smart(handler), path: fullPath }
|
|
214
|
+
|
|
215
|
+
export get = (path, ...handlers) -> addRoute 'GET', path, ...handlers
|
|
216
|
+
export post = (path, ...handlers) -> addRoute 'POST', path, ...handlers
|
|
217
|
+
export put = (path, ...handlers) -> addRoute 'PUT', path, ...handlers
|
|
218
|
+
export patch = (path, ...handlers) -> addRoute 'PATCH', path, ...handlers
|
|
219
|
+
export del = (path, ...handlers) -> addRoute 'DELETE', path, ...handlers
|
|
220
|
+
export all = (path, ...handlers) -> addRoute 'ALL', path, ...handlers
|
|
221
|
+
|
|
222
|
+
export use = (pathOrMw, mw = null) ->
|
|
223
|
+
if typeof pathOrMw is 'function'
|
|
224
|
+
_middlewares.push pathOrMw
|
|
225
|
+
else
|
|
226
|
+
{ regex, keys } = compilePattern(pathOrMw)
|
|
227
|
+
_routes.push { method: 'ALL', regex, keys, handler: mw, path: pathOrMw, middleware: true }
|
|
228
|
+
|
|
229
|
+
export prefix = (base, fn) ->
|
|
230
|
+
old = _prefix
|
|
231
|
+
_prefix = "#{_prefix}#{base}"
|
|
232
|
+
try
|
|
233
|
+
fn?()
|
|
234
|
+
finally
|
|
235
|
+
_prefix = old
|
|
236
|
+
|
|
237
|
+
export onError = (handler) -> _errorHandler = handler
|
|
238
|
+
export notFound = (handler) -> _notFoundHandler = handler
|
|
239
|
+
|
|
240
|
+
export before = (fn) -> _beforeFilters.push fn
|
|
241
|
+
export after = (fn) -> _afterFilters.push fn
|
|
242
|
+
|
|
243
|
+
# ==============================================================================
|
|
244
|
+
# Main Fetch Handler
|
|
245
|
+
# ==============================================================================
|
|
246
|
+
|
|
247
|
+
export fetch = (req) ->
|
|
248
|
+
url = new URL(req.url)
|
|
249
|
+
pathname = url.pathname
|
|
250
|
+
method = req.method
|
|
251
|
+
|
|
252
|
+
if method is 'HEAD'
|
|
253
|
+
res = fetch!(new Request(req.url, { method: 'GET', headers: req.headers }))
|
|
254
|
+
return new Response(null, { status: res.status, headers: res.headers })
|
|
255
|
+
|
|
256
|
+
match = matchRoute(method, pathname)
|
|
257
|
+
|
|
258
|
+
unless match
|
|
259
|
+
c = createContext(req)
|
|
260
|
+
if _notFoundHandler?
|
|
261
|
+
return _notFoundHandler!(c)
|
|
262
|
+
return new Response('Not Found', { status: 404 })
|
|
263
|
+
|
|
264
|
+
c = createContext(req, match.params)
|
|
265
|
+
|
|
266
|
+
# Pre-parse body and query for sync read() — baked in, zero ceremony
|
|
267
|
+
data = {}
|
|
268
|
+
try
|
|
269
|
+
ct = req.headers.get('content-type') or ''
|
|
270
|
+
if method in ['POST', 'PUT', 'PATCH']
|
|
271
|
+
if ct =~ /json/i
|
|
272
|
+
data = c.req.json!
|
|
273
|
+
else if ct =~ /x-www-form-urlencoded|form-data/i
|
|
274
|
+
data = c.req.parseBody!
|
|
275
|
+
catch
|
|
276
|
+
data = {}
|
|
277
|
+
merged = { ...data, ...c.req.query(), ...match.params }
|
|
278
|
+
|
|
279
|
+
# Run handler inside AsyncLocalStorage context
|
|
280
|
+
requestContext.run! { env: c, data: merged }, ->
|
|
281
|
+
runHandler!(c, match.handler)
|
|
282
|
+
|
|
283
|
+
# Internal: Execute handler with middleware and error handling
|
|
284
|
+
runHandler = (c, handler) ->
|
|
285
|
+
try
|
|
286
|
+
# Compose runs: middlewares → before filters → handler → after filters
|
|
287
|
+
compose!(_middlewares, _beforeFilters, _afterFilters, handler)(c)
|
|
288
|
+
c._response or new Response('', { status: 204 })
|
|
289
|
+
catch err
|
|
290
|
+
console.error 'Request error:', err
|
|
291
|
+
if _errorHandler?
|
|
292
|
+
_errorHandler.call!(c, err, c)
|
|
293
|
+
else
|
|
294
|
+
new Response err?.message or 'Internal Server Error', { status: 500 }
|
|
295
|
+
|
|
296
|
+
# ==============================================================================
|
|
297
|
+
# Server Startup
|
|
298
|
+
# ==============================================================================
|
|
299
|
+
|
|
300
|
+
export startHandler = -> fetch
|
|
301
|
+
|
|
302
|
+
export start = (opts = {}) ->
|
|
303
|
+
handler = startHandler()
|
|
304
|
+
host = opts.host or '0.0.0.0'
|
|
305
|
+
port = opts.port or 3000
|
|
306
|
+
server = Bun.serve { hostname: host, port: port, fetch: handler }
|
|
307
|
+
console.log "rip-api listening on http://#{host}:#{port}"
|
|
308
|
+
server
|
|
309
|
+
|
|
310
|
+
export App = (fn) ->
|
|
311
|
+
resetGlobals()
|
|
312
|
+
fn?()
|
|
313
|
+
startHandler()
|
|
314
|
+
|
|
315
|
+
# ==============================================================================
|
|
316
|
+
# Utility: Get Current Environment/Context
|
|
317
|
+
# ==============================================================================
|
|
318
|
+
|
|
319
|
+
export ctx = ->
|
|
320
|
+
store = requestContext.getStore()
|
|
321
|
+
store?.env or null
|
|
322
|
+
|
|
323
|
+
# Session proxy — access session.foo anywhere (via AsyncLocalStorage)
|
|
324
|
+
export session = new Proxy {},
|
|
325
|
+
get: (_, key) ->
|
|
326
|
+
requestContext.getStore()?.env?.session?[key]
|
|
327
|
+
set: (_, key, value) ->
|
|
328
|
+
ctx = requestContext.getStore()?.env
|
|
329
|
+
return false unless ctx?
|
|
330
|
+
ctx.session ?= {}
|
|
331
|
+
ctx.session[key] = value
|
|
332
|
+
true
|
|
333
|
+
deleteProperty: (_, key) ->
|
|
334
|
+
ctx = requestContext.getStore()?.env
|
|
335
|
+
delete ctx.session[key] if ctx?.session?
|
|
336
|
+
true
|
|
337
|
+
|
|
338
|
+
# Env proxy — access env.FOO anywhere (shortcut for process.env)
|
|
339
|
+
export env = new Proxy {}, get: (_, key) -> process.env[key]
|
|
340
|
+
|
|
341
|
+
# ==============================================================================
|
|
342
|
+
# Utility Functions
|
|
343
|
+
# ==============================================================================
|
|
344
|
+
|
|
345
|
+
export isBlank = (obj) ->
|
|
346
|
+
return true unless obj?
|
|
347
|
+
return true if obj is false
|
|
348
|
+
return true if typeof obj is 'string' and /^\s*$/.test obj
|
|
349
|
+
return true if Array.isArray(obj) and obj.length is 0
|
|
350
|
+
return true if typeof obj is 'object' and Object.keys(obj).length is 0
|
|
351
|
+
false
|
|
352
|
+
|
|
353
|
+
capitalize = (str) -> str.charAt(0).toUpperCase() + str.slice(1).toLowerCase()
|
|
354
|
+
|
|
355
|
+
export toName = (str, type...) ->
|
|
356
|
+
s = String(str)
|
|
357
|
+
s = s.toLowerCase().replace(/\s+/g, ' ').trim()
|
|
358
|
+
s = s.replace /(^|(?<=\P{L}))(\p{L})/gu, (m, pre, ch) -> pre + ch.toUpperCase()
|
|
359
|
+
s = s.replace(/[`'']/g, "'").replace(/[""]/g, '"')
|
|
360
|
+
s = s.replace /\b([a-z])\. ?([bcdfghjklmnpqrstvwxyz])\.?(?=\W|$)/gi, (match, p1, p2) -> (p1 + p2).toUpperCase()
|
|
361
|
+
s = s.replace /(?<=^|\P{L})([A-Za-z](?:(?![AEIOUYaeiouy])[A-Za-z]){1,4})(?=$|\P{L})/gu, (m) -> m.toUpperCase()
|
|
362
|
+
s = s.replace /\b([djs]r|us|acct|[ai]nn?|apps|ed|erb|elk|esq|grp|in[cj]|of[cf]|st|up)\.?(?=\W|$)/gi, (match) -> capitalize(match)
|
|
363
|
+
s = s.replace /(^|(?<=\d ))?\b(and|at|as|of|then?|in|on|or|for|to|by|de l[ao]s?|del?|(el-)|el|las)($)?\b/ig, (m, p1, p2, p3, p4, offset) ->
|
|
364
|
+
if offset is 0 or p1? or p3? or p4? then capitalize(p2) else p2.toLowerCase()
|
|
365
|
+
s = s.replace /\b(mc|mac(?=d[ao][a-k,m-z][a-z]|[fgmpw])|[dol]')([a-z])/gi, (m, p1, p2) -> capitalize(p1) + capitalize(p2)
|
|
366
|
+
if type.includes 'name'
|
|
367
|
+
s = s.replace /\b(ahn|an[gh]|al|art[sz]?|ash|e[dnv]|echt|elms|emms|eng|epps|essl|i[mp]|mrs?|ms|ng|ock|o[hm]|ohrt|ong|orr|ohrt|ost|ott|oz|sng|tsz|u[br]|ung)\b/gi, (match) -> capitalize(match)
|
|
368
|
+
if type.includes 'address'
|
|
369
|
+
s = s.replace /(?<=^| |\p{P})(apt?s?|arch|ave?|bldg|blvd|cr?t|co?mn|drv?|elm|end|f[lt]|hts?|ln|old|pkw?y|plc?|prk|pt|r[dm]|spc|s[qt]r?|srt|street|[nesw])\.?(?=$|[^\p{L}\p{N}_])/giu, (matched) -> capitalize(matched)
|
|
370
|
+
s = s.replace /(1st|2nd|3rd|[\d]th|\bde l[ao]s)\b/gi, (match) -> match.toLowerCase()
|
|
371
|
+
s = s.replace /\b(ca|dba|fbo|ihop|mri|ucla|usa|vru|[ns][ew]|i{1,3}v?)\b/gi, (match) -> match.toUpperCase()
|
|
372
|
+
s = s.replace /\b([-@.\w]+\.(?:com|net|io|org))\b/gi, (match) -> match.toLowerCase()
|
|
373
|
+
s = s.replace /(?<=\p{L}')S\b/gu, 's'
|
|
374
|
+
s = s.replace /# /g, '#'
|
|
375
|
+
s = s.replace /\s*[.,#]+$/, ''
|
|
376
|
+
s = s.replace /\bP\.? ?O\.? ?Box/i, 'PO Box'
|
|
377
|
+
s
|
|
378
|
+
|
|
379
|
+
export toPhone = (str) ->
|
|
380
|
+
return "" if isBlank(str)
|
|
381
|
+
num = str.toString().replace(/\s+/g, ' ').trim()
|
|
382
|
+
[num, ext] = num.split(/\s*(?:, ?)?(?:ext?\.?|x|#|:|,)\s*/i, 2)
|
|
383
|
+
ext = ext.replace(/\D+/g, "") if ext
|
|
384
|
+
if num =~ /^\+([-+#*.,\d ]+)$/
|
|
385
|
+
etc = _[1].replace(/[^#*,\d]+/g, "")
|
|
386
|
+
if etc.replace(/\D+/g, "").length >= 6
|
|
387
|
+
if etc =~ /^1(\d{10})$/
|
|
388
|
+
[num, ext] = [_[1], ""]
|
|
389
|
+
else
|
|
390
|
+
return "+#{etc}"
|
|
391
|
+
num = num.replace(/^[^2-9]*/, "").replace(/\D+/g, "")
|
|
392
|
+
if num =~ /^([2-9][0-8][0-9])([2-9]\d\d)(\d{4})$/
|
|
393
|
+
num = "(#{_[1]}) #{_[2]}-#{_[3]}"
|
|
394
|
+
num += ", ext. #{ext}" if ext
|
|
395
|
+
else
|
|
396
|
+
num = null
|
|
397
|
+
num
|
|
398
|
+
|
|
399
|
+
# ==============================================================================
|
|
400
|
+
# Validators
|
|
401
|
+
# ==============================================================================
|
|
402
|
+
|
|
403
|
+
export validators =
|
|
404
|
+
# Numbers & money
|
|
405
|
+
id: (v) -> v[/^([1-9]\d{0,19})$/] and parseInt(_[1])
|
|
406
|
+
whole: (v) -> v[/^(0|[1-9]\d{0,19})$/] and parseInt(_[1])
|
|
407
|
+
decimal: (v) -> v[/^([-+]?\d+(?:\.\d*)?|\.\d+)$/] and parseFloat(_[1])
|
|
408
|
+
money: (v) -> v[/^([-+]?\d+(?:\.\d*)?|\.\d+)$/] and Math.round(parseFloat(_[1]) * 100)
|
|
409
|
+
|
|
410
|
+
# Strings & formatting
|
|
411
|
+
string: (v) -> v.replace(/\s\s+/g, ' ')
|
|
412
|
+
text: (v) -> v.replace(/ +/g, ' ')
|
|
413
|
+
|
|
414
|
+
# Name/address
|
|
415
|
+
name: (v) -> v.replace(/\s+/g, ' ').trim()
|
|
416
|
+
address: (v) -> v.replace(/\s+/g, ' ').trim()
|
|
417
|
+
|
|
418
|
+
# Time & date
|
|
419
|
+
time: (v) -> v[/^([01]?\d|2[0-3]):[0-5]\d(:[0-5]\d)?$/] and _[0]
|
|
420
|
+
date: (v) -> v[/^\d{4}-\d{2}-\d{2}$/] and _[0]
|
|
421
|
+
dateutc: (v) -> v[/^\d{4}-\d{2}-\d{2}Z?$/] and _[0]
|
|
422
|
+
|
|
423
|
+
# Booleans
|
|
424
|
+
truthy: (v) -> (v =~ /^(true|t|1|yes|y|on)$/i) and true
|
|
425
|
+
falsy: (v) -> (v =~ /^(false|f|0|no|n|off)$/i) and true
|
|
426
|
+
bool: (v) -> (v =~ /^(true|t|1|yes|y|on|false|f|0|no|n|off)$/i) and (v =~ /^(true|t|1|yes|y|on)$/i)
|
|
427
|
+
|
|
428
|
+
# Contact, geo, identity
|
|
429
|
+
email: (v) -> v[/^([a-zA-Z0-9._%+-]+)@([a-zA-Z0-9.-]+\.[a-zA-Z]{2,})$/] and _[0]
|
|
430
|
+
state: (v) -> v[/^([a-z][a-z])$/i] and _[1].toUpperCase()
|
|
431
|
+
zip: (v) -> v[/^(\d{5})/] and _[1]
|
|
432
|
+
zipplus4: (v) -> v[/^(\d{5})-?(\d{4})$/] and "#{_[1]}-#{_[2]}"
|
|
433
|
+
ssn: (v) -> v[/^(\d{3})-?(\d{2})-?(\d{4})$/] and "#{_[1]}#{_[2]}#{_[3]}"
|
|
434
|
+
sex: (v) -> v[/^(m|male|f|female|o|other)$/i] and _[1][0].toLowerCase()
|
|
435
|
+
|
|
436
|
+
# Phone
|
|
437
|
+
phone: (v) -> toPhone(v)
|
|
438
|
+
|
|
439
|
+
# Web & technical
|
|
440
|
+
username: (v) -> v[/^([a-zA-Z0-9_-]{3,20})$/] and _[1].toLowerCase()
|
|
441
|
+
ip: (v) -> v[/^(?:\d{1,3}\.){3}\d{1,3}$/] and _[0]
|
|
442
|
+
mac: (v) -> v[/^([0-9a-fA-F]{2})(?:[:-]?)([0-9a-fA-F]{2})(?:[:-]?)([0-9a-fA-F]{2})(?:[:-]?)([0-9a-fA-F]{2})(?:[:-]?)([0-9a-fA-F]{2})(?:[:-]?)([0-9a-fA-F]{2})$/] and "#{_[1]}:#{_[2]}:#{_[3]}:#{_[4]}:#{_[5]}:#{_[6]}".toLowerCase()
|
|
443
|
+
url: (v) -> v[/^(https?:\/\/)?([\da-z\.-]+)\.([a-z\.]{2,6})([\/\w\.-]*)*\/?$/] and _[0]
|
|
444
|
+
color: (v) -> v[/^#?([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/] and "##{_[1].toLowerCase()}"
|
|
445
|
+
uuid: (v) -> v[/^([0-9a-f]{8})-?([0-9a-f]{4})-?([0-9a-f]{4})-?([0-9a-f]{4})-?([0-9a-f]{12})$/i] and "#{_[1]}-#{_[2]}-#{_[3]}-#{_[4]}-#{_[5]}".toLowerCase()
|
|
446
|
+
semver: (v) -> v[/^(\d+)\.(\d+)\.(\d+)(?:-([a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*))?(?:\+([a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*))?$/] and _[0]
|
|
447
|
+
|
|
448
|
+
# Time formats & currency
|
|
449
|
+
time24: (v) -> v[/^([01]?[0-9]|2[0-3]):([0-5][0-9])(?::([0-5][0-9]))?$/] and _[0]
|
|
450
|
+
time12: (v) -> v[/^(1[0-2]|0?[1-9]):([0-5][0-9])\s?(am|pm)$/i] and _[0].toLowerCase()
|
|
451
|
+
currency: (v) -> v[/^\$?(\d{1,3}(?:,\d{3})*(?:\.\d{2})?)$/] and parseFloat(_[1].replace(/,/g, ''))
|
|
452
|
+
|
|
453
|
+
# Collections & structured
|
|
454
|
+
array: (v) -> Array.isArray(v) and v or null
|
|
455
|
+
hash: (v) -> (v? and typeof v is 'object' and not Array.isArray(v)) and v or null
|
|
456
|
+
json: (v) -> if typeof v is 'string' then (try JSON.parse(v) catch then null) else if typeof v is 'object' then v else null
|
|
457
|
+
|
|
458
|
+
# Slugs & ids list
|
|
459
|
+
slug: (v) -> v[/^([a-z0-9]+(?:-[a-z0-9]+)*)$/] and _[1]
|
|
460
|
+
ids: (v) ->
|
|
461
|
+
cleaned = v.replace(/[, ]+/g, ' ').trim()
|
|
462
|
+
return null unless cleaned
|
|
463
|
+
try
|
|
464
|
+
nums = []
|
|
465
|
+
for part in cleaned.split(' ')
|
|
466
|
+
return null unless part[/^([1-9]\d{0,19})$/]
|
|
467
|
+
nums.push parseInt(_[1])
|
|
468
|
+
Array.from(new Set(nums)).sort((a, b) -> a - b)
|
|
469
|
+
catch then null
|
|
470
|
+
|
|
471
|
+
export registerValidator = (name, fn) -> validators[name] = fn
|
|
472
|
+
export getValidator = (name) -> validators[name]
|
|
473
|
+
|
|
474
|
+
# ==============================================================================
|
|
475
|
+
# read() — Sinatra-style Parameter Reading
|
|
476
|
+
# ==============================================================================
|
|
477
|
+
|
|
478
|
+
export read = (keyOrTag = null, tagOrMiss = null, missOrNil = null) ->
|
|
479
|
+
store = requestContext.getStore()
|
|
480
|
+
unless store?
|
|
481
|
+
throw new Error 'read() called outside request context'
|
|
482
|
+
|
|
483
|
+
data = store.data or {}
|
|
484
|
+
key = null
|
|
485
|
+
tag = null
|
|
486
|
+
miss = null
|
|
487
|
+
|
|
488
|
+
if keyOrTag? and (tagOrMiss? or missOrNil isnt null)
|
|
489
|
+
key = keyOrTag
|
|
490
|
+
tag = tagOrMiss
|
|
491
|
+
miss = missOrNil
|
|
492
|
+
else
|
|
493
|
+
key = null
|
|
494
|
+
tag = keyOrTag
|
|
495
|
+
miss = tagOrMiss
|
|
496
|
+
|
|
497
|
+
raw = if key? then data[key] else data
|
|
498
|
+
return raw unless tag?
|
|
499
|
+
|
|
500
|
+
required = false
|
|
501
|
+
if typeof tag is 'string' and tag.endsWith('!')
|
|
502
|
+
required = true
|
|
503
|
+
tag = tag.slice(0, -1)
|
|
504
|
+
|
|
505
|
+
v = raw
|
|
506
|
+
|
|
507
|
+
# Named validator
|
|
508
|
+
if typeof tag is 'string'
|
|
509
|
+
fn = getValidator(tag)
|
|
510
|
+
v = if fn then fn(String(v or '')) else null
|
|
511
|
+
|
|
512
|
+
# Array: [min, max] constraint or enumeration
|
|
513
|
+
else if Array.isArray(tag)
|
|
514
|
+
if typeof tag[0] is 'number'
|
|
515
|
+
minVal = tag[0]
|
|
516
|
+
maxVal = tag[1]
|
|
517
|
+
if typeof v is 'number' or (String(v) =~ /^[-+]?\d+$/)
|
|
518
|
+
n = if typeof v is 'number' then v else parseInt(String(v))
|
|
519
|
+
ok = not isNaN(n)
|
|
520
|
+
ok = false if minVal? and n < minVal
|
|
521
|
+
ok = false if maxVal? and n > maxVal
|
|
522
|
+
v = if ok then n else null
|
|
523
|
+
else
|
|
524
|
+
s = String(v or '')
|
|
525
|
+
ok = true
|
|
526
|
+
ok = false if minVal? and s.length < minVal
|
|
527
|
+
ok = false if maxVal? and s.length > maxVal
|
|
528
|
+
v = if ok then s else null
|
|
529
|
+
else
|
|
530
|
+
v = if tag.includes(String(v)) then String(v) else null
|
|
531
|
+
|
|
532
|
+
# Regex
|
|
533
|
+
else if tag instanceof RegExp
|
|
534
|
+
v = String(v or '')[tag] or null
|
|
535
|
+
|
|
536
|
+
# Object constraints
|
|
537
|
+
else if typeof tag is 'object'
|
|
538
|
+
if tag.start? or tag.end?
|
|
539
|
+
n = parseInt(v)
|
|
540
|
+
v = if not isNaN(n) and (not tag.start? or n >= tag.start) and (not tag.end? or n <= tag.end) then n else null
|
|
541
|
+
else if tag.min? or tag.max?
|
|
542
|
+
if typeof v is 'number' or (String(v or '') =~ /^[-+]?\d+$/)
|
|
543
|
+
n = if typeof v is 'number' then v else parseInt(String(v))
|
|
544
|
+
ok = not isNaN(n)
|
|
545
|
+
ok = false if tag.min? and n < tag.min
|
|
546
|
+
ok = false if tag.max? and n > tag.max
|
|
547
|
+
v = if ok then n else null
|
|
548
|
+
else
|
|
549
|
+
s = String(v or '')
|
|
550
|
+
ok = true
|
|
551
|
+
ok = false if tag.min? and s.length < tag.min
|
|
552
|
+
ok = false if tag.max? and s.length > tag.max
|
|
553
|
+
v = if ok then s else null
|
|
554
|
+
|
|
555
|
+
blank = v is null or v is undefined or (typeof v is 'string' and v.trim() is '')
|
|
556
|
+
|
|
557
|
+
if blank
|
|
558
|
+
if required
|
|
559
|
+
return miss?() if typeof miss is 'function'
|
|
560
|
+
throw new Error "Missing required field: #{key}"
|
|
561
|
+
else
|
|
562
|
+
return (miss?() if typeof miss is 'function') or miss or null
|
|
563
|
+
|
|
564
|
+
v
|