open_fec 0.1.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/CHANGELOG.md +15 -0
- data/LICENSE.txt +21 -0
- data/README.md +104 -0
- data/lib/open_fec/client.rb +218 -0
- data/lib/open_fec/configuration.rb +76 -0
- data/lib/open_fec/error.rb +58 -0
- data/lib/open_fec/resources/base.rb +25 -0
- data/lib/open_fec/resources/candidate_totals.rb +34 -0
- data/lib/open_fec/resources/candidates.rb +49 -0
- data/lib/open_fec/resources/committees.rb +38 -0
- data/lib/open_fec/resources/contribution_aggregates.rb +87 -0
- data/lib/open_fec/resources/contributions.rb +55 -0
- data/lib/open_fec/resources/disbursements.rb +29 -0
- data/lib/open_fec/resources/elections.rb +41 -0
- data/lib/open_fec/resources/independent_expenditures.rb +50 -0
- data/lib/open_fec/response.rb +184 -0
- data/lib/open_fec/version.rb +5 -0
- data/lib/open_fec.rb +107 -0
- metadata +196 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 8dd8d770d0957b0f17a29d0e4197501679ed4a7dea95790483bcdf4dbaa6a156
|
|
4
|
+
data.tar.gz: 0560f6316507f7cecfb1e307275010299f3a548810b989b53d628dfb789ef971
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 806ebc183c698c3db5b814b7cfd537afa003dfae26ab853a3b4826638d31734dad89fac780a265cacd860aeadbbb8683eb90b937ce7e7bc8eaf563dfab4d71f4
|
|
7
|
+
data.tar.gz: 10df7ef81f0c814c29aff29fe9e574b3797d6fd9e99229de061abc5330aa6db8bafd78c2c0452b9a7c2ae65c56ad00e44d8baf5348abb36903222c6e5370b61f
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [0.1.0] - 2026-03-25
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- Initial release
|
|
7
|
+
- Candidates resource: search, find, committees
|
|
8
|
+
- CandidateTotals resource: financial summaries per cycle
|
|
9
|
+
- Committees resource: search, find
|
|
10
|
+
- Contributions resource: itemized Schedule A (seek-paginated)
|
|
11
|
+
- ContributionAggregates resource: by_employer, by_occupation, by_size, by_state
|
|
12
|
+
- Disbursements resource: itemized Schedule B (seek-paginated)
|
|
13
|
+
- Dual pagination support (offset-based and seek/cursor-based)
|
|
14
|
+
- Thread-safe configuration with Mutex
|
|
15
|
+
- Faraday 2.0 with retry middleware (429, 5xx)
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jack Killilea
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# OpenFec
|
|
2
|
+
|
|
3
|
+
Ruby client for the [Federal Election Commission (FEC) API](https://api.open.fec.gov/developers/).
|
|
4
|
+
|
|
5
|
+
Part of the [govapi-rb](https://github.com/govapi-rb) collection of Ruby clients for US government APIs.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
gem 'open_fec'
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Configuration
|
|
14
|
+
|
|
15
|
+
Get a free API key at [api.data.gov/signup](https://api.data.gov/signup/).
|
|
16
|
+
|
|
17
|
+
```ruby
|
|
18
|
+
# Via environment variable (recommended)
|
|
19
|
+
ENV['OPEN_FEC_API_KEY'] = 'your-api-key'
|
|
20
|
+
|
|
21
|
+
# Or via configuration block
|
|
22
|
+
OpenFec.configure do |c|
|
|
23
|
+
c.api_key = 'your-api-key'
|
|
24
|
+
end
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Usage
|
|
28
|
+
|
|
29
|
+
### Candidates
|
|
30
|
+
|
|
31
|
+
```ruby
|
|
32
|
+
# Search for candidates
|
|
33
|
+
results = OpenFec.candidates.search(name: 'Pelosi', state: 'CA')
|
|
34
|
+
results.each { |c| puts "#{c['name']} (#{c['party']})" }
|
|
35
|
+
|
|
36
|
+
# Find a specific candidate
|
|
37
|
+
candidate = OpenFec.candidates.find('H8CA05035')
|
|
38
|
+
candidate.results.first['name'] # => "PELOSI, NANCY"
|
|
39
|
+
|
|
40
|
+
# Get a candidate's committees
|
|
41
|
+
committees = OpenFec.candidates.committees('H8CA05035')
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### Campaign Finance Totals
|
|
45
|
+
|
|
46
|
+
```ruby
|
|
47
|
+
# Financial totals for a candidate
|
|
48
|
+
totals = OpenFec.candidate_totals.list('H8CA05035')
|
|
49
|
+
totals.each do |t|
|
|
50
|
+
puts "Cycle #{t['cycle']}: raised #{t['receipts']}, spent #{t['disbursements']}"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# For a specific cycle
|
|
54
|
+
totals = OpenFec.candidate_totals.for_cycle('H8CA05035', 2024)
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### Contributions by Employer
|
|
58
|
+
|
|
59
|
+
```ruby
|
|
60
|
+
# Top employer donors to a committee
|
|
61
|
+
employers = OpenFec.contribution_aggregates.by_employer(
|
|
62
|
+
committee_id: 'C00213512',
|
|
63
|
+
cycle: 2024,
|
|
64
|
+
sort: '-total',
|
|
65
|
+
per_page: 10
|
|
66
|
+
)
|
|
67
|
+
employers.each { |e| puts "#{e['employer']}: $#{e['total']}" }
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### PAC Contributions
|
|
71
|
+
|
|
72
|
+
```ruby
|
|
73
|
+
# PAC contributions to a committee
|
|
74
|
+
pacs = OpenFec.contributions.from_pacs(committee_id: 'C00213512', cycle: 2024)
|
|
75
|
+
pacs.each { |c| puts "#{c['contributor_name']}: $#{c['contribution_receipt_amount']}" }
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### Pagination
|
|
79
|
+
|
|
80
|
+
Most endpoints use offset-based pagination:
|
|
81
|
+
|
|
82
|
+
```ruby
|
|
83
|
+
OpenFec.candidates.each_page(state: 'VA') do |page|
|
|
84
|
+
page.results.each { |c| puts c['name'] }
|
|
85
|
+
end
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Itemized transaction endpoints (Schedule A, Schedule B) use seek/cursor pagination:
|
|
89
|
+
|
|
90
|
+
```ruby
|
|
91
|
+
OpenFec.contributions.each_page(committee_id: 'C00213512') do |page|
|
|
92
|
+
page.results.each { |c| puts c['contributor_name'] }
|
|
93
|
+
end
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Rate Limits
|
|
97
|
+
|
|
98
|
+
- `DEMO_KEY`: 30 requests/hour
|
|
99
|
+
- Registered key: 1,000 requests/hour
|
|
100
|
+
- Contact `APIinfo@fec.gov` for higher limits
|
|
101
|
+
|
|
102
|
+
## License
|
|
103
|
+
|
|
104
|
+
MIT
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'faraday'
|
|
4
|
+
require 'faraday/retry'
|
|
5
|
+
|
|
6
|
+
module OpenFec
|
|
7
|
+
# HTTP client for the FEC API. Handles authentication, retries, and pagination.
|
|
8
|
+
#
|
|
9
|
+
# @example Direct usage
|
|
10
|
+
# client = OpenFec.client
|
|
11
|
+
# response = client.get('candidates/search/', name: 'Pelosi')
|
|
12
|
+
class Client
|
|
13
|
+
# @return [OpenFec::Configuration]
|
|
14
|
+
attr_reader :config
|
|
15
|
+
|
|
16
|
+
# @param config [OpenFec::Configuration]
|
|
17
|
+
# @raise [ConfigurationError] if api_key is missing
|
|
18
|
+
def initialize(config = OpenFec.configuration)
|
|
19
|
+
@config = config
|
|
20
|
+
config.validate!
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @return [Faraday::Connection]
|
|
24
|
+
def connection
|
|
25
|
+
@connection ||= build_connection
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Make a GET request to the FEC API.
|
|
29
|
+
#
|
|
30
|
+
# @param path [String] API endpoint path (e.g. "candidates/search/")
|
|
31
|
+
# @param params [Hash] query parameters
|
|
32
|
+
# @return [OpenFec::Response]
|
|
33
|
+
# @raise [ClientError, ServerError, ConnectionError]
|
|
34
|
+
def get(path, params = {})
|
|
35
|
+
response = connection.get(path, with_api_key(compact_params(params)))
|
|
36
|
+
handle_response(response)
|
|
37
|
+
rescue Faraday::TimeoutError => e
|
|
38
|
+
raise ConnectionError, "Request timed out: #{e.message}"
|
|
39
|
+
rescue Faraday::ConnectionFailed => e
|
|
40
|
+
raise ConnectionError, "Connection failed: #{e.message}"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Iterate through offset-paginated results, yielding each page.
|
|
44
|
+
# Used by most FEC API endpoints.
|
|
45
|
+
#
|
|
46
|
+
# @param path [String] API endpoint path
|
|
47
|
+
# @param params [Hash] query parameters
|
|
48
|
+
# @yield [OpenFec::Response] each page of results
|
|
49
|
+
def paginate(path, params = {})
|
|
50
|
+
page = 1
|
|
51
|
+
iterations = 0
|
|
52
|
+
loop do
|
|
53
|
+
iterations += 1
|
|
54
|
+
response = get(path, params.merge(page: page, per_page: config.per_page))
|
|
55
|
+
yield response
|
|
56
|
+
break if response.empty?
|
|
57
|
+
break unless response.next_page?
|
|
58
|
+
|
|
59
|
+
page = response.next_page_number
|
|
60
|
+
break if page.nil?
|
|
61
|
+
break if iterations >= config.max_pages
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Iterate through seek/cursor-paginated results, yielding each page.
|
|
66
|
+
# Used by itemized transaction endpoints (Schedule A, Schedule B).
|
|
67
|
+
#
|
|
68
|
+
# @param path [String] API endpoint path
|
|
69
|
+
# @param params [Hash] query parameters
|
|
70
|
+
# @yield [OpenFec::Response] each page of results
|
|
71
|
+
def paginate_seek(path, params = {})
|
|
72
|
+
iterations = 0
|
|
73
|
+
cursor_params = {}
|
|
74
|
+
loop do
|
|
75
|
+
iterations += 1
|
|
76
|
+
response = get(path, params.merge(per_page: config.per_page, **cursor_params))
|
|
77
|
+
yield response
|
|
78
|
+
break if response.empty?
|
|
79
|
+
break unless response.next_page?
|
|
80
|
+
|
|
81
|
+
cursor_params = response.next_page_params || {}
|
|
82
|
+
break if cursor_params.empty?
|
|
83
|
+
break if iterations >= config.max_pages
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Fetch all results from an offset-paginated endpoint.
|
|
88
|
+
#
|
|
89
|
+
# @param path [String] API endpoint path
|
|
90
|
+
# @param params [Hash] query parameters
|
|
91
|
+
# @return [Array<Hash>] all result records
|
|
92
|
+
def all(path, params = {})
|
|
93
|
+
records = []
|
|
94
|
+
paginate(path, params) { |response| records.concat(response.results) }
|
|
95
|
+
records
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Fetch all results from a seek-paginated endpoint.
|
|
99
|
+
#
|
|
100
|
+
# @param path [String] API endpoint path
|
|
101
|
+
# @param params [Hash] query parameters
|
|
102
|
+
# @return [Array<Hash>] all result records
|
|
103
|
+
def all_seek(path, params = {})
|
|
104
|
+
records = []
|
|
105
|
+
paginate_seek(path, params) { |response| records.concat(response.results) }
|
|
106
|
+
records
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Resource accessors
|
|
110
|
+
|
|
111
|
+
# @return [OpenFec::Resources::Candidates]
|
|
112
|
+
def candidates
|
|
113
|
+
@candidates ||= Resources::Candidates.new(self)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# @return [OpenFec::Resources::CandidateTotals]
|
|
117
|
+
def candidate_totals
|
|
118
|
+
@candidate_totals ||= Resources::CandidateTotals.new(self)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# @return [OpenFec::Resources::Committees]
|
|
122
|
+
def committees
|
|
123
|
+
@committees ||= Resources::Committees.new(self)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# @return [OpenFec::Resources::Contributions]
|
|
127
|
+
def contributions
|
|
128
|
+
@contributions ||= Resources::Contributions.new(self)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# @return [OpenFec::Resources::ContributionAggregates]
|
|
132
|
+
def contribution_aggregates
|
|
133
|
+
@contribution_aggregates ||= Resources::ContributionAggregates.new(self)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# @return [OpenFec::Resources::Disbursements]
|
|
137
|
+
def disbursements
|
|
138
|
+
@disbursements ||= Resources::Disbursements.new(self)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# @return [OpenFec::Resources::Elections]
|
|
142
|
+
def elections
|
|
143
|
+
@elections ||= Resources::Elections.new(self)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# @return [OpenFec::Resources::IndependentExpenditures]
|
|
147
|
+
def independent_expenditures
|
|
148
|
+
@independent_expenditures ||= Resources::IndependentExpenditures.new(self)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# @return [String]
|
|
152
|
+
def inspect
|
|
153
|
+
"#<#{self.class} base_url=#{config.base_url.inspect} timeout=#{config.timeout}>"
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
private
|
|
157
|
+
|
|
158
|
+
def build_connection
|
|
159
|
+
Faraday.new(url: config.base_url) do |f|
|
|
160
|
+
f.options.timeout = config.timeout
|
|
161
|
+
f.options.open_timeout = 10
|
|
162
|
+
|
|
163
|
+
f.headers['User-Agent'] = "open_fec-rb/#{OpenFec::VERSION}"
|
|
164
|
+
|
|
165
|
+
f.request :json
|
|
166
|
+
f.response :json, content_type: /\bjson$/
|
|
167
|
+
|
|
168
|
+
f.request :retry,
|
|
169
|
+
max: config.retries,
|
|
170
|
+
interval: 0.5,
|
|
171
|
+
interval_randomness: 0.5,
|
|
172
|
+
backoff_factor: 2,
|
|
173
|
+
retry_statuses: [429, 500, 502, 503, 504],
|
|
174
|
+
exceptions: [
|
|
175
|
+
Faraday::TimeoutError,
|
|
176
|
+
Faraday::ConnectionFailed,
|
|
177
|
+
Faraday::RetriableResponse
|
|
178
|
+
]
|
|
179
|
+
|
|
180
|
+
f.response :logger, config.logger if config.logger
|
|
181
|
+
f.adapter config.adapter
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def with_api_key(params)
|
|
186
|
+
params.merge('api_key' => config.api_key)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def compact_params(params)
|
|
190
|
+
params.compact.transform_keys(&:to_s)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def handle_response(response) # rubocop:disable Metrics/MethodLength
|
|
194
|
+
case response.status
|
|
195
|
+
when 200..299
|
|
196
|
+
Response.new(response.body, response.status)
|
|
197
|
+
when 400
|
|
198
|
+
raise BadRequestError.new(status: response.status, body: response.body)
|
|
199
|
+
when 401
|
|
200
|
+
raise UnauthorizedError.new(status: response.status, body: response.body)
|
|
201
|
+
when 403
|
|
202
|
+
raise ForbiddenError.new(status: response.status, body: response.body)
|
|
203
|
+
when 404
|
|
204
|
+
raise NotFoundError.new(status: response.status, body: response.body)
|
|
205
|
+
when 422
|
|
206
|
+
raise UnprocessableEntityError.new(status: response.status, body: response.body)
|
|
207
|
+
when 429
|
|
208
|
+
raise RateLimitError.new(status: 429, body: response.body)
|
|
209
|
+
when 400..499
|
|
210
|
+
raise ClientError.new(status: response.status, body: response.body)
|
|
211
|
+
when 500..599
|
|
212
|
+
raise ServerError.new(status: response.status, body: response.body)
|
|
213
|
+
else
|
|
214
|
+
raise Error, "Unexpected HTTP status: #{response.status}"
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenFec
|
|
4
|
+
# Configuration for the OpenFEC API client.
|
|
5
|
+
#
|
|
6
|
+
# @example
|
|
7
|
+
# OpenFec.configure do |c|
|
|
8
|
+
# c.api_key = 'your-api-key'
|
|
9
|
+
# c.per_page = 50
|
|
10
|
+
# c.timeout = 60
|
|
11
|
+
# end
|
|
12
|
+
class Configuration
|
|
13
|
+
DEFAULT_BASE_URL = 'https://api.open.fec.gov/v1/'
|
|
14
|
+
DEFAULT_TIMEOUT = 30
|
|
15
|
+
DEFAULT_RETRIES = 3
|
|
16
|
+
DEFAULT_PER_PAGE = 20
|
|
17
|
+
DEFAULT_MAX_PAGES = 100
|
|
18
|
+
|
|
19
|
+
# @return [String] base URL for the FEC API
|
|
20
|
+
attr_accessor :base_url
|
|
21
|
+
# @return [Integer] HTTP timeout in seconds
|
|
22
|
+
attr_accessor :timeout
|
|
23
|
+
# @return [Integer] number of retries on 429/5xx
|
|
24
|
+
attr_accessor :retries
|
|
25
|
+
# @return [Logger, nil] optional logger for HTTP requests
|
|
26
|
+
attr_accessor :logger
|
|
27
|
+
# @return [Symbol] Faraday adapter (default: system default)
|
|
28
|
+
attr_accessor :adapter
|
|
29
|
+
# @return [String, nil] FEC API key (get one free at https://api.data.gov/signup)
|
|
30
|
+
attr_accessor :api_key
|
|
31
|
+
# @return [Integer] default results per page
|
|
32
|
+
attr_accessor :per_page
|
|
33
|
+
# @return [Integer] max pages to fetch during pagination
|
|
34
|
+
attr_accessor :max_pages
|
|
35
|
+
|
|
36
|
+
def initialize
|
|
37
|
+
@base_url = DEFAULT_BASE_URL
|
|
38
|
+
@timeout = DEFAULT_TIMEOUT
|
|
39
|
+
@retries = DEFAULT_RETRIES
|
|
40
|
+
@per_page = DEFAULT_PER_PAGE
|
|
41
|
+
@max_pages = DEFAULT_MAX_PAGES
|
|
42
|
+
@logger = nil
|
|
43
|
+
@adapter = Faraday.default_adapter
|
|
44
|
+
@api_key = ENV.fetch('OPEN_FEC_API_KEY', nil)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Validates that required configuration is present.
|
|
48
|
+
#
|
|
49
|
+
# @raise [ConfigurationError] if api_key is missing
|
|
50
|
+
# @return [void]
|
|
51
|
+
def validate!
|
|
52
|
+
return unless api_key.nil? || api_key.empty?
|
|
53
|
+
|
|
54
|
+
raise ConfigurationError,
|
|
55
|
+
'api_key is required (get one free at https://api.data.gov/signup)'
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# @return [Hash] all configuration values
|
|
59
|
+
def to_h
|
|
60
|
+
{
|
|
61
|
+
base_url: base_url,
|
|
62
|
+
timeout: timeout,
|
|
63
|
+
retries: retries,
|
|
64
|
+
per_page: per_page,
|
|
65
|
+
max_pages: max_pages,
|
|
66
|
+
logger: logger,
|
|
67
|
+
adapter: adapter,
|
|
68
|
+
api_key: api_key
|
|
69
|
+
}
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def inspect
|
|
73
|
+
"#<#{self.class} base_url=#{base_url.inspect} timeout=#{timeout} retries=#{retries}>"
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenFec
|
|
4
|
+
# Base error class for all OpenFec errors.
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# Raised for 4xx HTTP responses.
|
|
8
|
+
class ClientError < Error
|
|
9
|
+
# @return [Integer, nil] HTTP status code
|
|
10
|
+
attr_reader :status
|
|
11
|
+
# @return [Hash, String, nil] response body
|
|
12
|
+
attr_reader :body
|
|
13
|
+
|
|
14
|
+
# @param message [String, nil]
|
|
15
|
+
# @param status [Integer, nil]
|
|
16
|
+
# @param body [Hash, String, nil]
|
|
17
|
+
def initialize(message = nil, status: nil, body: nil)
|
|
18
|
+
@status = status
|
|
19
|
+
@body = body
|
|
20
|
+
super(message || "HTTP #{status}: #{body}")
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Raised on 400 Bad Request.
|
|
25
|
+
class BadRequestError < ClientError; end
|
|
26
|
+
# Raised on 401 Unauthorized (invalid or missing API key).
|
|
27
|
+
class UnauthorizedError < ClientError; end
|
|
28
|
+
# Raised on 403 Forbidden.
|
|
29
|
+
class ForbiddenError < ClientError; end
|
|
30
|
+
# Raised on 404 Not Found.
|
|
31
|
+
class NotFoundError < ClientError; end
|
|
32
|
+
# Raised on 429 Too Many Requests (rate limit exceeded).
|
|
33
|
+
class RateLimitError < ClientError; end
|
|
34
|
+
# Raised on 422 Unprocessable Entity.
|
|
35
|
+
class UnprocessableEntityError < ClientError; end
|
|
36
|
+
|
|
37
|
+
# Raised for 5xx HTTP responses.
|
|
38
|
+
class ServerError < Error
|
|
39
|
+
# @return [Integer, nil] HTTP status code
|
|
40
|
+
attr_reader :status
|
|
41
|
+
# @return [Hash, String, nil] response body
|
|
42
|
+
attr_reader :body
|
|
43
|
+
|
|
44
|
+
# @param message [String, nil]
|
|
45
|
+
# @param status [Integer, nil]
|
|
46
|
+
# @param body [Hash, String, nil]
|
|
47
|
+
def initialize(message = nil, status: nil, body: nil)
|
|
48
|
+
@status = status
|
|
49
|
+
@body = body
|
|
50
|
+
super(message || "Server error HTTP #{status}")
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Raised on network-level failures (timeout, connection refused).
|
|
55
|
+
class ConnectionError < Error; end
|
|
56
|
+
# Raised when configuration is invalid (e.g. missing API key).
|
|
57
|
+
class ConfigurationError < Error; end
|
|
58
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenFec
|
|
4
|
+
# Resource classes for interacting with FEC API endpoints.
|
|
5
|
+
module Resources
|
|
6
|
+
# Base class for all FEC API resource classes.
|
|
7
|
+
class Base
|
|
8
|
+
# @param client [OpenFec::Client]
|
|
9
|
+
def initialize(client = OpenFec.client)
|
|
10
|
+
@client = client
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
attr_reader :client
|
|
16
|
+
|
|
17
|
+
# @param path [String]
|
|
18
|
+
# @param params [Hash]
|
|
19
|
+
# @return [OpenFec::Response]
|
|
20
|
+
def get(path, params = {})
|
|
21
|
+
client.get(path, params)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenFec
|
|
4
|
+
module Resources
|
|
5
|
+
# Campaign finance totals for candidates across election cycles.
|
|
6
|
+
#
|
|
7
|
+
# @example Get all cycles for a candidate
|
|
8
|
+
# OpenFec.candidate_totals.list('H8CA05035')
|
|
9
|
+
#
|
|
10
|
+
# @example Get totals for a specific cycle
|
|
11
|
+
# OpenFec.candidate_totals.for_cycle('H8CA05035', 2024)
|
|
12
|
+
class CandidateTotals < Base
|
|
13
|
+
# Financial totals for a candidate across election cycles.
|
|
14
|
+
# Returns receipts, disbursements, cash_on_hand, individual_contributions,
|
|
15
|
+
# other_political_committee_contributions (PAC money), etc.
|
|
16
|
+
#
|
|
17
|
+
# @param candidate_id [String] FEC candidate ID (e.g. "H8CA05035")
|
|
18
|
+
# @param params [Hash] optional filters (:cycle, :election_full, etc.)
|
|
19
|
+
# @return [OpenFec::Response]
|
|
20
|
+
def list(candidate_id, **params)
|
|
21
|
+
get("candidate/#{candidate_id}/totals/", params)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Fetch totals for a specific election cycle.
|
|
25
|
+
#
|
|
26
|
+
# @param candidate_id [String] FEC candidate ID
|
|
27
|
+
# @param cycle [Integer] election cycle year (e.g. 2024)
|
|
28
|
+
# @return [OpenFec::Response]
|
|
29
|
+
def for_cycle(candidate_id, cycle)
|
|
30
|
+
list(candidate_id, cycle: cycle)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenFec
|
|
4
|
+
module Resources
|
|
5
|
+
# Search and retrieve FEC candidate records.
|
|
6
|
+
#
|
|
7
|
+
# @example Search by name
|
|
8
|
+
# OpenFec.candidates.search(name: 'Pelosi', state: 'CA')
|
|
9
|
+
#
|
|
10
|
+
# @example Get a candidate with their principal committees
|
|
11
|
+
# candidate = OpenFec.candidates.find('H8CA05035')
|
|
12
|
+
# committee_id = candidate.results.first.dig('principal_committees', 0, 'committee_id')
|
|
13
|
+
class Candidates < Base
|
|
14
|
+
# Search for candidates by name, state, district, party, etc.
|
|
15
|
+
#
|
|
16
|
+
# @param params [Hash] search filters (:name, :state, :district, :party, :office, etc.)
|
|
17
|
+
# @return [OpenFec::Response]
|
|
18
|
+
def search(**params)
|
|
19
|
+
get('candidates/search/', params)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Fetch a single candidate by FEC candidate ID.
|
|
23
|
+
# Includes principal_committees in the response.
|
|
24
|
+
#
|
|
25
|
+
# @param candidate_id [String] FEC candidate ID (e.g. "H8CA05035")
|
|
26
|
+
# @return [OpenFec::Response]
|
|
27
|
+
def find(candidate_id)
|
|
28
|
+
get("candidate/#{candidate_id}/")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# List committees associated with a candidate.
|
|
32
|
+
#
|
|
33
|
+
# @param candidate_id [String] FEC candidate ID
|
|
34
|
+
# @param params [Hash] optional filters (:designation, :organization_type, etc.)
|
|
35
|
+
# @return [OpenFec::Response]
|
|
36
|
+
def committees(candidate_id, **params)
|
|
37
|
+
get("candidate/#{candidate_id}/committees/", params)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Paginate through candidate search results (offset-based).
|
|
41
|
+
#
|
|
42
|
+
# @param params [Hash] search filters
|
|
43
|
+
# @yield [OpenFec::Response] each page of results
|
|
44
|
+
def each_page(**params, &)
|
|
45
|
+
client.paginate('candidates/search/', params, &)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenFec
|
|
4
|
+
module Resources
|
|
5
|
+
# Search and retrieve FEC committee records (PACs, campaign committees, etc.).
|
|
6
|
+
#
|
|
7
|
+
# @example Find committees for a candidate
|
|
8
|
+
# OpenFec.committees.search(candidate_id: 'H8CA05035')
|
|
9
|
+
#
|
|
10
|
+
# @example Get a specific committee
|
|
11
|
+
# OpenFec.committees.find('C00213512')
|
|
12
|
+
class Committees < Base
|
|
13
|
+
# Search committees by name, candidate_id, state, committee_type, etc.
|
|
14
|
+
#
|
|
15
|
+
# @param params [Hash] search filters
|
|
16
|
+
# @return [OpenFec::Response]
|
|
17
|
+
def search(**params)
|
|
18
|
+
get('committees/', params)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Fetch a single committee by FEC committee ID.
|
|
22
|
+
#
|
|
23
|
+
# @param committee_id [String] FEC committee ID (e.g. "C00213512")
|
|
24
|
+
# @return [OpenFec::Response]
|
|
25
|
+
def find(committee_id)
|
|
26
|
+
get("committee/#{committee_id}/")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Paginate through committee search results (offset-based).
|
|
30
|
+
#
|
|
31
|
+
# @param params [Hash] search filters
|
|
32
|
+
# @yield [OpenFec::Response] each page of results
|
|
33
|
+
def each_page(**params, &)
|
|
34
|
+
client.paginate('committees/', params, &)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|