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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5baff744454d485e7f9788aca18285ece0c8901a5abea6f9c2f3fd4a22a7de75
4
- data.tar.gz: 6c2062d73b6bec5e60c0adfd897e7e8aec5d10fa366439471592fc196bfe5bbd
3
+ metadata.gz: 3029bc3666ce5b81096d8248de52ac25f31e6cc11e271ad954058d9241bf48fb
4
+ data.tar.gz: 4a62476bf115c24635c2833ddf0814ec49de5b7e036a138e37f197ef8fb51905
5
5
  SHA512:
6
- metadata.gz: 7ef32b36e7405fe117eefc60908baf71a9553d328ccd2fd7823c715b7e37d4f01aeedc29a7cb1709e865410d407a32bc511f49353b90339702a06bb7f9a31425
7
- data.tar.gz: 75188ded90b9205cb7c779d18252ef842989ec84d76ad78e1167d4ca267e544918b39c5d2a3596abe0b2d6001620e98e4272a75bdd62491b4130127bba78487c
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-542%20passing-brightgreen" alt="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
- # 1. RequestLimit — check content length
400
- content_length = env["CONTENT_LENGTH"]&.to_i || 0
401
- if content_length > max_bytes
402
- return [413, { "content-type" => "application/json" },
403
- [JSON.generate({ error: "request_too_large", max_bytes: max_bytes })]]
404
- end
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
- # 2. Request ID (from RequestLogger)
407
- request_id = env["HTTP_X_REQUEST_ID"] || SecureRandom.uuid
408
- env["whoosh.request_id"] = request_id
409
-
410
- start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
411
-
412
- # 3. CORS preflight
413
- origin = env["HTTP_ORIGIN"]
414
- if env["REQUEST_METHOD"] == "OPTIONS" && origin
415
- cors_headers = {
416
- "access-control-allow-methods" => "GET, POST, PUT, PATCH, DELETE, OPTIONS",
417
- "access-control-allow-headers" => "Content-Type, Authorization, X-API-Key, X-Request-ID",
418
- "access-control-max-age" => "86400",
419
- "access-control-allow-origin" => "*",
420
- "access-control-expose-headers" => "X-Request-ID",
421
- "vary" => "Origin"
422
- }
423
- return [204, cors_headers, []]
424
- end
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
- # 4. Handle request (core)
427
- status, headers, body = handle_request(env)
452
+ # 4. Handle request (core)
453
+ status, headers, body = handle_request(env)
428
454
 
429
- # Ensure headers are mutable (streaming returns frozen headers)
430
- headers = headers.dup if headers.frozen?
455
+ # Ensure headers are mutable (streaming returns frozen headers)
456
+ headers = headers.dup if headers.frozen?
431
457
 
432
- # 5. Security headers (inline, no allocation)
433
- security_headers.each { |k, v| headers[k] ||= v }
458
+ # 5. Security headers (inline, no allocation)
459
+ security_headers.each { |k, v| headers[k] ||= v }
434
460
 
435
- # 6. CORS headers
436
- if origin
437
- headers["access-control-allow-origin"] = "*"
438
- headers["access-control-expose-headers"] = "X-Request-ID"
439
- headers["vary"] = "Origin"
440
- end
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
- # 7. Request ID in response
443
- headers["x-request-id"] = request_id
468
+ # 7. Request ID in response
469
+ headers["x-request-id"] = request_id
444
470
 
445
- # 8. Logging + metrics
446
- duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round(2)
447
- logger.info("request_complete",
448
- method: env["REQUEST_METHOD"], path: env["PATH_INFO"],
449
- status: status, duration_ms: duration_ms, request_id: request_id)
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
- if metrics
452
- metrics.increment("whoosh_requests_total",
453
- labels: { method: env["REQUEST_METHOD"], path: env["PATH_INFO"], status: status.to_s })
454
- metrics.observe("whoosh_request_duration_seconds",
455
- duration_ms / 1000.0, labels: { path: env["PATH_INFO"] })
456
- end
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
- [status, headers, body]
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
@@ -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
- def initialize(token_url: "/oauth/token", validator: nil)
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 header" unless auth_header
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
- def token_url
29
- @token_url
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,
@@ -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"]
@@ -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: [])
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Whoosh
4
- VERSION = "1.4.1"
4
+ VERSION = "1.5.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: whoosh
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.4.1
4
+ version: 1.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Johannes Dwi Cahyo