whoosh 1.4.1 → 1.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.
- checksums.yaml +4 -4
- data/README.md +1 -1
- data/lib/whoosh/app.rb +89 -53
- data/lib/whoosh/auth/oauth2.rb +69 -6
- data/lib/whoosh/http.rb +24 -0
- data/lib/whoosh/middleware/security_headers.rb +2 -1
- data/lib/whoosh/openapi/generator.rb +13 -1
- data/lib/whoosh/request.rb +10 -0
- data/lib/whoosh/response.rb +24 -0
- data/lib/whoosh/schema.rb +15 -0
- data/lib/whoosh/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3029bc3666ce5b81096d8248de52ac25f31e6cc11e271ad954058d9241bf48fb
|
|
4
|
+
data.tar.gz: 4a62476bf115c24635c2833ddf0814ec49de5b7e036a138e37f197ef8fb51905
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1152664925e99cce8c668a87aa0e9e131bb48a59b4cb967569fa76224f4ae50ab00aa9658231eb9312ea640993cfb46f904bf9248200bf40a5f65df282a7ac71
|
|
7
|
+
data.tar.gz: 8c32d13d07cdfe9fdf8ff5ab201ba72632c054ee9ef60540a06f00dd4d0303755ddb7c22b0e6fad702003e0c3b088c69c062d606e1dee947f08f2330faf74285
|
data/README.md
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
<img src="https://img.shields.io/badge/ruby-%3E%3D%203.4.0-red" alt="Ruby">
|
|
14
14
|
<img src="https://img.shields.io/badge/rack-3.0-blue" alt="Rack">
|
|
15
15
|
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
|
|
16
|
-
<img src="https://img.shields.io/badge/tests-
|
|
16
|
+
<img src="https://img.shields.io/badge/tests-564%20passing-brightgreen" alt="Tests">
|
|
17
17
|
<img src="https://img.shields.io/badge/overhead-2.5%C2%B5s-orange" alt="Performance">
|
|
18
18
|
</p>
|
|
19
19
|
|
data/lib/whoosh/app.rb
CHANGED
|
@@ -250,6 +250,31 @@ module Whoosh
|
|
|
250
250
|
Paginate.cursor(collection, cursor: cursor, limit: limit, column: column)
|
|
251
251
|
end
|
|
252
252
|
|
|
253
|
+
def redirect(url, status: 302)
|
|
254
|
+
Response.redirect(url, status: status)
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def download(data, filename:, content_type: nil)
|
|
258
|
+
Response.download(data, filename: filename, content_type: content_type || "application/octet-stream")
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def send_file(path, content_type: nil)
|
|
262
|
+
Response.file(path, content_type: content_type)
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def serve_static(prefix, root:)
|
|
266
|
+
get "#{prefix}/:_static_path" do |req|
|
|
267
|
+
file_path = File.join(root, req.params[:_static_path])
|
|
268
|
+
real = File.realpath(file_path) rescue nil
|
|
269
|
+
real_root = File.realpath(root) rescue root
|
|
270
|
+
if real && real.start_with?(real_root) && File.file?(real)
|
|
271
|
+
Response.file(real)
|
|
272
|
+
else
|
|
273
|
+
Response.not_found
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
|
|
253
278
|
# --- Endpoint loading ---
|
|
254
279
|
|
|
255
280
|
def load_endpoints(dir)
|
|
@@ -396,66 +421,71 @@ module Whoosh
|
|
|
396
421
|
security_headers = Middleware::SecurityHeaders::HEADERS
|
|
397
422
|
|
|
398
423
|
-> (env) {
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
[
|
|
404
|
-
|
|
424
|
+
begin
|
|
425
|
+
# 1. RequestLimit — check content length
|
|
426
|
+
content_length = env["CONTENT_LENGTH"]&.to_i || 0
|
|
427
|
+
if content_length > max_bytes
|
|
428
|
+
return [413, { "content-type" => "application/json" },
|
|
429
|
+
[JSON.generate({ error: "request_too_large", max_bytes: max_bytes })]]
|
|
430
|
+
end
|
|
405
431
|
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
432
|
+
# 2. Request ID (from RequestLogger)
|
|
433
|
+
request_id = env["HTTP_X_REQUEST_ID"] || SecureRandom.uuid
|
|
434
|
+
env["whoosh.request_id"] = request_id
|
|
435
|
+
|
|
436
|
+
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
437
|
+
|
|
438
|
+
# 3. CORS preflight
|
|
439
|
+
origin = env["HTTP_ORIGIN"]
|
|
440
|
+
if env["REQUEST_METHOD"] == "OPTIONS" && origin
|
|
441
|
+
cors_headers = {
|
|
442
|
+
"access-control-allow-methods" => "GET, POST, PUT, PATCH, DELETE, OPTIONS",
|
|
443
|
+
"access-control-allow-headers" => "Content-Type, Authorization, X-API-Key, X-Request-ID",
|
|
444
|
+
"access-control-max-age" => "86400",
|
|
445
|
+
"access-control-allow-origin" => "*",
|
|
446
|
+
"access-control-expose-headers" => "X-Request-ID",
|
|
447
|
+
"vary" => "Origin"
|
|
448
|
+
}
|
|
449
|
+
return [204, cors_headers, []]
|
|
450
|
+
end
|
|
425
451
|
|
|
426
|
-
|
|
427
|
-
|
|
452
|
+
# 4. Handle request (core)
|
|
453
|
+
status, headers, body = handle_request(env)
|
|
428
454
|
|
|
429
|
-
|
|
430
|
-
|
|
455
|
+
# Ensure headers are mutable (streaming returns frozen headers)
|
|
456
|
+
headers = headers.dup if headers.frozen?
|
|
431
457
|
|
|
432
|
-
|
|
433
|
-
|
|
458
|
+
# 5. Security headers (inline, no allocation)
|
|
459
|
+
security_headers.each { |k, v| headers[k] ||= v }
|
|
434
460
|
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
461
|
+
# 6. CORS headers
|
|
462
|
+
if origin
|
|
463
|
+
headers["access-control-allow-origin"] = "*"
|
|
464
|
+
headers["access-control-expose-headers"] = "X-Request-ID"
|
|
465
|
+
headers["vary"] = "Origin"
|
|
466
|
+
end
|
|
441
467
|
|
|
442
|
-
|
|
443
|
-
|
|
468
|
+
# 7. Request ID in response
|
|
469
|
+
headers["x-request-id"] = request_id
|
|
444
470
|
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
471
|
+
# 8. Logging + metrics
|
|
472
|
+
duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round(2)
|
|
473
|
+
logger.info("request_complete",
|
|
474
|
+
method: env["REQUEST_METHOD"], path: env["PATH_INFO"],
|
|
475
|
+
status: status, duration_ms: duration_ms, request_id: request_id)
|
|
450
476
|
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
477
|
+
if metrics
|
|
478
|
+
metrics.increment("whoosh_requests_total",
|
|
479
|
+
labels: { method: env["REQUEST_METHOD"], path: env["PATH_INFO"], status: status.to_s })
|
|
480
|
+
metrics.observe("whoosh_request_duration_seconds",
|
|
481
|
+
duration_ms / 1000.0, labels: { path: env["PATH_INFO"] })
|
|
482
|
+
end
|
|
457
483
|
|
|
458
|
-
|
|
484
|
+
[status, headers, body]
|
|
485
|
+
rescue => e
|
|
486
|
+
[500, { "content-type" => "application/json" },
|
|
487
|
+
[JSON.generate({ error: "internal_error", message: e.message })]]
|
|
488
|
+
end
|
|
459
489
|
}
|
|
460
490
|
end
|
|
461
491
|
|
|
@@ -509,7 +539,8 @@ module Whoosh
|
|
|
509
539
|
generator.add_route(
|
|
510
540
|
method: route[:method], path: route[:path],
|
|
511
541
|
request_schema: handler[:request_schema],
|
|
512
|
-
response_schema: handler[:response_schema]
|
|
542
|
+
response_schema: handler[:response_schema],
|
|
543
|
+
query_schema: handler[:query_schema]
|
|
513
544
|
)
|
|
514
545
|
end
|
|
515
546
|
|
|
@@ -540,13 +571,14 @@ module Whoosh
|
|
|
540
571
|
})
|
|
541
572
|
end
|
|
542
573
|
|
|
543
|
-
def add_route(method, path, request: nil, response: nil, **metadata, &block)
|
|
574
|
+
def add_route(method, path, request: nil, response: nil, query: nil, **metadata, &block)
|
|
544
575
|
full_path = "#{@group_prefix}#{path}"
|
|
545
576
|
merged_metadata = @group_metadata.merge(metadata)
|
|
546
577
|
handler = {
|
|
547
578
|
block: block,
|
|
548
579
|
request_schema: request,
|
|
549
580
|
response_schema: response,
|
|
581
|
+
query_schema: query,
|
|
550
582
|
middleware: @group_middleware.dup
|
|
551
583
|
}
|
|
552
584
|
@router.add(method, full_path, handler, **merged_metadata)
|
|
@@ -662,6 +694,10 @@ module Whoosh
|
|
|
662
694
|
@strategies[:jwt] = Auth::Jwt.new(secret: secret, algorithm: algorithm, expiry: expiry)
|
|
663
695
|
end
|
|
664
696
|
|
|
697
|
+
def oauth2(provider: :custom, **opts)
|
|
698
|
+
@strategies[:oauth2] = Auth::OAuth2.new(provider: provider, **opts)
|
|
699
|
+
end
|
|
700
|
+
|
|
665
701
|
def build
|
|
666
702
|
@strategies.values.first
|
|
667
703
|
end
|
data/lib/whoosh/auth/oauth2.rb
CHANGED
|
@@ -1,18 +1,74 @@
|
|
|
1
|
-
# lib/whoosh/auth/oauth2.rb
|
|
2
1
|
# frozen_string_literal: true
|
|
3
2
|
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
4
5
|
module Whoosh
|
|
5
6
|
module Auth
|
|
6
7
|
class OAuth2
|
|
7
|
-
|
|
8
|
+
PROVIDERS = {
|
|
9
|
+
google: {
|
|
10
|
+
authorize_url: "https://accounts.google.com/o/oauth2/v2/auth",
|
|
11
|
+
token_url: "https://oauth2.googleapis.com/token",
|
|
12
|
+
userinfo_url: "https://www.googleapis.com/oauth2/v3/userinfo"
|
|
13
|
+
},
|
|
14
|
+
github: {
|
|
15
|
+
authorize_url: "https://github.com/login/oauth/authorize",
|
|
16
|
+
token_url: "https://github.com/login/oauth/access_token",
|
|
17
|
+
userinfo_url: "https://api.github.com/user"
|
|
18
|
+
},
|
|
19
|
+
apple: {
|
|
20
|
+
authorize_url: "https://appleid.apple.com/auth/authorize",
|
|
21
|
+
token_url: "https://appleid.apple.com/auth/token",
|
|
22
|
+
userinfo_url: nil
|
|
23
|
+
}
|
|
24
|
+
}.freeze
|
|
25
|
+
|
|
26
|
+
attr_reader :provider
|
|
27
|
+
|
|
28
|
+
def initialize(provider: :custom, client_id: nil, client_secret: nil,
|
|
29
|
+
authorize_url: nil, token_url: nil, userinfo_url: nil,
|
|
30
|
+
redirect_uri: nil, scopes: [], validator: nil)
|
|
31
|
+
@provider = provider
|
|
32
|
+
@client_id = client_id
|
|
33
|
+
@client_secret = client_secret
|
|
34
|
+
@authorize_url = authorize_url
|
|
8
35
|
@token_url = token_url
|
|
36
|
+
@userinfo_url = userinfo_url
|
|
37
|
+
@redirect_uri = redirect_uri
|
|
38
|
+
@scopes = scopes
|
|
9
39
|
@validator = validator
|
|
40
|
+
apply_provider_defaults if PROVIDERS[@provider]
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def authorize_url(state: SecureRandom.hex(16))
|
|
44
|
+
params = {
|
|
45
|
+
client_id: @client_id, redirect_uri: @redirect_uri,
|
|
46
|
+
response_type: "code", scope: @scopes.join(" "), state: state
|
|
47
|
+
}
|
|
48
|
+
"#{@authorize_url}?#{URI.encode_www_form(params)}"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def exchange_code(code)
|
|
52
|
+
response = HTTP.post(@token_url, json: {
|
|
53
|
+
client_id: @client_id, client_secret: @client_secret,
|
|
54
|
+
code: code, redirect_uri: @redirect_uri, grant_type: "authorization_code"
|
|
55
|
+
}, headers: { "Accept" => "application/json" })
|
|
56
|
+
raise Errors::UnauthorizedError, "Token exchange failed: #{response.status}" unless response.ok?
|
|
57
|
+
response.json
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def userinfo(access_token)
|
|
61
|
+
return nil unless @userinfo_url
|
|
62
|
+
response = HTTP.get(@userinfo_url, headers: {
|
|
63
|
+
"Authorization" => "Bearer #{access_token}", "Accept" => "application/json"
|
|
64
|
+
})
|
|
65
|
+
raise Errors::UnauthorizedError, "Userinfo failed" unless response.ok?
|
|
66
|
+
response.json
|
|
10
67
|
end
|
|
11
68
|
|
|
12
69
|
def authenticate(request)
|
|
13
70
|
auth_header = request.headers["Authorization"]
|
|
14
|
-
raise Errors::UnauthorizedError, "Missing authorization
|
|
15
|
-
|
|
71
|
+
raise Errors::UnauthorizedError, "Missing authorization" unless auth_header
|
|
16
72
|
token = auth_header.sub(/\ABearer\s+/i, "")
|
|
17
73
|
raise Errors::UnauthorizedError, "Missing token" if token.empty?
|
|
18
74
|
|
|
@@ -20,13 +76,20 @@ module Whoosh
|
|
|
20
76
|
result = @validator.call(token)
|
|
21
77
|
raise Errors::UnauthorizedError, "Invalid token" unless result
|
|
22
78
|
result
|
|
79
|
+
elsif @userinfo_url
|
|
80
|
+
userinfo(token)
|
|
23
81
|
else
|
|
24
82
|
{ token: token }
|
|
25
83
|
end
|
|
26
84
|
end
|
|
27
85
|
|
|
28
|
-
|
|
29
|
-
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
def apply_provider_defaults
|
|
89
|
+
defaults = PROVIDERS[@provider]
|
|
90
|
+
@authorize_url ||= defaults[:authorize_url]
|
|
91
|
+
@token_url ||= defaults[:token_url]
|
|
92
|
+
@userinfo_url ||= defaults[:userinfo_url]
|
|
30
93
|
end
|
|
31
94
|
end
|
|
32
95
|
end
|
data/lib/whoosh/http.rb
CHANGED
|
@@ -33,6 +33,22 @@ module Whoosh
|
|
|
33
33
|
request(:delete, url, headers: headers, timeout: timeout)
|
|
34
34
|
end
|
|
35
35
|
|
|
36
|
+
# Run multiple HTTP requests concurrently
|
|
37
|
+
def concurrent(*requests)
|
|
38
|
+
threads = requests.map do |req|
|
|
39
|
+
method = req[:method] || :get
|
|
40
|
+
url = req[:url]
|
|
41
|
+
opts = req.except(:method, :url)
|
|
42
|
+
Thread.new { send(method, url, **opts) }
|
|
43
|
+
end
|
|
44
|
+
threads.map(&:value)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Returns an async client for non-blocking requests
|
|
48
|
+
def async
|
|
49
|
+
AsyncClient.new
|
|
50
|
+
end
|
|
51
|
+
|
|
36
52
|
private
|
|
37
53
|
|
|
38
54
|
def request(method, url, json: nil, body: nil, headers: {}, timeout: 30)
|
|
@@ -69,5 +85,13 @@ module Whoosh
|
|
|
69
85
|
req
|
|
70
86
|
end
|
|
71
87
|
end
|
|
88
|
+
|
|
89
|
+
class AsyncClient
|
|
90
|
+
def get(url, **opts) = Thread.new { HTTP.get(url, **opts) }
|
|
91
|
+
def post(url, **opts) = Thread.new { HTTP.post(url, **opts) }
|
|
92
|
+
def put(url, **opts) = Thread.new { HTTP.put(url, **opts) }
|
|
93
|
+
def patch(url, **opts) = Thread.new { HTTP.patch(url, **opts) }
|
|
94
|
+
def delete(url, **opts) = Thread.new { HTTP.delete(url, **opts) }
|
|
95
|
+
end
|
|
72
96
|
end
|
|
73
97
|
end
|
|
@@ -10,7 +10,8 @@ module Whoosh
|
|
|
10
10
|
"strict-transport-security" => "max-age=31536000; includeSubDomains",
|
|
11
11
|
"x-download-options" => "noopen",
|
|
12
12
|
"x-permitted-cross-domain-policies" => "none",
|
|
13
|
-
"referrer-policy" => "strict-origin-when-cross-origin"
|
|
13
|
+
"referrer-policy" => "strict-origin-when-cross-origin",
|
|
14
|
+
"content-security-policy" => "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'"
|
|
14
15
|
}.freeze
|
|
15
16
|
|
|
16
17
|
def initialize(app)
|
|
@@ -12,7 +12,7 @@ module Whoosh
|
|
|
12
12
|
@paths = {}
|
|
13
13
|
end
|
|
14
14
|
|
|
15
|
-
def add_route(method:, path:, request_schema: nil, response_schema: nil, description: nil)
|
|
15
|
+
def add_route(method:, path:, request_schema: nil, response_schema: nil, query_schema: nil, description: nil)
|
|
16
16
|
openapi_path = path.gsub(/:(\w+)/, '{\1}')
|
|
17
17
|
http_method = method.downcase.to_sym
|
|
18
18
|
@paths[openapi_path] ||= {}
|
|
@@ -23,6 +23,18 @@ module Whoosh
|
|
|
23
23
|
operation[:parameters] = params.map { |p| { name: p, in: "path", required: true, schema: { type: "string" } } }
|
|
24
24
|
end
|
|
25
25
|
|
|
26
|
+
if query_schema
|
|
27
|
+
qs = SchemaConverter.convert(query_schema)
|
|
28
|
+
(qs[:properties] || {}).each do |name, prop|
|
|
29
|
+
operation[:parameters] ||= []
|
|
30
|
+
operation[:parameters] << {
|
|
31
|
+
name: name.to_s, in: "query",
|
|
32
|
+
required: qs[:required]&.include?(name) || false,
|
|
33
|
+
schema: prop, description: prop[:description]
|
|
34
|
+
}
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
26
38
|
if request_schema
|
|
27
39
|
operation[:requestBody] = {
|
|
28
40
|
required: true,
|
data/lib/whoosh/request.rb
CHANGED
|
@@ -54,6 +54,16 @@ module Whoosh
|
|
|
54
54
|
end
|
|
55
55
|
end
|
|
56
56
|
|
|
57
|
+
def cookies
|
|
58
|
+
@cookies ||= begin
|
|
59
|
+
raw = @env["HTTP_COOKIE"] || ""
|
|
60
|
+
raw.split(";").each_with_object({}) do |pair, h|
|
|
61
|
+
k, v = pair.strip.split("=", 2)
|
|
62
|
+
h[k] = v if k
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
57
67
|
def files
|
|
58
68
|
@files ||= begin
|
|
59
69
|
storage = @env["whoosh.storage"]
|
data/lib/whoosh/response.rb
CHANGED
|
@@ -4,6 +4,30 @@ module Whoosh
|
|
|
4
4
|
class Response
|
|
5
5
|
JSON_HEADERS = { "content-type" => "application/json" }.freeze
|
|
6
6
|
|
|
7
|
+
MIME_TYPES = { ".html" => "text/html", ".json" => "application/json", ".css" => "text/css",
|
|
8
|
+
".js" => "application/javascript", ".png" => "image/png", ".jpg" => "image/jpeg",
|
|
9
|
+
".svg" => "image/svg+xml", ".pdf" => "application/pdf", ".txt" => "text/plain" }.freeze
|
|
10
|
+
|
|
11
|
+
def self.redirect(url, status: 302)
|
|
12
|
+
[status, { "location" => url }, []]
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.download(data, filename:, content_type: "application/octet-stream")
|
|
16
|
+
[200, { "content-type" => content_type, "content-disposition" => "attachment; filename=\"#{filename}\"" }, [data]]
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.file(path, content_type: nil)
|
|
20
|
+
raise Errors::NotFoundError unless File.exist?(path)
|
|
21
|
+
ct = content_type || guess_content_type(path)
|
|
22
|
+
body = File.binread(path)
|
|
23
|
+
[200, { "content-type" => ct, "content-length" => body.bytesize.to_s }, [body]]
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.guess_content_type(path)
|
|
27
|
+
ext = File.extname(path).downcase
|
|
28
|
+
MIME_TYPES[ext] || "application/octet-stream"
|
|
29
|
+
end
|
|
30
|
+
|
|
7
31
|
def self.json(data, status: 200, headers: {})
|
|
8
32
|
body = Serialization::Json.encode(data)
|
|
9
33
|
response_headers = if headers.empty?
|
data/lib/whoosh/schema.rb
CHANGED
|
@@ -23,6 +23,16 @@ module Whoosh
|
|
|
23
23
|
super
|
|
24
24
|
subclass.instance_variable_set(:@fields, {})
|
|
25
25
|
subclass.instance_variable_set(:@contract, nil)
|
|
26
|
+
subclass.instance_variable_set(:@custom_validators, [])
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def validate_with(&block)
|
|
30
|
+
@custom_validators ||= []
|
|
31
|
+
@custom_validators << block
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def custom_validators
|
|
35
|
+
@custom_validators || []
|
|
26
36
|
end
|
|
27
37
|
|
|
28
38
|
def field(name, type, **options)
|
|
@@ -78,6 +88,11 @@ module Whoosh
|
|
|
78
88
|
end
|
|
79
89
|
end
|
|
80
90
|
|
|
91
|
+
# Third pass: custom validators
|
|
92
|
+
self.custom_validators.each do |validator|
|
|
93
|
+
validator.call(validated, errors)
|
|
94
|
+
end
|
|
95
|
+
|
|
81
96
|
return Result.new(data: nil, errors: errors) unless errors.empty?
|
|
82
97
|
|
|
83
98
|
Result.new(data: apply_defaults(validated), errors: [])
|
data/lib/whoosh/version.rb
CHANGED