@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.
Files changed (4) hide show
  1. package/README.md +776 -0
  2. package/api.rip +564 -0
  3. package/middleware.rip +394 -0
  4. 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