tina4ruby 3.13.41 → 3.13.43

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: 74de2870c01871240bb98d6ceeb66cd5bc5bf05c6e1c4e82d19a198d585d83e6
4
- data.tar.gz: 9d548edf025f53d6547216f3a421d9215eb893cfda89131c342910ebc2c820bb
3
+ metadata.gz: 209245b1743578b21530e31feb670940f068cc911725fff1b2a59ee6d8335e10
4
+ data.tar.gz: '059d0fde8d4fbc57db9e4aa408d0dd31f71b1c8065d5041258a8a502017ce7ab'
5
5
  SHA512:
6
- metadata.gz: 3d2db7a1fc7dfcb95848dbc80c4f76664800bd6692a460667f5dfcbb59cb59fcba101737773c4eda4b4731275f735b4ef494164388869ded0849d29bb6de3804
7
- data.tar.gz: 49e9c4742313a45ebb610cc5840c1b277915db9b1a5eb3a98dcb5b680a93d336b5b1d846698a9ec9121d2d722c905b8e0261b2532d050d0d7805b27a6c2c5228
6
+ metadata.gz: 30babc583e40444ec46069088e1af2a94f0485546b585530ccda22cca2a8ba29fafca3464bd08845fd1d9f88e92342533f2c58a82b89f38a517fce607096f905
7
+ data.tar.gz: 84960f5d44cb50d7ec4d8dd31a6645e10640526aaf6c9f777e81c7376a43290a6027d33d903f45a317f369834eee59518ec9d215d05e8933c820e5d3020ae49e
data/lib/tina4/queue.rb CHANGED
@@ -305,6 +305,10 @@ module Tina4
305
305
  config = resolve_mongo_config
306
306
  config[:visibility_timeout] = vt
307
307
  config[:max_retries] = max_retries
308
+ # Thread retry_backoff through so a failed/retried job's available_at is
309
+ # reset to now (or now + retry_backoff) instead of being stranded for the
310
+ # full visibility window (Bug B).
311
+ config[:retry_backoff] = retry_backoff
308
312
  Tina4::QueueBackends::MongoBackend.new(config)
309
313
  else
310
314
  raise ArgumentError, "Unknown queue backend: #{chosen.inspect}. Use 'lite', 'rabbitmq', 'kafka', or 'mongodb'."
@@ -5,11 +5,16 @@ module Tina4
5
5
  class MongoBackend
6
6
  # Reservation/visibility + retry policy (settable so a Queue can propagate
7
7
  # its own onto a backend instance passed directly — legacy path).
8
- attr_accessor :visibility_timeout, :max_retries
8
+ attr_accessor :visibility_timeout, :max_retries, :retry_backoff
9
9
 
10
10
  def initialize(options = {})
11
11
  require "mongo"
12
12
  @max_retries = options[:max_retries] || 3
13
+ # Seconds to delay a requeued (failed/retried) job before it is eligible
14
+ # again. Default 0 = available on the very next dequeue, so a fail()'d job
15
+ # retries immediately (matching the lite backend) instead of waiting out
16
+ # the visibility window.
17
+ @retry_backoff = (options[:retry_backoff] || 0).to_f
13
18
 
14
19
  uri = options[:uri] || ENV["TINA4_MONGO_URI"]
15
20
  host = options[:host] || ENV.fetch("TINA4_MONGO_HOST", "localhost")
@@ -134,10 +139,58 @@ module Tina4
134
139
  collection.delete_one(_id: message.id)
135
140
  end
136
141
 
142
+ # Terminal success — the job is done and removed (mirrors the lite
143
+ # backend's complete()). Without this, job.complete() was a no-op on
144
+ # MongoDB and the document stayed "processing" forever.
145
+ def complete(message)
146
+ collection.delete_one(_id: message.id)
147
+ end
148
+
149
+ # Record a failed attempt (mirrors the lite backend + the Python Mongo
150
+ # adapter). Increments attempts; while attempts < max_retries the job is
151
+ # re-queued to pending (visible again immediately, or after retry_backoff),
152
+ # otherwise it is dead-lettered. The Queue/Job lifecycle expects fail() to
153
+ # route the requeue here — previously MongoBackend had no fail(), so
154
+ # job.fail() degraded to in-memory bookkeeping and never touched Mongo.
155
+ def fail(job, error = "")
156
+ job.attempts += 1
157
+ if job.attempts >= @max_retries
158
+ collection.find_one_and_update(
159
+ { _id: job.id },
160
+ { "$set" => { status: "dead", topic: "#{job.topic}.dead_letter",
161
+ error: error, reserved_at: nil } },
162
+ upsert: true
163
+ )
164
+ else
165
+ requeue_with_error(job, error)
166
+ end
167
+ end
168
+
169
+ # Explicit re-queue requested by the caller (job.retry()). Always
170
+ # re-enqueues regardless of the retry limit — a manual override, distinct
171
+ # from the automatic fail() path. Increments attempts, clears the error.
172
+ def retry(job, delay_seconds: 0)
173
+ job.attempts += 1
174
+ backoff = delay_seconds.to_f > 0 ? delay_seconds.to_f : @retry_backoff
175
+ collection.find_one_and_update(
176
+ { _id: job.id },
177
+ { "$set" => { status: "pending", error: nil, reserved_at: nil,
178
+ available_at: requeue_available_at(backoff) } },
179
+ upsert: true
180
+ )
181
+ end
182
+
137
183
  def requeue(message)
184
+ # Reset available_at so the requeued job is visible again right away (or
185
+ # after retry_backoff) and clear reserved_at. dequeue() pushed
186
+ # available_at out to the reservation expiry; leaving it there stranded a
187
+ # requeued job for the full visibility window instead of retrying it on
188
+ # the next pop().
138
189
  collection.find_one_and_update(
139
190
  { _id: message.id },
140
- { "$set" => { status: "pending" }, "$inc" => { attempts: 1 } },
191
+ { "$set" => { status: "pending", reserved_at: nil,
192
+ available_at: requeue_available_at(@retry_backoff) },
193
+ "$inc" => { attempts: 1 } },
141
194
  upsert: true
142
195
  )
143
196
  end
@@ -172,7 +225,11 @@ module Tina4
172
225
  def retry_failed(topic, max_retries: 3)
173
226
  result = collection.update_many(
174
227
  { topic: topic, status: "failed", attempts: { "$lt" => max_retries } },
175
- { "$set" => { status: "pending" } }
228
+ # Reset available_at so re-queued failed jobs are visible again — they
229
+ # were reserved with available_at in the future at dequeue. Clear
230
+ # reserved_at too. (Same Bug B reason as requeue/fail.)
231
+ { "$set" => { status: "pending", error: nil, reserved_at: nil,
232
+ available_at: requeue_available_at(@retry_backoff) } }
176
233
  )
177
234
  result.modified_count
178
235
  end
@@ -183,6 +240,31 @@ module Tina4
183
240
 
184
241
  private
185
242
 
243
+ # Re-queue a failed job to pending, carrying the failure reason and
244
+ # resetting available_at (now, or now + retry_backoff) + clearing
245
+ # reserved_at so the next dequeue picks it up.
246
+ def requeue_with_error(job, error)
247
+ collection.find_one_and_update(
248
+ { _id: job.id },
249
+ # Persist the incremented attempt count (fail() already did job.attempts
250
+ # += 1). Without this the doc's attempts stayed at its old value, so the
251
+ # next dequeue surfaced a stale count, fail()'s attempts >= max_retries
252
+ # check never tripped, and the job was re-queued forever instead of
253
+ # dead-lettering.
254
+ { "$set" => { status: "pending", error: error, reserved_at: nil,
255
+ attempts: job.attempts,
256
+ available_at: requeue_available_at(@retry_backoff) } },
257
+ upsert: true
258
+ )
259
+ end
260
+
261
+ # available_at for a re-queued job: now (immediately retryable) or
262
+ # now + backoff when a backoff is configured.
263
+ def requeue_available_at(backoff)
264
+ b = (backoff || 0).to_f
265
+ b > 0 ? (Time.now.utc + b) : Time.now.utc
266
+ end
267
+
186
268
  def collection
187
269
  @db[@collection_name]
188
270
  end
data/lib/tina4/swagger.rb CHANGED
@@ -3,23 +3,68 @@ require "json"
3
3
 
4
4
  module Tina4
5
5
  module Swagger
6
+ # Process-wide registries for security schemes and reusable component
7
+ # schemas declared programmatically (add_security_scheme / add_schema).
8
+ # Kept module-level so app bootstrap can register before any generate()
9
+ # call; reset_registry clears them (tests).
10
+ @registered_schemes = {}
11
+ @registered_schemas = {}
12
+
6
13
  class << self
14
+ # ── Programmatic registries ────────────────────────────────────
15
+
16
+ # Register a named OpenAPI security scheme (e.g. an oauth2 scheme with
17
+ # scopes, or a custom apiKey). Call at app bootstrap, before generate().
18
+ #
19
+ # Tina4::Swagger.add_security_scheme("oauth2", {
20
+ # "type" => "oauth2",
21
+ # "flows" => { "clientCredentials" => {
22
+ # "tokenUrl" => "https://api.example.com/oauth/token",
23
+ # "scopes" => { "read:users" => "Read users", "write:users" => "Write users" }
24
+ # } }
25
+ # })
26
+ def add_security_scheme(name, definition)
27
+ @registered_schemes[name.to_s] = definition
28
+ end
29
+
30
+ # Register a reusable component schema, referenceable via
31
+ # swagger_meta[:request_schema] / [:response_schemas] or a raw $ref.
32
+ def add_schema(name, schema)
33
+ @registered_schemas[name.to_s] = schema
34
+ end
35
+
36
+ # Clear the security-scheme and schema registries (test helper).
37
+ def reset_registry
38
+ @registered_schemes = {}
39
+ @registered_schemas = {}
40
+ end
41
+
7
42
  def generate(routes = [])
8
43
  spec = base_spec
9
44
  route_list = routes.empty? ? Tina4::Router.routes : routes
10
45
  # Accumulators shared across routes: ORM models referenced
11
- # (-> components.schemas), tags used (-> top-level tags[]), seen
12
- # operationIds (de-dup OpenAPI requires them unique).
13
- ctx = { models: {}, used_tags: [], seen_ids: [] }
46
+ # (-> components.schemas), registered custom-schema names referenced
47
+ # by routes, tags used (-> top-level tags[]), seen operationIds
48
+ # (de-dup OpenAPI requires them unique).
49
+ ctx = { models: {}, ref_schemas: [], used_tags: [], seen_ids: [],
50
+ schemes: spec["components"]["securitySchemes"] }
14
51
  route_list.each do |route|
52
+ next unless included?(route.path)
53
+
15
54
  add_route_to_spec(spec, route, ctx)
16
55
  end
17
56
 
18
- unless ctx[:models].empty?
19
- spec["components"]["schemas"] = {}
57
+ unless ctx[:models].empty? && ctx[:ref_schemas].empty?
58
+ spec["components"]["schemas"] ||= {}
20
59
  ctx[:models].each do |name, klass|
21
60
  spec["components"]["schemas"][name] = model_schema(klass)
22
61
  end
62
+ ctx[:ref_schemas].each do |name|
63
+ next unless @registered_schemas.key?(name)
64
+ next if spec["components"]["schemas"].key?(name)
65
+
66
+ spec["components"]["schemas"][name] = @registered_schemas[name]
67
+ end
23
68
  end
24
69
  spec["tags"] = ctx[:used_tags].map { |t| { "name" => t } } unless ctx[:used_tags].empty?
25
70
 
@@ -63,22 +108,88 @@ module Tina4
63
108
  end
64
109
 
65
110
  {
66
- "openapi" => "3.0.3",
111
+ "openapi" => resolve_openapi_version(ENV["TINA4_SWAGGER_OPENAPI"]),
67
112
  "info" => info,
68
113
  "servers" => servers,
69
114
  "paths" => {},
70
115
  "components" => {
71
- "securitySchemes" => {
72
- "bearerAuth" => {
73
- "type" => "http",
74
- "scheme" => "bearer",
75
- "bearerFormat" => "JWT"
76
- }
77
- }
116
+ "securitySchemes" => security_schemes
78
117
  }
79
118
  }
80
119
  end
81
120
 
121
+ # OpenAPI version — default 3.0.3 for broad tool compatibility; opt in to
122
+ # 3.1.0 via TINA4_SWAGGER_OPENAPI=3.1 (the schemas this generator emits
123
+ # are valid in both dialects).
124
+ def resolve_openapi_version(val)
125
+ v = val.to_s.strip
126
+ return "3.0.3" if v.empty?
127
+ return "3.1.0" if %w[3.1 3.1.0].include?(v)
128
+ return "3.0.3" if %w[3.0 3.0.3].include?(v)
129
+
130
+ v # honour an explicit full version verbatim
131
+ end
132
+
133
+ # Resolve components.securitySchemes from defaults + env + registry.
134
+ def security_schemes
135
+ schemes = {
136
+ "bearerAuth" => {
137
+ "type" => "http",
138
+ "scheme" => "bearer",
139
+ # Default bearer format (the built-in bearerAuth scheme). JWT unless an
140
+ # API uses opaque tokens / API keys as the bearer (e.g. sk_live_...).
141
+ "bearerFormat" => ENV["TINA4_SWAGGER_BEARER_FORMAT"] || "JWT"
142
+ }
143
+ }
144
+
145
+ # Optional apiKey scheme — emitted as "apiKeyAuth" when a header/query
146
+ # name is configured (e.g. X-Api-Key).
147
+ api_key_name = ENV["TINA4_SWAGGER_API_KEY_NAME"]
148
+ if api_key_name && !api_key_name.empty?
149
+ api_key_in = ENV["TINA4_SWAGGER_API_KEY_IN"] || "header"
150
+ api_key_in = "header" unless %w[header query cookie].include?(api_key_in)
151
+ schemes["apiKeyAuth"] = {
152
+ "type" => "apiKey",
153
+ "name" => api_key_name,
154
+ "in" => api_key_in
155
+ }
156
+ end
157
+
158
+ # Registered schemes win (let an app override bearerAuth or add oauth2).
159
+ @registered_schemes.each { |name, defn| schemes[name] = defn }
160
+ schemes
161
+ end
162
+
163
+ # Which scheme secured routes use by default when no per-route security
164
+ # is declared.
165
+ def default_scheme
166
+ scheme = ENV["TINA4_SWAGGER_DEFAULT_SCHEME"]
167
+ scheme && !scheme.empty? ? scheme : "bearerAuth"
168
+ end
169
+
170
+ # Path filtering. Framework internals (/swagger, /__dev) are ALWAYS
171
+ # excluded; then TINA4_SWAGGER_INCLUDE (allow-list) / _EXCLUDE apply.
172
+ def included?(raw_path)
173
+ ["/swagger", "/__dev"].each do |internal|
174
+ return false if raw_path == internal || raw_path.start_with?("#{internal}/")
175
+ end
176
+
177
+ includes = csv(ENV["TINA4_SWAGGER_INCLUDE"])
178
+ unless includes.empty?
179
+ matched = includes.any? { |p| raw_path == p || raw_path.start_with?(p) }
180
+ return false unless matched
181
+ end
182
+
183
+ excludes = csv(ENV["TINA4_SWAGGER_EXCLUDE"])
184
+ return false if excludes.any? { |p| raw_path == p || raw_path.start_with?(p) }
185
+
186
+ true
187
+ end
188
+
189
+ def csv(val)
190
+ val.to_s.split(",").map(&:strip).reject(&:empty?)
191
+ end
192
+
82
193
  # servers[] — TINA4_SWAGGER_SERVERS (comma-separated) for a multi-server
83
194
  # list, else SWAGGER_DEV_URL, else the relative "/" default.
84
195
  def servers
@@ -124,31 +235,135 @@ module Tina4
124
235
  "description" => meta[:description] || "",
125
236
  "tags" => tags,
126
237
  "parameters" => build_parameters(route),
127
- "responses" => meta[:responses] || model_or_default_responses(ref, meta[:model_list])
238
+ "responses" => build_responses(meta, ref, ctx)
128
239
  }
129
240
 
130
241
  operation["deprecated"] = true if meta[:deprecated]
131
242
 
132
- if route.auth_handler
133
- operation["security"] = [{ "bearerAuth" => [] }]
134
- end
243
+ security = resolve_security(meta, route, ctx[:schemes])
244
+ operation["security"] = security unless security.nil?
135
245
 
136
246
  if %w[post put patch].include?(method)
137
- operation["requestBody"] = build_request_body(method, meta, ref)
247
+ operation["requestBody"] = build_request_body(method, meta, ref, ctx)
138
248
  end
139
249
 
140
250
  spec["paths"][path][method] = operation
141
251
  end
142
252
 
143
- def build_request_body(_method, meta, ref)
253
+ # Auth — explicit per-route security wins (an explicit "public"/[] = no
254
+ # security); otherwise a secured route gets the default scheme. Returns
255
+ # nil when the route declares nothing and is not secured (omit the key).
256
+ def resolve_security(meta, route, schemes)
257
+ if meta.key?(:security)
258
+ reqs = normalize_security(meta[:security], meta[:scopes])
259
+ return reqs.empty? ? [] : sanitize_security(reqs, schemes)
260
+ end
261
+
262
+ return sanitize_security([{ default_scheme => [] }], schemes) if route.auth_handler
263
+
264
+ nil
265
+ end
266
+
267
+ # Normalize swagger_meta[:security] to an OpenAPI security-requirement list.
268
+ #
269
+ # security: "oauth2", scopes: ["read"] -> [{"oauth2" => ["read"]}]
270
+ # security: { "bearerAuth" => [] } -> [{"bearerAuth" => []}] (AND within one map)
271
+ # security: [{"oauth2"=>["read"]}, {...}] -> verbatim (OR across maps)
272
+ # security: "public" / [] -> [] (explicitly no auth)
273
+ def normalize_security(value, scopes)
274
+ scope_list = Array(scopes).map(&:to_s)
275
+ if value.nil? || value == [] || (value.is_a?(String) && %w[public none].include?(value))
276
+ return []
277
+ end
278
+
279
+ case value
280
+ when String
281
+ [{ value => scope_list }]
282
+ when Hash
283
+ [value.each_with_object({}) { |(k, v), h| h[k.to_s] = Array(v).map(&:to_s) }]
284
+ when Array
285
+ value.map { |req| req.each_with_object({}) { |(k, v), h| h[k.to_s] = Array(v).map(&:to_s) } }
286
+ else
287
+ []
288
+ end
289
+ end
290
+
291
+ # Keep a security-requirement list spec-valid: scopes are allowed only on
292
+ # oauth2/openIdConnect schemes; everything else gets an empty array
293
+ # (OpenAPI requires that for http/apiKey).
294
+ def sanitize_security(reqs, schemes)
295
+ scope_ok = %w[oauth2 openIdConnect]
296
+ reqs.map do |req|
297
+ req.each_with_object({}) do |(name, scopes), clean|
298
+ stype = (schemes[name] || {})["type"]
299
+ clean[name] = scope_ok.include?(stype) ? Array(scopes) : []
300
+ end
301
+ end
302
+ end
303
+
304
+ def build_request_body(_method, meta, ref, ctx)
144
305
  return meta[:request_body] if meta[:request_body]
145
306
 
307
+ # Registered custom request schema ($ref) wins over the ORM-model ref.
308
+ if (req_schema = meta[:request_schema])
309
+ name, content_type = request_schema_parts(req_schema)
310
+ ctx[:ref_schemas] << name unless ctx[:ref_schemas].include?(name)
311
+ content = { "schema" => { "$ref" => "#/components/schemas/#{name}" } }
312
+ content["example"] = meta[:example] if meta[:example]
313
+ return { "content" => { content_type => content } }
314
+ end
315
+
146
316
  schema = ref ? { "$ref" => ref } : { "type" => "object" }
147
317
  content = { "schema" => schema }
148
318
  content["example"] = meta[:example] if meta[:example]
149
319
  { "content" => { "application/json" => content } }
150
320
  end
151
321
 
322
+ # swagger_meta[:request_schema] accepts "Name" or { name:, content_type: }.
323
+ def request_schema_parts(req_schema)
324
+ if req_schema.is_a?(Hash)
325
+ [(req_schema[:name] || req_schema["name"]).to_s,
326
+ (req_schema[:content_type] || req_schema["content_type"] || "application/json").to_s]
327
+ else
328
+ [req_schema.to_s, "application/json"]
329
+ end
330
+ end
331
+
332
+ def build_responses(meta, ref, ctx)
333
+ return meta[:responses] if meta[:responses]
334
+
335
+ responses = model_or_default_responses(ref, meta[:model_list])
336
+
337
+ # Registered response schemas ($ref) — explicit and authoritative.
338
+ if (resp_schemas = meta[:response_schemas])
339
+ resp_schemas.each do |status, spec|
340
+ name, is_list = response_schema_parts(spec)
341
+ ctx[:ref_schemas] << name unless ctx[:ref_schemas].include?(name)
342
+ sref = "#/components/schemas/#{name}"
343
+ schema = is_list ? { "type" => "array", "items" => { "$ref" => sref } } : { "$ref" => sref }
344
+ responses[status.to_s] = {
345
+ "description" => status.to_s.start_with?("2") ? "Successful response" : "Response",
346
+ "content" => { "application/json" => { "schema" => schema } }
347
+ }
348
+ end
349
+ end
350
+
351
+ responses
352
+ end
353
+
354
+ # A response-schema entry is "Name", { name:, list: } or [name, is_list].
355
+ def response_schema_parts(spec)
356
+ case spec
357
+ when Hash
358
+ [(spec[:name] || spec["name"]).to_s,
359
+ !!(spec[:list] || spec["list"] || spec[:is_list] || spec["is_list"])]
360
+ when Array
361
+ [spec[0].to_s, !!spec[1]]
362
+ else
363
+ [spec.to_s, false]
364
+ end
365
+ end
366
+
152
367
  def model_or_default_responses(ref, model_list)
153
368
  return default_responses unless ref
154
369
 
data/lib/tina4/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Tina4
4
- VERSION = "3.13.41"
4
+ VERSION = "3.13.43"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tina4ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.13.41
4
+ version: 3.13.43
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tina4 Team