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.
- checksums.yaml +7 -0
- data/.devcontainer/Dockerfile +54 -0
- data/.devcontainer/README.md +178 -0
- data/.devcontainer/devcontainer.json +46 -0
- data/.devcontainer/docker-compose.yml +28 -0
- data/.devcontainer/post-create.sh +51 -0
- data/.devcontainer/post-start.sh +44 -0
- data/.rspec +3 -0
- data/.standard.yml +3 -0
- data/CHANGELOG.md +5 -0
- data/CLAUDE.md +0 -0
- data/LICENSE.txt +21 -0
- data/MIGRATION.md +274 -0
- data/README.md +370 -0
- data/Rakefile +10 -0
- data/config/secapi.yml.example +57 -0
- data/docs/development-guide.md +291 -0
- data/docs/enumerator_pattern_design.md +483 -0
- data/docs/examples/README.md +58 -0
- data/docs/examples/backfill_filings.rb +419 -0
- data/docs/examples/instrumentation.rb +583 -0
- data/docs/examples/query_builder.rb +308 -0
- data/docs/examples/streaming_notifications.rb +491 -0
- data/docs/index.md +244 -0
- data/docs/migration-guide-v1.md +1091 -0
- data/docs/pre-review-checklist.md +145 -0
- data/docs/project-overview.md +90 -0
- data/docs/project-scan-report.json +60 -0
- data/docs/source-tree-analysis.md +190 -0
- data/lib/sec_api/callback_helper.rb +49 -0
- data/lib/sec_api/client.rb +606 -0
- data/lib/sec_api/collections/filings.rb +267 -0
- data/lib/sec_api/collections/fulltext_results.rb +86 -0
- data/lib/sec_api/config.rb +590 -0
- data/lib/sec_api/deep_freezable.rb +42 -0
- data/lib/sec_api/errors/authentication_error.rb +24 -0
- data/lib/sec_api/errors/configuration_error.rb +5 -0
- data/lib/sec_api/errors/error.rb +75 -0
- data/lib/sec_api/errors/network_error.rb +26 -0
- data/lib/sec_api/errors/not_found_error.rb +23 -0
- data/lib/sec_api/errors/pagination_error.rb +28 -0
- data/lib/sec_api/errors/permanent_error.rb +29 -0
- data/lib/sec_api/errors/rate_limit_error.rb +57 -0
- data/lib/sec_api/errors/reconnection_error.rb +34 -0
- data/lib/sec_api/errors/server_error.rb +25 -0
- data/lib/sec_api/errors/transient_error.rb +28 -0
- data/lib/sec_api/errors/validation_error.rb +23 -0
- data/lib/sec_api/extractor.rb +122 -0
- data/lib/sec_api/filing_journey.rb +477 -0
- data/lib/sec_api/mapping.rb +125 -0
- data/lib/sec_api/metrics_collector.rb +411 -0
- data/lib/sec_api/middleware/error_handler.rb +250 -0
- data/lib/sec_api/middleware/instrumentation.rb +186 -0
- data/lib/sec_api/middleware/rate_limiter.rb +541 -0
- data/lib/sec_api/objects/data_file.rb +34 -0
- data/lib/sec_api/objects/document_format_file.rb +45 -0
- data/lib/sec_api/objects/entity.rb +92 -0
- data/lib/sec_api/objects/extracted_data.rb +118 -0
- data/lib/sec_api/objects/fact.rb +147 -0
- data/lib/sec_api/objects/filing.rb +197 -0
- data/lib/sec_api/objects/fulltext_result.rb +66 -0
- data/lib/sec_api/objects/period.rb +96 -0
- data/lib/sec_api/objects/stream_filing.rb +194 -0
- data/lib/sec_api/objects/xbrl_data.rb +356 -0
- data/lib/sec_api/query.rb +423 -0
- data/lib/sec_api/rate_limit_state.rb +130 -0
- data/lib/sec_api/rate_limit_tracker.rb +154 -0
- data/lib/sec_api/stream.rb +841 -0
- data/lib/sec_api/structured_logger.rb +199 -0
- data/lib/sec_api/types.rb +32 -0
- data/lib/sec_api/version.rb +42 -0
- data/lib/sec_api/xbrl.rb +220 -0
- data/lib/sec_api.rb +137 -0
- data/sig/sec_api.rbs +4 -0
- 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
|
data/lib/sec_api/xbrl.rb
ADDED
|
@@ -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