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,199 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SecApi
4
+ # Provides structured JSON logging for SEC API operations.
5
+ #
6
+ # This module can be used directly or via the `default_logging` config option.
7
+ # All log events follow the `secapi.*` naming convention for easy filtering
8
+ # in log aggregation tools like ELK, Datadog, Splunk, or CloudWatch.
9
+ #
10
+ # @example Manual usage with Rails logger
11
+ # SecApi::StructuredLogger.log_request(Rails.logger, :info,
12
+ # request_id: "abc-123",
13
+ # method: :get,
14
+ # url: "https://api.sec-api.io/query"
15
+ # )
16
+ #
17
+ # @example Using with default_logging
18
+ # config = SecApi::Config.new(
19
+ # api_key: "...",
20
+ # logger: Rails.logger,
21
+ # default_logging: true
22
+ # )
23
+ # # All requests/responses now logged automatically
24
+ #
25
+ # @example ELK Stack integration
26
+ # # Configure Logstash to parse JSON logs:
27
+ # # filter {
28
+ # # json { source => "message" }
29
+ # # }
30
+ # # Query in Kibana: event:"secapi.request.complete" AND status:>=400
31
+ #
32
+ # @example Datadog Logs integration
33
+ # # Logs are automatically parsed as JSON by Datadog
34
+ # # Create facets on: event, request_id, status, duration_ms
35
+ # # Alert query: event:secapi.request.error count:>10
36
+ #
37
+ # @example Splunk integration
38
+ # # Search: sourcetype=ruby_json event="secapi.request.*"
39
+ # # | stats avg(duration_ms) by method
40
+ #
41
+ # @example CloudWatch Logs integration
42
+ # # Filter pattern: { $.event = "secapi.request.error" }
43
+ # # Metric filter: Count errors by request_id
44
+ #
45
+ module StructuredLogger
46
+ extend self
47
+
48
+ # Logs a request start event.
49
+ #
50
+ # @param logger [Logger] Logger instance (Ruby Logger or compatible interface)
51
+ # @param level [Symbol] Log level (:debug, :info, :warn, :error)
52
+ # @param request_id [String] Request correlation ID (UUID)
53
+ # @param method [Symbol] HTTP method (:get, :post, etc.)
54
+ # @param url [String] Request URL
55
+ # @return [void]
56
+ #
57
+ # @example Basic request logging
58
+ # SecApi::StructuredLogger.log_request(logger, :info,
59
+ # request_id: "550e8400-e29b-41d4-a716-446655440000",
60
+ # method: :get,
61
+ # url: "https://api.sec-api.io/query"
62
+ # )
63
+ # # Output: {"event":"secapi.request.start","request_id":"550e8400-...","method":"GET","url":"https://...","timestamp":"2024-01-15T10:30:00.123Z"}
64
+ #
65
+ def log_request(logger, level, request_id:, method:, url:)
66
+ log_event(logger, level, {
67
+ event: "secapi.request.start",
68
+ request_id: request_id,
69
+ method: method.to_s.upcase,
70
+ url: url,
71
+ timestamp: timestamp
72
+ })
73
+ end
74
+
75
+ # Logs a request completion event.
76
+ #
77
+ # @param logger [Logger] Logger instance
78
+ # @param level [Symbol] Log level (:debug, :info, :warn, :error)
79
+ # @param request_id [String] Request correlation ID (matches on_request)
80
+ # @param status [Integer] HTTP status code (200, 429, 500, etc.)
81
+ # @param duration_ms [Integer, Float] Request duration in milliseconds
82
+ # @param url [String] Request URL
83
+ # @param method [Symbol] HTTP method
84
+ # @return [void]
85
+ #
86
+ # @example Response logging with duration
87
+ # SecApi::StructuredLogger.log_response(logger, :info,
88
+ # request_id: "550e8400-e29b-41d4-a716-446655440000",
89
+ # status: 200,
90
+ # duration_ms: 150,
91
+ # url: "https://api.sec-api.io/query",
92
+ # method: :get
93
+ # )
94
+ # # Output: {"event":"secapi.request.complete","request_id":"550e8400-...","status":200,"duration_ms":150,"success":true,...}
95
+ #
96
+ def log_response(logger, level, request_id:, status:, duration_ms:, url:, method:)
97
+ log_event(logger, level, {
98
+ event: "secapi.request.complete",
99
+ request_id: request_id,
100
+ status: status,
101
+ duration_ms: duration_ms,
102
+ method: method.to_s.upcase,
103
+ url: url,
104
+ success: status < 400,
105
+ timestamp: timestamp
106
+ })
107
+ end
108
+
109
+ # Logs a retry attempt event.
110
+ #
111
+ # @param logger [Logger] Logger instance
112
+ # @param level [Symbol] Log level (typically :warn)
113
+ # @param request_id [String] Request correlation ID
114
+ # @param attempt [Integer] Retry attempt number (1-indexed)
115
+ # @param max_attempts [Integer] Maximum retry attempts configured
116
+ # @param error_class [String] Exception class name that triggered retry
117
+ # @param error_message [String] Exception message
118
+ # @param will_retry_in [Float] Seconds until next retry attempt
119
+ # @return [void]
120
+ #
121
+ # @example Retry logging
122
+ # SecApi::StructuredLogger.log_retry(logger, :warn,
123
+ # request_id: "550e8400-e29b-41d4-a716-446655440000",
124
+ # attempt: 2,
125
+ # max_attempts: 5,
126
+ # error_class: "SecApi::ServerError",
127
+ # error_message: "Internal Server Error",
128
+ # will_retry_in: 4.0
129
+ # )
130
+ # # Output: {"event":"secapi.request.retry","request_id":"550e8400-...","attempt":2,"max_attempts":5,...}
131
+ #
132
+ def log_retry(logger, level, request_id:, attempt:, max_attempts:, error_class:, error_message:, will_retry_in:)
133
+ log_event(logger, level, {
134
+ event: "secapi.request.retry",
135
+ request_id: request_id,
136
+ attempt: attempt,
137
+ max_attempts: max_attempts,
138
+ error_class: error_class,
139
+ error_message: error_message,
140
+ will_retry_in: will_retry_in,
141
+ timestamp: timestamp
142
+ })
143
+ end
144
+
145
+ # Logs a request error event (final failure after all retries).
146
+ #
147
+ # @param logger [Logger] Logger instance
148
+ # @param level [Symbol] Log level (typically :error)
149
+ # @param request_id [String] Request correlation ID
150
+ # @param error [Exception] The exception that caused failure
151
+ # @param url [String] Request URL
152
+ # @param method [Symbol] HTTP method
153
+ # @return [void]
154
+ #
155
+ # @example Error logging
156
+ # SecApi::StructuredLogger.log_error(logger, :error,
157
+ # request_id: "550e8400-e29b-41d4-a716-446655440000",
158
+ # error: SecApi::AuthenticationError.new("Invalid API key"),
159
+ # url: "https://api.sec-api.io/query",
160
+ # method: :get
161
+ # )
162
+ # # Output: {"event":"secapi.request.error","request_id":"550e8400-...","error_class":"SecApi::AuthenticationError",...}
163
+ #
164
+ def log_error(logger, level, request_id:, error:, url:, method:)
165
+ log_event(logger, level, {
166
+ event: "secapi.request.error",
167
+ request_id: request_id,
168
+ error_class: error.class.name,
169
+ error_message: error.message,
170
+ method: method.to_s.upcase,
171
+ url: url,
172
+ timestamp: timestamp
173
+ })
174
+ end
175
+
176
+ private
177
+
178
+ # Writes a structured log event to the logger.
179
+ #
180
+ # @param logger [Logger] Logger instance
181
+ # @param level [Symbol] Log level
182
+ # @param data [Hash] Event data to serialize as JSON
183
+ # @return [void]
184
+ # @api private
185
+ def log_event(logger, level, data)
186
+ logger.send(level) { data.to_json }
187
+ rescue
188
+ # Don't let logging errors break the request flow
189
+ end
190
+
191
+ # Returns current UTC timestamp in ISO8601 format with milliseconds.
192
+ #
193
+ # @return [String] ISO8601 timestamp (e.g., "2024-01-15T10:30:00.123Z")
194
+ # @api private
195
+ def timestamp
196
+ Time.now.utc.iso8601(3)
197
+ end
198
+ end
199
+ end
@@ -0,0 +1,32 @@
1
+ require "dry-struct"
2
+ require "dry/types"
3
+
4
+ module SecApi
5
+ # Type definitions for Dry::Struct value objects.
6
+ #
7
+ # Why Dry::Types? (Architecture ADR-8: Value Object Strategy)
8
+ # - Type safety: Catches type mismatches at construction time, not runtime
9
+ # - Automatic coercion: API returns strings for numbers, Dry::Types handles conversion
10
+ # - Immutability: Combined with Dry::Struct, ensures thread-safe response objects
11
+ # - Documentation: Type declarations serve as inline documentation
12
+ #
13
+ # Why not plain Ruby classes? We handle financial data where type errors
14
+ # could lead to incorrect calculations. Explicit types prevent silent failures.
15
+ #
16
+ # This module includes Dry::Types and provides type definitions used across
17
+ # all value objects in the gem. The types ensure type safety and automatic
18
+ # coercion for API response data.
19
+ #
20
+ # @example Using types in a Dry::Struct
21
+ # class MyStruct < Dry::Struct
22
+ # attribute :name, SecApi::Types::String
23
+ # attribute :count, SecApi::Types::Coercible::Integer
24
+ # attribute :optional_field, SecApi::Types::String.optional
25
+ # end
26
+ #
27
+ # @see https://dry-rb.org/gems/dry-types/ Dry::Types documentation
28
+ #
29
+ module Types
30
+ include Dry.Types()
31
+ end
32
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SecApi
4
+ # Returns the gem version as a Gem::Version object.
5
+ #
6
+ # @return [Gem::Version] the version of the gem
7
+ #
8
+ # @example
9
+ # SecApi.gem_version # => #<Gem::Version "1.0.0">
10
+ # SecApi.gem_version >= Gem::Version.new("1.0.0") # => true
11
+ #
12
+ def self.gem_version
13
+ Gem::Version.new(VERSION::STRING)
14
+ end
15
+
16
+ # Version information for the sec_api gem.
17
+ #
18
+ # @example Access version string
19
+ # SecApi::VERSION::STRING # => "1.0.0"
20
+ #
21
+ # @example Access version components
22
+ # SecApi::VERSION::MAJOR # => 1
23
+ # SecApi::VERSION::MINOR # => 0
24
+ # SecApi::VERSION::PATCH # => 0
25
+ #
26
+ module VERSION
27
+ # @return [Integer] Major version number (breaking changes)
28
+ MAJOR = 1
29
+
30
+ # @return [Integer] Minor version number (new features, backwards compatible)
31
+ MINOR = 0
32
+
33
+ # @return [Integer] Patch version number (bug fixes)
34
+ PATCH = 0
35
+
36
+ # @return [String, nil] Pre-release identifier (e.g., "alpha", "beta", "rc1")
37
+ PRE = nil
38
+
39
+ # @return [String] Complete version string (e.g., "0.1.0" or "1.0.0-beta")
40
+ STRING = [MAJOR, MINOR, PATCH, PRE].compact.join(".")
41
+ end
42
+ end
@@ -0,0 +1,220 @@
1
+ module SecApi
2
+ # XBRL extraction proxy for converting SEC EDGAR XBRL filings to structured JSON.
3
+ #
4
+ # Extraction Workflow:
5
+ # 1. Client calls to_json() with URL, accession_no, or Filing object
6
+ # 2. Input is validated locally (URL format, accession format)
7
+ # 3. Request sent to sec-api.io XBRL-to-JSON endpoint
8
+ # 4. Response validated heuristically (has statements? valid structure?)
9
+ # 5. Data wrapped in immutable XbrlData Dry::Struct object
10
+ #
11
+ # Validation Philosophy (Architecture ADR-5):
12
+ # We use HEURISTIC validation (check structure, required sections) rather than
13
+ # full XBRL schema validation. Rationale:
14
+ # - sec-api.io handles taxonomy parsing - we trust their output structure
15
+ # - Full schema validation would require bundling 100MB+ taxonomy files
16
+ # - We validate what matters: required sections present, data types coercible
17
+ # - Catch malformed responses early with actionable error messages
18
+ #
19
+ # Provides access to the sec-api.io XBRL-to-JSON API, which extracts financial
20
+ # statement data from XBRL filings and returns structured, typed data objects.
21
+ # Supports both US GAAP and IFRS taxonomies.
22
+ #
23
+ # @example Extract XBRL data from a filing URL
24
+ # client = SecApi::Client.new(api_key: "your_api_key")
25
+ # xbrl = client.xbrl.to_json("https://www.sec.gov/Archives/edgar/data/320193/000032019323000106/aapl-20230930.htm")
26
+ #
27
+ # # Access income statement data
28
+ # revenue = xbrl.statements_of_income["RevenueFromContractWithCustomerExcludingAssessedTax"]
29
+ # revenue.first.to_numeric # => 394328000000.0
30
+ #
31
+ # @example Extract using accession number
32
+ # xbrl = client.xbrl.to_json(accession_no: "0000320193-23-000106")
33
+ #
34
+ # @example Extract from Filing object
35
+ # filing = client.query.ticker("AAPL").form_type("10-K").search.first
36
+ # xbrl = client.xbrl.to_json(filing)
37
+ #
38
+ # @example Discover available XBRL elements
39
+ # xbrl.element_names # => ["Assets", "CashAndCashEquivalents", "Revenue", ...]
40
+ # xbrl.taxonomy_hint # => :us_gaap or :ifrs
41
+ #
42
+ # @note The gem returns element names exactly as provided by sec-api.io without
43
+ # normalizing between US GAAP and IFRS taxonomies. Use {XbrlData#element_names}
44
+ # to discover available elements in any filing.
45
+ #
46
+ # @see SecApi::XbrlData The immutable value object returned by {#to_json}
47
+ # @see SecApi::Fact Individual financial facts with periods and values
48
+ #
49
+ class Xbrl
50
+ # Pattern for validating SEC EDGAR URLs.
51
+ # @return [Regexp] regex pattern matching sec.gov domains
52
+ SEC_URL_PATTERN = %r{\Ahttps?://(?:www\.)?sec\.gov/}i
53
+
54
+ # Pattern for dashed accession number format (10-2-6 digits).
55
+ # @return [Regexp] regex pattern for format like "0000320193-23-000106"
56
+ # @example
57
+ # "0000320193-23-000106".match?(ACCESSION_DASHED_PATTERN) # => true
58
+ ACCESSION_DASHED_PATTERN = /\A\d{10}-\d{2}-\d{6}\z/
59
+
60
+ # Pattern for undashed accession number format (18 consecutive digits).
61
+ # @return [Regexp] regex pattern for format like "0000320193230001060"
62
+ # @example
63
+ # "0000320193230001060".match?(ACCESSION_UNDASHED_PATTERN) # => true
64
+ ACCESSION_UNDASHED_PATTERN = /\A\d{18}\z/
65
+
66
+ # Creates a new XBRL extraction proxy instance.
67
+ #
68
+ # XBRL instances are obtained via {Client#xbrl} and cached
69
+ # for reuse. Direct instantiation is not recommended.
70
+ #
71
+ # @param client [SecApi::Client] The parent client for API access
72
+ # @return [SecApi::Xbrl] A new XBRL proxy instance
73
+ # @api private
74
+ def initialize(client)
75
+ @_client = client
76
+ end
77
+
78
+ # Extracts XBRL financial data from an SEC filing and returns structured, immutable XbrlData object.
79
+ #
80
+ # @overload to_json(url)
81
+ # Extract XBRL data from a SEC filing URL.
82
+ # @param url [String] SEC EDGAR URL pointing to XBRL document
83
+ # @return [SecApi::XbrlData] Immutable XBRL data object
84
+ #
85
+ # @overload to_json(accession_no:)
86
+ # Extract XBRL data using an accession number.
87
+ # @param accession_no [String] SEC accession number (e.g., "0000320193-23-000106")
88
+ # @return [SecApi::XbrlData] Immutable XBRL data object
89
+ #
90
+ # @overload to_json(filing, options = {})
91
+ # Extract XBRL data from a Filing object (backward compatible).
92
+ # @param filing [Object] Filing object with xbrl_url and accession_number attributes
93
+ # @param options [Hash] Optional parameters to pass to the XBRL extraction API
94
+ # @return [SecApi::XbrlData] Immutable XBRL data object
95
+ #
96
+ # @raise [SecApi::ValidationError] When input is invalid or XBRL data validation fails
97
+ # @raise [SecApi::NotFoundError] When filing URL is invalid or has no XBRL data
98
+ # @raise [SecApi::AuthenticationError] When API key is invalid (401/403)
99
+ # @raise [SecApi::RateLimitError] When rate limit is exceeded (429) - automatically retried
100
+ # @raise [SecApi::ServerError] When sec-api.io returns 5xx errors - automatically retried
101
+ # @raise [SecApi::NetworkError] When connection fails or times out - automatically retried
102
+ #
103
+ # @example Extract XBRL data using URL string
104
+ # client = SecApi::Client.new(api_key: "your_api_key")
105
+ # xbrl_data = client.xbrl.to_json("https://www.sec.gov/Archives/edgar/data/320193/000032019323000106/aapl-20230930.htm")
106
+ #
107
+ # @example Extract XBRL data using accession number
108
+ # xbrl_data = client.xbrl.to_json(accession_no: "0000320193-23-000106")
109
+ #
110
+ # @example Extract XBRL data from Filing object (backward compatible)
111
+ # filing = client.query.ticker("AAPL").form_type("10-K").search.first
112
+ # xbrl_data = client.xbrl.to_json(filing)
113
+ #
114
+ def to_json(input = nil, options = {}, **kwargs)
115
+ request_params = build_request_params(input, kwargs)
116
+ request_params.merge!(options) unless options.empty?
117
+
118
+ response = @_client.connection.get("/xbrl-to-json", request_params)
119
+
120
+ # Return XbrlData object instead of raw hash.
121
+ # XbrlData.from_api performs heuristic validation:
122
+ # - Checks at least one statement section exists
123
+ # - Dry::Struct validates type coercion (string/numeric values)
124
+ # - Fact objects validate period and value structure
125
+ # Error handling delegated to middleware (Story 1.2)
126
+ begin
127
+ XbrlData.from_api(response.body)
128
+ rescue Dry::Struct::Error, NoMethodError, TypeError => e
129
+ # Heuristic validation failed - data structure doesn't match expected format.
130
+ # This catches issues like missing required fields, wrong types, or malformed
131
+ # fact arrays. Provide actionable context for debugging.
132
+ accession = request_params[:"accession-no"] || "unknown"
133
+ raise ValidationError,
134
+ "XBRL data validation failed for filing #{accession}: #{e.message}. " \
135
+ "This may indicate incomplete or malformed filing data from sec-api.io. " \
136
+ "Check the filing URL and verify the XBRL document is available."
137
+ end
138
+ end
139
+
140
+ private
141
+
142
+ def build_request_params(input, kwargs)
143
+ # Handle keyword-only call: to_json(accession_no: "...")
144
+ if input.nil? && kwargs[:accession_no]
145
+ return build_params_from_accession_no(kwargs[:accession_no])
146
+ end
147
+
148
+ # Handle URL string input: to_json("https://...")
149
+ if input.is_a?(String)
150
+ return build_params_from_url(input)
151
+ end
152
+
153
+ # Handle Filing object input (backward compatibility): to_json(filing)
154
+ if input.respond_to?(:xbrl_url) && input.respond_to?(:accession_number)
155
+ return build_params_from_filing(input)
156
+ end
157
+
158
+ # Handle hash input with accession_no: to_json(accession_no: "...") passed as positional
159
+ if input.is_a?(Hash) && input[:accession_no]
160
+ return build_params_from_accession_no(input[:accession_no])
161
+ end
162
+
163
+ raise ValidationError, "Invalid input: expected URL string, accession_no keyword, or Filing object"
164
+ end
165
+
166
+ def build_params_from_url(url)
167
+ validate_url!(url)
168
+ {"xbrl-url": url}
169
+ end
170
+
171
+ def build_params_from_accession_no(accession_no)
172
+ normalized = normalize_accession_no(accession_no)
173
+ validate_accession_no!(normalized)
174
+ {"accession-no": normalized}
175
+ end
176
+
177
+ def build_params_from_filing(filing)
178
+ params = {}
179
+ params[:"xbrl-url"] = filing.xbrl_url unless filing.xbrl_url.to_s.empty?
180
+ params[:"accession-no"] = filing.accession_number unless filing.accession_number.to_s.empty?
181
+ params
182
+ end
183
+
184
+ def validate_url!(url)
185
+ begin
186
+ uri = URI.parse(url)
187
+ unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
188
+ raise NotFoundError, "Filing not found: invalid URL format. URL must be a valid HTTP/HTTPS URL."
189
+ end
190
+ rescue URI::InvalidURIError
191
+ raise NotFoundError, "Filing not found: invalid URL format '#{url}'. Provide a valid SEC EDGAR URL."
192
+ end
193
+
194
+ unless url.match?(SEC_URL_PATTERN)
195
+ raise NotFoundError, "Filing not found: URL must be from sec.gov domain. Received: #{url}"
196
+ end
197
+ end
198
+
199
+ def validate_accession_no!(accession_no)
200
+ unless accession_no.match?(ACCESSION_DASHED_PATTERN)
201
+ raise ValidationError,
202
+ "Invalid accession number format: #{accession_no}. " \
203
+ "Expected format: XXXXXXXXXX-XX-XXXXXX (10-2-6 digits)"
204
+ end
205
+ end
206
+
207
+ def normalize_accession_no(accession_no)
208
+ # Already in dashed format
209
+ return accession_no if accession_no.match?(ACCESSION_DASHED_PATTERN)
210
+
211
+ # Convert undashed to dashed format: 0000320193230001060 -> 0000320193-23-000106
212
+ if accession_no.match?(ACCESSION_UNDASHED_PATTERN)
213
+ return "#{accession_no[0, 10]}-#{accession_no[10, 2]}-#{accession_no[12, 6]}"
214
+ end
215
+
216
+ # Return as-is for validation to catch
217
+ accession_no
218
+ end
219
+ end
220
+ end
data/lib/sec_api.rb ADDED
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Load Order Dependencies (Architecture ADR-1: Module Loading Strategy)
4
+ #
5
+ # This file establishes the load order for all SecApi modules. Order matters because:
6
+ #
7
+ # 1. Types FIRST: Dry::Types module must be defined before any Dry::Struct classes.
8
+ # Without this, attribute declarations in value objects will fail with NameError.
9
+ #
10
+ # 2. Errors BEFORE Middleware: Middleware raises typed errors, so error classes
11
+ # must be loaded first. Base classes before subclasses (Error → TransientError → RateLimitError).
12
+ #
13
+ # 3. Objects BEFORE Collections: Filings collection wraps Filing objects.
14
+ #
15
+ # 4. Helpers BEFORE dependents: DeepFreezable, CallbackHelper, etc. are mixed into
16
+ # other classes and must be available when those classes are defined.
17
+ #
18
+ # 5. Config BEFORE Client: Client validates config during initialization.
19
+ #
20
+ # Never use require_relative in individual files - all loading happens here.
21
+ # This centralized approach prevents circular dependencies and makes the load
22
+ # order explicit and auditable.
23
+
24
+ require_relative "sec_api/version"
25
+ require "dry-struct"
26
+ require "dry-types"
27
+
28
+ # === Foundation Layer ===
29
+ # Types and utilities that everything else depends on
30
+
31
+ # SecApi::Types module must load FIRST - Dry::Struct classes reference these types
32
+ require "sec_api/types"
33
+ require "sec_api/deep_freezable"
34
+ require "sec_api/callback_helper"
35
+ require "sec_api/structured_logger"
36
+ require "sec_api/metrics_collector"
37
+ require "sec_api/filing_journey"
38
+
39
+ # === Error Hierarchy ===
40
+ # Base classes before subclasses (Error → TransientError → RateLimitError)
41
+ # Middleware references these error classes, so they must load first
42
+
43
+ require "sec_api/errors/error"
44
+ require "sec_api/errors/configuration_error"
45
+ require "sec_api/errors/transient_error"
46
+ require "sec_api/errors/permanent_error"
47
+ require "sec_api/errors/rate_limit_error"
48
+ require "sec_api/errors/server_error"
49
+ require "sec_api/errors/network_error"
50
+ require "sec_api/errors/reconnection_error"
51
+ require "sec_api/errors/authentication_error"
52
+ require "sec_api/errors/not_found_error"
53
+ require "sec_api/errors/validation_error"
54
+ require "sec_api/errors/pagination_error"
55
+
56
+ # === State Tracking ===
57
+ require "sec_api/rate_limit_state"
58
+ require "sec_api/rate_limit_tracker"
59
+
60
+ # === Middleware Layer ===
61
+ # HTTP middleware for resilience and observability
62
+
63
+ require "sec_api/middleware/instrumentation"
64
+ require "sec_api/middleware/rate_limiter"
65
+ require "sec_api/middleware/error_handler"
66
+
67
+ # === Value Objects ===
68
+ # Immutable Dry::Struct objects for API responses
69
+
70
+ require "sec_api/objects/document_format_file"
71
+ require "sec_api/objects/data_file"
72
+ require "sec_api/objects/entity"
73
+ require "sec_api/objects/filing"
74
+ require "sec_api/objects/fulltext_result"
75
+ require "sec_api/objects/period"
76
+ require "sec_api/objects/fact"
77
+ require "sec_api/objects/xbrl_data"
78
+ require "sec_api/objects/extracted_data"
79
+ require "sec_api/objects/stream_filing"
80
+
81
+ # === Collections ===
82
+ # Enumerable wrappers for API response arrays
83
+
84
+ require "sec_api/collections/filings"
85
+ require "sec_api/collections/fulltext_results"
86
+
87
+ # === API Proxies ===
88
+ # Domain-specific interfaces to API endpoints
89
+
90
+ require "sec_api/query"
91
+ require "sec_api/extractor"
92
+ require "sec_api/mapping"
93
+ require "sec_api/config"
94
+ require "sec_api/client"
95
+ require "sec_api/xbrl"
96
+ require "sec_api/stream"
97
+
98
+ # SecApi is a Ruby client library for the sec-api.io API.
99
+ #
100
+ # This gem provides programmatic access to 18+ million SEC EDGAR filings
101
+ # with production-grade error handling, resilience, and observability.
102
+ #
103
+ # @example Basic usage
104
+ # client = SecApi::Client.new(api_key: "your_api_key")
105
+ # filings = client.query.ticker("AAPL").form_type("10-K").search
106
+ # filings.each { |f| puts "#{f.ticker}: #{f.form_type}" }
107
+ #
108
+ # @example Query with date range and full-text search
109
+ # filings = client.query
110
+ # .ticker("AAPL", "TSLA")
111
+ # .form_type("10-K", "10-Q")
112
+ # .date_range(from: "2020-01-01", to: "2023-12-31")
113
+ # .search_text("revenue growth")
114
+ # .search
115
+ #
116
+ # @example Entity resolution (ticker to CIK)
117
+ # entity = client.mapping.ticker("AAPL")
118
+ # puts "CIK: #{entity.cik}, Name: #{entity.name}"
119
+ #
120
+ # @example XBRL financial data extraction
121
+ # filing = client.query.ticker("AAPL").form_type("10-K").search.first
122
+ # xbrl = client.xbrl.to_json(filing)
123
+ # revenue = xbrl.statements_of_income["RevenueFromContractWithCustomerExcludingAssessedTax"]
124
+ #
125
+ # @example Real-time filing notifications
126
+ # client.stream.subscribe(tickers: ["AAPL"]) do |filing|
127
+ # puts "New filing: #{filing.form_type} at #{filing.filed_at}"
128
+ # end
129
+ #
130
+ # @see SecApi::Client Main entry point for API interactions
131
+ # @see SecApi::Query Fluent query builder for filing searches
132
+ # @see SecApi::Mapping Entity resolution endpoints
133
+ # @see SecApi::Xbrl XBRL data extraction
134
+ # @see SecApi::Stream Real-time WebSocket notifications
135
+ #
136
+ module SecApi
137
+ end
data/sig/sec_api.rbs ADDED
@@ -0,0 +1,4 @@
1
+ module SecApi
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end