sec_api 1.0.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 (75) hide show
  1. checksums.yaml +7 -0
  2. data/.devcontainer/Dockerfile +54 -0
  3. data/.devcontainer/README.md +178 -0
  4. data/.devcontainer/devcontainer.json +46 -0
  5. data/.devcontainer/docker-compose.yml +28 -0
  6. data/.devcontainer/post-create.sh +51 -0
  7. data/.devcontainer/post-start.sh +44 -0
  8. data/.rspec +3 -0
  9. data/.standard.yml +3 -0
  10. data/CHANGELOG.md +5 -0
  11. data/CLAUDE.md +0 -0
  12. data/LICENSE.txt +21 -0
  13. data/MIGRATION.md +274 -0
  14. data/README.md +370 -0
  15. data/Rakefile +10 -0
  16. data/config/secapi.yml.example +57 -0
  17. data/docs/development-guide.md +291 -0
  18. data/docs/enumerator_pattern_design.md +483 -0
  19. data/docs/examples/README.md +58 -0
  20. data/docs/examples/backfill_filings.rb +419 -0
  21. data/docs/examples/instrumentation.rb +583 -0
  22. data/docs/examples/query_builder.rb +308 -0
  23. data/docs/examples/streaming_notifications.rb +491 -0
  24. data/docs/index.md +244 -0
  25. data/docs/migration-guide-v1.md +1091 -0
  26. data/docs/pre-review-checklist.md +145 -0
  27. data/docs/project-overview.md +90 -0
  28. data/docs/project-scan-report.json +60 -0
  29. data/docs/source-tree-analysis.md +190 -0
  30. data/lib/sec_api/callback_helper.rb +49 -0
  31. data/lib/sec_api/client.rb +606 -0
  32. data/lib/sec_api/collections/filings.rb +267 -0
  33. data/lib/sec_api/collections/fulltext_results.rb +86 -0
  34. data/lib/sec_api/config.rb +590 -0
  35. data/lib/sec_api/deep_freezable.rb +42 -0
  36. data/lib/sec_api/errors/authentication_error.rb +24 -0
  37. data/lib/sec_api/errors/configuration_error.rb +5 -0
  38. data/lib/sec_api/errors/error.rb +75 -0
  39. data/lib/sec_api/errors/network_error.rb +26 -0
  40. data/lib/sec_api/errors/not_found_error.rb +23 -0
  41. data/lib/sec_api/errors/pagination_error.rb +28 -0
  42. data/lib/sec_api/errors/permanent_error.rb +29 -0
  43. data/lib/sec_api/errors/rate_limit_error.rb +57 -0
  44. data/lib/sec_api/errors/reconnection_error.rb +34 -0
  45. data/lib/sec_api/errors/server_error.rb +25 -0
  46. data/lib/sec_api/errors/transient_error.rb +28 -0
  47. data/lib/sec_api/errors/validation_error.rb +23 -0
  48. data/lib/sec_api/extractor.rb +122 -0
  49. data/lib/sec_api/filing_journey.rb +477 -0
  50. data/lib/sec_api/mapping.rb +125 -0
  51. data/lib/sec_api/metrics_collector.rb +411 -0
  52. data/lib/sec_api/middleware/error_handler.rb +250 -0
  53. data/lib/sec_api/middleware/instrumentation.rb +186 -0
  54. data/lib/sec_api/middleware/rate_limiter.rb +541 -0
  55. data/lib/sec_api/objects/data_file.rb +34 -0
  56. data/lib/sec_api/objects/document_format_file.rb +45 -0
  57. data/lib/sec_api/objects/entity.rb +92 -0
  58. data/lib/sec_api/objects/extracted_data.rb +118 -0
  59. data/lib/sec_api/objects/fact.rb +147 -0
  60. data/lib/sec_api/objects/filing.rb +197 -0
  61. data/lib/sec_api/objects/fulltext_result.rb +66 -0
  62. data/lib/sec_api/objects/period.rb +96 -0
  63. data/lib/sec_api/objects/stream_filing.rb +194 -0
  64. data/lib/sec_api/objects/xbrl_data.rb +356 -0
  65. data/lib/sec_api/query.rb +423 -0
  66. data/lib/sec_api/rate_limit_state.rb +130 -0
  67. data/lib/sec_api/rate_limit_tracker.rb +154 -0
  68. data/lib/sec_api/stream.rb +841 -0
  69. data/lib/sec_api/structured_logger.rb +199 -0
  70. data/lib/sec_api/types.rb +32 -0
  71. data/lib/sec_api/version.rb +42 -0
  72. data/lib/sec_api/xbrl.rb +220 -0
  73. data/lib/sec_api.rb +137 -0
  74. data/sig/sec_api.rbs +4 -0
  75. metadata +217 -0
@@ -0,0 +1,541 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "securerandom"
5
+ require "json"
6
+
7
+ module SecApi
8
+ module Middleware
9
+ # Faraday middleware that extracts rate limit headers from API responses,
10
+ # proactively throttles requests when approaching the rate limit, and
11
+ # queues requests when the rate limit is exhausted.
12
+ #
13
+ # This middleware parses X-RateLimit-* headers from sec-api.io responses
14
+ # and updates a shared state store with the current rate limit information.
15
+ # When the remaining quota drops below a configurable threshold, the middleware
16
+ # will sleep until the rate limit window resets to avoid hitting 429 errors.
17
+ # When remaining reaches 0 (exhausted), requests are queued until reset.
18
+ #
19
+ # Headers parsed:
20
+ # - X-RateLimit-Limit: Total requests allowed per time window
21
+ # - X-RateLimit-Remaining: Requests remaining in current window
22
+ # - X-RateLimit-Reset: Unix timestamp when the limit resets
23
+ #
24
+ # Position in middleware stack: After Retry, before ErrorHandler
25
+ # This ensures we capture headers from the final response (after retries)
26
+ # and can extract rate limit info even from error responses (429).
27
+ #
28
+ # @example Middleware stack integration
29
+ # Faraday.new(url: base_url) do |conn|
30
+ # conn.request :retry, retry_options
31
+ # conn.use SecApi::Middleware::RateLimiter,
32
+ # state_store: tracker,
33
+ # threshold: 0.1 # Throttle when < 10% remaining
34
+ # conn.use SecApi::Middleware::ErrorHandler
35
+ # conn.adapter Faraday.default_adapter
36
+ # end
37
+ #
38
+ # @see SecApi::RateLimitTracker Thread-safe state storage
39
+ # @see SecApi::RateLimitState Immutable state value object
40
+ #
41
+ class RateLimiter < Faraday::Middleware
42
+ # Header name for total requests allowed per time window.
43
+ # @return [String] lowercase header name
44
+ LIMIT_HEADER = "x-ratelimit-limit"
45
+
46
+ # Header name for requests remaining in current window.
47
+ # @return [String] lowercase header name
48
+ REMAINING_HEADER = "x-ratelimit-remaining"
49
+
50
+ # Header name for Unix timestamp when the limit resets.
51
+ # @return [String] lowercase header name
52
+ RESET_HEADER = "x-ratelimit-reset"
53
+
54
+ # Default throttle threshold (10% remaining).
55
+ # Rationale: 10% provides a safety buffer to avoid hitting 429 while not being overly
56
+ # conservative. At typical sec-api.io limits (~100 req/min), 10% = 10 requests buffer,
57
+ # which handles small bursts. Lower values risk 429s; higher values waste capacity.
58
+ # (Architecture ADR-4: Rate Limiting Strategy)
59
+ DEFAULT_THRESHOLD = 0.1
60
+
61
+ # Default warning threshold for excessive wait times (5 minutes).
62
+ # Rationale: 5 minutes is long enough to indicate potential issues (API outage,
63
+ # misconfigured limits) but short enough to be actionable. Matches typical
64
+ # monitoring alert thresholds for request latency.
65
+ DEFAULT_QUEUE_WAIT_WARNING_THRESHOLD = 300
66
+
67
+ # Default wait time when rate limit is exhausted but reset_at is unknown (60 seconds).
68
+ # Rationale: sec-api.io rate limit windows are typically 60 seconds. When the API
69
+ # doesn't send X-RateLimit-Reset header, this provides a reasonable fallback that
70
+ # aligns with expected window duration without excessive waiting.
71
+ DEFAULT_QUEUE_WAIT_SECONDS = 60
72
+
73
+ # Creates a new RateLimiter middleware instance.
74
+ #
75
+ # @param app [#call] The next middleware in the stack
76
+ # @param options [Hash] Configuration options
77
+ # @option options [RateLimitTracker] :state_store The tracker to update with rate limit info
78
+ # @option options [Float] :threshold (0.1) Throttle when percentage remaining drops below
79
+ # this value (0.0-1.0). Default is 0.1 (10%).
80
+ # @option options [Proc, nil] :on_throttle Callback invoked when throttling occurs.
81
+ # Receives a hash with :remaining, :limit, :delay, :reset_at, and :request_id keys.
82
+ # @option options [Proc, nil] :on_queue Callback invoked when a request is queued
83
+ # due to exhausted rate limit (remaining = 0). Receives a hash with :queue_size,
84
+ # :wait_time, :reset_at, and :request_id keys.
85
+ # @option options [Integer] :queue_wait_warning_threshold (300) Seconds threshold
86
+ # for warning about excessive wait times. Default is 300 (5 minutes).
87
+ # @option options [Proc, nil] :on_excessive_wait Callback invoked when wait time
88
+ # exceeds queue_wait_warning_threshold. Receives a hash with :wait_time,
89
+ # :threshold, :reset_at, and :request_id keys.
90
+ # @option options [Proc, nil] :on_dequeue Callback invoked when a request exits
91
+ # the queue (after waiting). Receives a hash with :queue_size, :waited, and :request_id keys.
92
+ # @option options [Logger, nil] :logger Logger instance for structured rate limit logging.
93
+ # Set to nil (default) to disable logging.
94
+ # @option options [Symbol] :log_level (:info) Log level for rate limit events.
95
+ #
96
+ # @example With custom threshold, callbacks, and logging
97
+ # tracker = SecApi::RateLimitTracker.new
98
+ # middleware = SecApi::Middleware::RateLimiter.new(app,
99
+ # state_store: tracker,
100
+ # threshold: 0.2, # Throttle at 20% remaining
101
+ # on_throttle: ->(info) { puts "Throttling for #{info[:delay]}s" },
102
+ # on_queue: ->(info) { puts "Request queued, #{info[:queue_size]} waiting" },
103
+ # on_dequeue: ->(info) { puts "Request dequeued after #{info[:waited]}s" },
104
+ # on_excessive_wait: ->(info) { puts "Warning: wait time #{info[:wait_time]}s" },
105
+ # logger: Rails.logger,
106
+ # log_level: :info
107
+ # )
108
+ #
109
+ def initialize(app, options = {})
110
+ super(app)
111
+ @state_store = options[:state_store]
112
+ @threshold = options.fetch(:threshold, DEFAULT_THRESHOLD)
113
+ @on_throttle = options[:on_throttle]
114
+ @on_queue = options[:on_queue]
115
+ @on_dequeue = options[:on_dequeue]
116
+ @on_excessive_wait = options[:on_excessive_wait]
117
+ @queue_wait_warning_threshold = options.fetch(
118
+ :queue_wait_warning_threshold,
119
+ DEFAULT_QUEUE_WAIT_WARNING_THRESHOLD
120
+ )
121
+ @logger = options[:logger]
122
+ @log_level = options.fetch(:log_level, :info)
123
+ # Thread-safety design: Mutex + ConditionVariable pattern for efficient blocking.
124
+ # Why not just sleep? Sleep wastes CPU cycles polling. ConditionVariable allows
125
+ # threads to truly wait (zero CPU) until signaled, crucial for high-concurrency
126
+ # workloads (Sidekiq, Puma) where many threads may be rate-limited simultaneously.
127
+ # Why not atomic counters? We need to coordinate multiple operations (check state,
128
+ # increment queue, wait) atomically, which requires a mutex.
129
+ @mutex = Mutex.new
130
+ @condition = ConditionVariable.new
131
+ end
132
+
133
+ # Returns the current count of queued (waiting) requests.
134
+ #
135
+ # Delegates to the state store if available, otherwise returns 0.
136
+ #
137
+ # @return [Integer] Number of requests currently waiting for rate limit reset
138
+ def queued_count
139
+ @state_store&.queued_count || 0
140
+ end
141
+
142
+ # Processes the request with rate limit queueing, throttling, and header extraction.
143
+ #
144
+ # Before sending the request:
145
+ # 1. Generates a unique request_id (UUID) for tracing across callbacks
146
+ # 2. If rate limit is exhausted (remaining = 0), queues the request until reset
147
+ # 3. Otherwise, checks if below threshold and throttles if needed
148
+ #
149
+ # After the response, extracts rate limit headers to update state.
150
+ #
151
+ # @param env [Faraday::Env] The request/response environment
152
+ # @return [Faraday::Response] The response
153
+ #
154
+ def call(env)
155
+ # Generate unique request_id for tracing across all callbacks
156
+ request_id = env[:request_id] ||= SecureRandom.uuid
157
+
158
+ wait_if_exhausted(request_id)
159
+ throttle_if_needed(request_id)
160
+
161
+ @app.call(env).on_complete do |response_env|
162
+ extract_rate_limit_headers(response_env)
163
+ signal_waiting_threads
164
+ end
165
+ end
166
+
167
+ private
168
+
169
+ # Blocks the request if the rate limit is exhausted (remaining = 0).
170
+ #
171
+ # When exhausted, increments the queued count, invokes the on_queue callback,
172
+ # and waits using ConditionVariable until signaled or timeout. Uses a while
173
+ # loop to re-check state after wakeup in case another thread took the slot.
174
+ #
175
+ # Thread-safety: Uses mutex and ConditionVariable for efficient blocking.
176
+ # Sequential release: After reset, threads are signaled one at a time.
177
+ # Exception safety: Uses ensure block to guarantee queued_count is decremented.
178
+ #
179
+ # @param request_id [String] Unique identifier for tracing this request
180
+ # @return [void]
181
+ #
182
+ def wait_if_exhausted(request_id)
183
+ return unless @state_store
184
+
185
+ @mutex.synchronize do
186
+ state = @state_store.current_state
187
+ return unless state
188
+ return unless exhausted?(state)
189
+
190
+ # Calculate wait time, using default if reset_at is unknown
191
+ wait_time = calculate_delay_with_default(state)
192
+ return unless wait_time.positive?
193
+
194
+ queued_at = Time.now
195
+ @state_store.increment_queued
196
+ begin
197
+ invoke_queue_callback(state, wait_time, request_id)
198
+ warn_if_excessive_wait(wait_time, state.reset_at, request_id)
199
+
200
+ # Wait using ConditionVariable with timeout.
201
+ # Re-check state after wakeup in while loop - another thread may have taken
202
+ # the available capacity before this thread resumes (spurious wakeup handling).
203
+ # This is the standard pattern for condition variable usage (Mesa semantics).
204
+ while should_wait?
205
+ remaining_wait = calculate_remaining_wait_with_default
206
+ break unless remaining_wait.positive?
207
+ @condition.wait(@mutex, remaining_wait)
208
+ end
209
+ ensure
210
+ @state_store.decrement_queued
211
+ invoke_dequeue_callback(Time.now - queued_at, request_id)
212
+ end
213
+ end
214
+ end
215
+
216
+ # Determines if the current thread should continue waiting.
217
+ #
218
+ # Checks if rate limit is still exhausted. Does not check reset_passed
219
+ # because we may be using default wait time when reset_at is nil.
220
+ # Called after ConditionVariable wakeup to re-verify state.
221
+ #
222
+ # @return [Boolean] true if thread should continue waiting
223
+ #
224
+ def should_wait?
225
+ state = @state_store.current_state
226
+ return false unless state
227
+ exhausted?(state)
228
+ end
229
+
230
+ # Calculates remaining wait time until reset, with default fallback.
231
+ #
232
+ # When reset_at is nil (API didn't send X-RateLimit-Reset header),
233
+ # returns 0 to allow the wait loop to exit and retry.
234
+ #
235
+ # @return [Float] Seconds remaining until reset, or 0 if unknown/passed
236
+ #
237
+ def calculate_remaining_wait_with_default
238
+ state = @state_store.current_state
239
+ return 0 unless state&.reset_at
240
+ delay = state.reset_at - Time.now
241
+ delay.positive? ? delay : 0
242
+ end
243
+
244
+ # Checks if the rate limit is exhausted (remaining = 0).
245
+ #
246
+ # @param state [RateLimitState] The current rate limit state
247
+ # @return [Boolean] true if remaining is exactly 0
248
+ #
249
+ def exhausted?(state)
250
+ state.remaining&.zero?
251
+ end
252
+
253
+ # Invokes the on_queue callback if configured and logs the event.
254
+ #
255
+ # @param state [RateLimitState] The current rate limit state
256
+ # @param wait_time [Float] Seconds the request will wait
257
+ # @param request_id [String] Unique identifier for tracing this request
258
+ # @return [void]
259
+ #
260
+ def invoke_queue_callback(state, wait_time, request_id)
261
+ log_queue(state, wait_time, request_id)
262
+
263
+ return unless @on_queue
264
+
265
+ @on_queue.call(
266
+ queue_size: queued_count,
267
+ wait_time: wait_time,
268
+ reset_at: state.reset_at,
269
+ request_id: request_id
270
+ )
271
+ end
272
+
273
+ # Logs a queue event with structured data.
274
+ #
275
+ # @param state [RateLimitState] The current rate limit state
276
+ # @param wait_time [Float] Seconds the request will wait
277
+ # @param request_id [String] Unique identifier for tracing this request
278
+ # @return [void]
279
+ #
280
+ def log_queue(state, wait_time, request_id)
281
+ return unless @logger
282
+
283
+ log_event(
284
+ event: "secapi.rate_limit.queue",
285
+ request_id: request_id,
286
+ queue_size: queued_count,
287
+ wait_time: wait_time.round(2),
288
+ reset_at: state.reset_at&.iso8601
289
+ )
290
+ end
291
+
292
+ # Invokes the on_dequeue callback if configured.
293
+ #
294
+ # Called when a request exits the queue after waiting.
295
+ #
296
+ # @param waited [Float] Actual seconds the request waited
297
+ # @param request_id [String] Unique identifier for tracing this request
298
+ # @return [void]
299
+ #
300
+ def invoke_dequeue_callback(waited, request_id)
301
+ return unless @on_dequeue
302
+
303
+ @on_dequeue.call(
304
+ queue_size: queued_count,
305
+ waited: waited,
306
+ request_id: request_id
307
+ )
308
+ end
309
+
310
+ # Calculates delay until reset, using default when reset_at is unknown.
311
+ #
312
+ # When the API returns remaining=0 but no X-RateLimit-Reset header,
313
+ # uses DEFAULT_QUEUE_WAIT_SECONDS (60s) as a fallback.
314
+ #
315
+ # @param state [RateLimitState] The current rate limit state
316
+ # @return [Float] Seconds to wait (always positive when exhausted)
317
+ #
318
+ def calculate_delay_with_default(state)
319
+ if state.reset_at.nil?
320
+ # No reset time known - use default wait
321
+ DEFAULT_QUEUE_WAIT_SECONDS
322
+ else
323
+ delay = state.reset_at - Time.now
324
+ delay.positive? ? delay : 0
325
+ end
326
+ end
327
+
328
+ # Warns when wait time exceeds the configured threshold.
329
+ #
330
+ # Invokes the on_excessive_wait callback when the wait time is greater
331
+ # than queue_wait_warning_threshold (default 300 seconds / 5 minutes).
332
+ # The request continues waiting after the warning.
333
+ #
334
+ # @param wait_time [Float] Seconds the request will wait
335
+ # @param reset_at [Time] When the rate limit resets
336
+ # @param request_id [String] Unique identifier for tracing this request
337
+ # @return [void]
338
+ #
339
+ def warn_if_excessive_wait(wait_time, reset_at, request_id)
340
+ return unless wait_time > @queue_wait_warning_threshold
341
+ return unless @on_excessive_wait
342
+
343
+ @on_excessive_wait.call(
344
+ wait_time: wait_time,
345
+ threshold: @queue_wait_warning_threshold,
346
+ reset_at: reset_at,
347
+ request_id: request_id
348
+ )
349
+ end
350
+
351
+ # Signals waiting threads that the rate limit may have reset.
352
+ #
353
+ # Called after each response to wake up one waiting thread.
354
+ # Uses ConditionVariable for efficient thread coordination.
355
+ #
356
+ # @return [void]
357
+ #
358
+ def signal_waiting_threads
359
+ @mutex.synchronize do
360
+ @condition.signal
361
+ end
362
+ end
363
+
364
+ # Checks rate limit state and sleeps if below threshold.
365
+ #
366
+ # Only throttles when:
367
+ # - State store exists
368
+ # - Current state exists (at least one prior response received)
369
+ # - Rate limit is NOT exhausted (remaining > 0) - exhausted case handled by wait_if_exhausted
370
+ # - Reset time has not passed (state is still valid)
371
+ # - Percentage remaining is below threshold
372
+ # - Delay is positive (reset is in the future)
373
+ #
374
+ # @param request_id [String] Unique identifier for tracing this request
375
+ # @return [void]
376
+ #
377
+ def throttle_if_needed(request_id)
378
+ return unless @state_store
379
+
380
+ state = @state_store.current_state
381
+ return unless state
382
+ return if exhausted?(state) # Exhausted case handled separately by queueing
383
+ return if reset_passed?(state)
384
+ return unless should_throttle?(state)
385
+
386
+ delay = calculate_delay(state)
387
+ return unless delay.positive?
388
+
389
+ invoke_throttle_callback(state, delay, request_id)
390
+ sleep(delay)
391
+ end
392
+
393
+ # Invokes the on_throttle callback if configured and logs the event.
394
+ #
395
+ # @param state [RateLimitState] The current rate limit state
396
+ # @param delay [Float] Seconds the request will be delayed
397
+ # @param request_id [String] Unique identifier for tracing this request
398
+ # @return [void]
399
+ #
400
+ def invoke_throttle_callback(state, delay, request_id)
401
+ log_throttle(state, delay, request_id)
402
+
403
+ return unless @on_throttle
404
+
405
+ @on_throttle.call(
406
+ remaining: state.remaining,
407
+ limit: state.limit,
408
+ delay: delay,
409
+ reset_at: state.reset_at,
410
+ request_id: request_id
411
+ )
412
+ end
413
+
414
+ # Logs a throttle event with structured data.
415
+ #
416
+ # @param state [RateLimitState] The current rate limit state
417
+ # @param delay [Float] Seconds the request will be delayed
418
+ # @param request_id [String] Unique identifier for tracing this request
419
+ # @return [void]
420
+ #
421
+ def log_throttle(state, delay, request_id)
422
+ return unless @logger
423
+
424
+ log_event(
425
+ event: "secapi.rate_limit.throttle",
426
+ request_id: request_id,
427
+ remaining: state.remaining,
428
+ limit: state.limit,
429
+ delay: delay.round(2),
430
+ reset_at: state.reset_at&.iso8601
431
+ )
432
+ end
433
+
434
+ # Determines if throttling should be applied based on remaining percentage.
435
+ #
436
+ # @param state [RateLimitState] The current rate limit state
437
+ # @return [Boolean] true if percentage remaining is below threshold
438
+ #
439
+ def should_throttle?(state)
440
+ pct = state.percentage_remaining
441
+ return false if pct.nil?
442
+
443
+ # percentage_remaining returns 0.0-100.0, threshold is 0.0-1.0
444
+ # Convert threshold to percentage: 0.1 * 100 = 10%
445
+ pct < (@threshold * 100)
446
+ end
447
+
448
+ # Checks if the rate limit window has already reset.
449
+ #
450
+ # @param state [RateLimitState] The current rate limit state
451
+ # @return [Boolean] true if reset_at is nil or in the past
452
+ #
453
+ def reset_passed?(state)
454
+ return true if state.reset_at.nil?
455
+ Time.now >= state.reset_at
456
+ end
457
+
458
+ # Calculates the delay in seconds until the rate limit resets.
459
+ #
460
+ # @param state [RateLimitState] The current rate limit state
461
+ # @return [Float] Seconds to wait (0 or positive)
462
+ #
463
+ def calculate_delay(state)
464
+ return 0 if state.reset_at.nil?
465
+
466
+ delay = state.reset_at - Time.now
467
+ delay.positive? ? delay : 0
468
+ end
469
+
470
+ # Extracts rate limit headers and updates the state store.
471
+ #
472
+ # Only updates state if at least one rate limit header is present.
473
+ # Missing headers result in nil values for those fields.
474
+ #
475
+ # @param env [Faraday::Env] The response environment
476
+ #
477
+ def extract_rate_limit_headers(env)
478
+ return unless @state_store
479
+
480
+ headers = env[:response_headers]
481
+ return unless headers
482
+
483
+ limit = parse_integer(headers[LIMIT_HEADER])
484
+ remaining = parse_integer(headers[REMAINING_HEADER])
485
+ reset_at = parse_timestamp(headers[RESET_HEADER])
486
+
487
+ # Only update if we got at least one header
488
+ return if limit.nil? && remaining.nil? && reset_at.nil?
489
+
490
+ @state_store.update(
491
+ limit: limit,
492
+ remaining: remaining,
493
+ reset_at: reset_at
494
+ )
495
+ end
496
+
497
+ # Parses a header value as an integer.
498
+ #
499
+ # @param value [String, nil] The header value
500
+ # @return [Integer, nil] The parsed integer or nil
501
+ #
502
+ def parse_integer(value)
503
+ return nil if value.nil? || value.to_s.empty?
504
+ Integer(value)
505
+ rescue ArgumentError, TypeError
506
+ nil
507
+ end
508
+
509
+ # Parses a Unix timestamp header value as a Time object.
510
+ #
511
+ # @param value [String, nil] The Unix timestamp header value
512
+ # @return [Time, nil] The parsed Time object or nil
513
+ #
514
+ def parse_timestamp(value)
515
+ return nil if value.nil? || value.to_s.empty?
516
+ Time.at(Integer(value))
517
+ rescue ArgumentError, TypeError
518
+ nil
519
+ end
520
+
521
+ # Logs a structured event using the configured logger and log level.
522
+ #
523
+ # Outputs events as JSON for compatibility with monitoring tools
524
+ # like ELK, Splunk, and Datadog.
525
+ #
526
+ # @param data [Hash] Event data to log
527
+ # @return [void]
528
+ #
529
+ def log_event(data)
530
+ return unless @logger
531
+
532
+ begin
533
+ @logger.send(@log_level) { data.to_json }
534
+ rescue
535
+ # Don't let logging errors break the request
536
+ # Silently ignore - logging is best-effort
537
+ end
538
+ end
539
+ end
540
+ end
541
+ end
@@ -0,0 +1,34 @@
1
+ require "dry/struct"
2
+ require "sec_api/objects/document_format_file"
3
+
4
+ module SecApi
5
+ module Objects
6
+ # Represents a data file (XBRL, XML, etc.) within an SEC filing.
7
+ #
8
+ # DataFile objects inherit from {DocumentFormatFile} and represent
9
+ # structured data files such as XBRL instance documents, XML schemas,
10
+ # and other machine-readable attachments. All instances are immutable.
11
+ #
12
+ # DataFile inherits all attributes from {DocumentFormatFile}:
13
+ # - `sequence` - File sequence number
14
+ # - `description` - File description (optional)
15
+ # - `type` - MIME type or file type
16
+ # - `url` - Direct URL to download the file
17
+ # - `size` - File size in bytes
18
+ #
19
+ # @example Accessing data files from a filing
20
+ # filing = client.query.ticker("AAPL").form_type("10-K").search.first
21
+ # filing.data_files.each do |file|
22
+ # puts "#{file.description}: #{file.url}"
23
+ # end
24
+ #
25
+ # @example Finding XBRL instance documents
26
+ # xbrl_files = filing.data_files.select { |f| f.type.include?("xml") }
27
+ #
28
+ # @see SecApi::Objects::Filing#data_files
29
+ # @see SecApi::Objects::DocumentFormatFile Parent class with all attributes
30
+ #
31
+ class DataFile < DocumentFormatFile
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,45 @@
1
+ require "dry/struct"
2
+
3
+ module SecApi
4
+ module Objects
5
+ # Represents a document file within an SEC filing.
6
+ #
7
+ # DocumentFormatFile objects contain metadata about individual documents
8
+ # within a filing, such as the main filing document, exhibits, and
9
+ # attachments. All instances are immutable (frozen).
10
+ #
11
+ # @example Accessing document files from a filing
12
+ # filing = client.query.ticker("AAPL").form_type("10-K").search.first
13
+ # filing.documents.each do |doc|
14
+ # puts "#{doc.sequence}: #{doc.description} (#{doc.type})"
15
+ # puts "URL: #{doc.url}, Size: #{doc.size} bytes"
16
+ # end
17
+ #
18
+ # @see SecApi::Objects::Filing#documents
19
+ # @see SecApi::Objects::DataFile
20
+ #
21
+ class DocumentFormatFile < Dry::Struct
22
+ transform_keys { |key| key.to_s.underscore }
23
+ transform_keys(&:to_sym)
24
+
25
+ attribute :sequence, Types::String
26
+ attribute? :description, Types::String
27
+ attribute :type, Types::String
28
+ attribute :url, Types::String
29
+ attribute :size, Types::Coercible::Integer
30
+
31
+ # Creates a DocumentFormatFile from API response data.
32
+ #
33
+ # Normalizes camelCase keys from the API to snake_case format.
34
+ #
35
+ # @param data [Hash] API response hash with document data
36
+ # @return [DocumentFormatFile] Immutable document file object
37
+ #
38
+ def self.from_api(data)
39
+ data[:url] = data.delete(:documentUrl) if data.key?(:documentUrl)
40
+
41
+ new(data)
42
+ end
43
+ end
44
+ end
45
+ end