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.
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenFec
4
+ module Resources
5
+ # Aggregated contribution data (by employer, occupation, size, state, zip).
6
+ # All endpoints use offset-based pagination.
7
+ #
8
+ # @example Top employer donors to a committee
9
+ # OpenFec.contribution_aggregates.by_employer(
10
+ # committee_id: 'C00213512', cycle: 2024, sort: '-total', per_page: 10
11
+ # )
12
+ #
13
+ # @example Donations by zip code
14
+ # OpenFec.contribution_aggregates.by_zip(
15
+ # committee_id: 'C00555888', cycle: 2024, sort: '-total', per_page: 10
16
+ # )
17
+ class ContributionAggregates < Base
18
+ # Contributions aggregated by employer.
19
+ # Returns: employer, total, count, cycle.
20
+ #
21
+ # @param committee_id [String] FEC committee ID
22
+ # @param cycle [Integer] election cycle year
23
+ # @param params [Hash] additional params (:sort, :per_page, etc.)
24
+ # @return [OpenFec::Response]
25
+ def by_employer(committee_id:, cycle:, **params)
26
+ get('schedules/schedule_a/by_employer/',
27
+ params.merge(committee_id: committee_id, cycle: cycle))
28
+ end
29
+
30
+ # Contributions aggregated by occupation.
31
+ #
32
+ # @param committee_id [String] FEC committee ID
33
+ # @param cycle [Integer] election cycle year
34
+ # @param params [Hash] additional params
35
+ # @return [OpenFec::Response]
36
+ def by_occupation(committee_id:, cycle:, **params)
37
+ get('schedules/schedule_a/by_occupation/',
38
+ params.merge(committee_id: committee_id, cycle: cycle))
39
+ end
40
+
41
+ # Contributions aggregated by size (small/medium/large dollar).
42
+ #
43
+ # @param candidate_id [String] FEC candidate ID
44
+ # @param cycle [Integer] election cycle year
45
+ # @param params [Hash] additional params
46
+ # @return [OpenFec::Response]
47
+ def by_size(candidate_id:, cycle:, **params)
48
+ get('schedules/schedule_a/by_size/by_candidate/',
49
+ params.merge(candidate_id: candidate_id, cycle: cycle))
50
+ end
51
+
52
+ # Contributions aggregated by state.
53
+ #
54
+ # @param candidate_id [String] FEC candidate ID
55
+ # @param cycle [Integer] election cycle year
56
+ # @param params [Hash] additional params
57
+ # @return [OpenFec::Response]
58
+ def by_state(candidate_id:, cycle:, **params)
59
+ get('schedules/schedule_a/by_state/by_candidate/',
60
+ params.merge(candidate_id: candidate_id, cycle: cycle))
61
+ end
62
+
63
+ # Contributions aggregated by zip code.
64
+ # Returns: zip, total, count, cycle.
65
+ #
66
+ # @param committee_id [String] FEC committee ID
67
+ # @param cycle [Integer] election cycle year
68
+ # @param params [Hash] additional params (:sort, :per_page, etc.)
69
+ # @return [OpenFec::Response]
70
+ def by_zip(committee_id:, cycle:, **params)
71
+ get('schedules/schedule_a/by_zip/',
72
+ params.merge(committee_id: committee_id, cycle: cycle))
73
+ end
74
+
75
+ # Paginate through employer contribution aggregates.
76
+ #
77
+ # @param committee_id [String] FEC committee ID
78
+ # @param cycle [Integer] election cycle year
79
+ # @param params [Hash] additional params
80
+ # @yield [OpenFec::Response] each page of results
81
+ def each_employer_page(committee_id:, cycle:, **params, &)
82
+ client.paginate('schedules/schedule_a/by_employer/',
83
+ params.merge(committee_id: committee_id, cycle: cycle), &)
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenFec
4
+ module Resources
5
+ # Itemized contribution records (Schedule A).
6
+ # Uses seek/cursor-based pagination (not offset-based).
7
+ #
8
+ # @example Get PAC contributions to a committee
9
+ # OpenFec.contributions.from_pacs(committee_id: 'C00213512', cycle: 2024)
10
+ #
11
+ # @example Paginate all contributions
12
+ # OpenFec.contributions.each_page(committee_id: 'C00213512') do |page|
13
+ # page.each { |c| puts c['contributor_name'] }
14
+ # end
15
+ class Contributions < Base
16
+ # List itemized contributions (Schedule A).
17
+ #
18
+ # @param params [Hash] filters (:committee_id, :contributor_name,
19
+ # :entity_type, :two_year_transaction_period, :sort, etc.)
20
+ # @return [OpenFec::Response]
21
+ def list(**params)
22
+ get('schedules/schedule_a/', params)
23
+ end
24
+
25
+ # PAC contributions to a committee (entity_type=COM).
26
+ #
27
+ # @param committee_id [String] FEC committee ID
28
+ # @param cycle [Integer, nil] election cycle year
29
+ # @param params [Hash] additional filters
30
+ # @return [OpenFec::Response]
31
+ def from_pacs(committee_id:, cycle: nil, **params)
32
+ merged = params.merge(committee_id: committee_id, entity_type: 'COM')
33
+ merged[:two_year_transaction_period] = cycle if cycle
34
+ get('schedules/schedule_a/', merged)
35
+ end
36
+
37
+ # Paginate through itemized contributions (seek/cursor-based).
38
+ #
39
+ # @param params [Hash] filters
40
+ # @yield [OpenFec::Response] each page of results
41
+ def each_page(**params, &)
42
+ client.paginate_seek('schedules/schedule_a/', params, &)
43
+ end
44
+
45
+ # Fetch all itemized contributions (seek-based). Use with caution on
46
+ # large result sets.
47
+ #
48
+ # @param params [Hash] filters
49
+ # @return [Array<Hash>] all contribution records
50
+ def all_contributions(**params)
51
+ client.all_seek('schedules/schedule_a/', params)
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenFec
4
+ module Resources
5
+ # Itemized disbursement records (Schedule B).
6
+ # Uses seek/cursor-based pagination (not offset-based).
7
+ #
8
+ # @example List disbursements for a committee
9
+ # OpenFec.disbursements.list(committee_id: 'C00213512')
10
+ class Disbursements < Base
11
+ # List itemized disbursements (Schedule B).
12
+ #
13
+ # @param params [Hash] filters (:committee_id, :recipient_name,
14
+ # :two_year_transaction_period, :sort, etc.)
15
+ # @return [OpenFec::Response]
16
+ def list(**params)
17
+ get('schedules/schedule_b/', params)
18
+ end
19
+
20
+ # Paginate through disbursements (seek/cursor-based).
21
+ #
22
+ # @param params [Hash] filters
23
+ # @yield [OpenFec::Response] each page of results
24
+ def each_page(**params, &)
25
+ client.paginate_seek('schedules/schedule_b/', params, &)
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenFec
4
+ module Resources
5
+ # Election results and candidate comparisons for a specific race.
6
+ # Shows all candidates who ran in a district with financial totals side by side.
7
+ #
8
+ # @example Get all candidates who ran for VA-08 in 2024
9
+ # OpenFec.elections.list(state: 'VA', district: '08', cycle: 2024, office: 'house')
10
+ #
11
+ # @example Election summary for a race
12
+ # OpenFec.elections.summary(cycle: 2024, office: 'house', state: 'VA', district: '08')
13
+ class Elections < Base
14
+ # List all candidates in a specific election with financial totals.
15
+ # Returns candidate_name, party, total_receipts, total_disbursements,
16
+ # cash_on_hand_end_period, etc.
17
+ #
18
+ # @param params [Hash] filters (:state, :district, :cycle, :office — "house" or "senate")
19
+ # @return [OpenFec::Response]
20
+ def list(**params)
21
+ get('elections/', params)
22
+ end
23
+
24
+ # Search for elections by state, office, and cycle.
25
+ #
26
+ # @param params [Hash] filters (:state, :office, :cycle, :district, :zip)
27
+ # @return [OpenFec::Response]
28
+ def search(**params)
29
+ get('elections/search/', params)
30
+ end
31
+
32
+ # Election summary with aggregate spending data.
33
+ #
34
+ # @param params [Hash] filters (:cycle, :office, :state, :district, :election_full)
35
+ # @return [OpenFec::Response]
36
+ def summary(**params)
37
+ get('elections/summary/', params)
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenFec
4
+ module Resources
5
+ # Independent expenditures (Schedule E) — Super PAC and outside group
6
+ # spending for or against candidates.
7
+ #
8
+ # @example Super PAC spending for/against a candidate
9
+ # OpenFec.independent_expenditures.by_candidate(candidate_id: 'H4VA08224', cycle: 2024)
10
+ #
11
+ # @example Totals by candidate
12
+ # OpenFec.independent_expenditures.totals_by_candidate(candidate_id: 'H4VA08224', cycle: 2024)
13
+ class IndependentExpenditures < Base
14
+ # List itemized independent expenditures (Schedule E).
15
+ # Uses seek/cursor-based pagination.
16
+ #
17
+ # @param params [Hash] filters (:committee_id, :candidate_id, :cycle,
18
+ # :support_oppose_indicator — "S" for support, "O" for oppose, etc.)
19
+ # @return [OpenFec::Response]
20
+ def list(**params)
21
+ get('schedules/schedule_e/', params)
22
+ end
23
+
24
+ # Independent expenditures aggregated by candidate.
25
+ # Shows committee_name, support/oppose indicator, total amount.
26
+ #
27
+ # @param params [Hash] filters (:candidate_id, :cycle, :office, :state, :district)
28
+ # @return [OpenFec::Response]
29
+ def by_candidate(**params)
30
+ get('schedules/schedule_e/by_candidate/', params)
31
+ end
32
+
33
+ # Totals of independent expenditures by candidate.
34
+ #
35
+ # @param params [Hash] filters (:candidate_id, :cycle, :election_full)
36
+ # @return [OpenFec::Response]
37
+ def totals_by_candidate(**params)
38
+ get('schedules/schedule_e/totals/by_candidate/', params)
39
+ end
40
+
41
+ # Paginate through itemized independent expenditures (seek-based).
42
+ #
43
+ # @param params [Hash] filters
44
+ # @yield [OpenFec::Response] each page of results
45
+ def each_page(**params, &)
46
+ client.paginate_seek('schedules/schedule_e/', params, &)
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,184 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenFec
4
+ # Wraps FEC API JSON responses with pagination metadata and Enumerable access.
5
+ #
6
+ # The FEC API uses two pagination models:
7
+ # - **Offset-based** (most endpoints): uses `page`/`pages` counters
8
+ # - **Seek/cursor-based** (itemized transactions): uses `last_indexes` cursors
9
+ #
10
+ # This class auto-detects which model is in use and exposes a unified
11
+ # {#next_page_params} method for both.
12
+ #
13
+ # @example Iterating results
14
+ # response = OpenFec.candidates.search(name: 'Pelosi')
15
+ # response.each { |candidate| puts candidate['name'] }
16
+ #
17
+ # @example Checking pagination
18
+ # response.next_page? # => true
19
+ # response.next_page_params # => { page: 2 } or { last_index: '456', ... }
20
+ class Response
21
+ include Enumerable
22
+
23
+ # @return [Hash, Array] raw parsed JSON body
24
+ attr_reader :raw
25
+ # @return [Integer] HTTP status code
26
+ attr_reader :status
27
+
28
+ # @param body [Hash, Array] parsed JSON response body
29
+ # @param status [Integer] HTTP status code
30
+ def initialize(body, status)
31
+ @raw = body
32
+ @status = status
33
+ end
34
+
35
+ # Yields each result record.
36
+ # @yield [Hash] individual result record
37
+ def each(&)
38
+ results.each(&)
39
+ end
40
+
41
+ # Returns the results array from the response.
42
+ # @return [Array<Hash>]
43
+ def results
44
+ return raw if raw.is_a?(Array)
45
+ return [] unless raw.is_a?(Hash)
46
+
47
+ raw['results'] || []
48
+ end
49
+
50
+ # @return [Integer] number of results in this page
51
+ def size
52
+ results.size
53
+ end
54
+ alias length size
55
+
56
+ # @return [Boolean] true if no results in this page
57
+ def empty?
58
+ results.empty?
59
+ end
60
+
61
+ # --- Pagination metadata ---
62
+
63
+ # @return [Hash] raw pagination metadata from the API
64
+ def pagination
65
+ return {} unless raw.is_a?(Hash)
66
+
67
+ raw['pagination'] || {}
68
+ end
69
+
70
+ # @return [Integer, nil] total number of matching records across all pages
71
+ def total_count
72
+ pagination['count']
73
+ end
74
+
75
+ # @return [Integer, nil] total number of pages (offset-based only)
76
+ def total_pages
77
+ pagination['pages']
78
+ end
79
+
80
+ # @return [Integer, nil] current page number (offset-based only)
81
+ def current_page
82
+ pagination['page']
83
+ end
84
+
85
+ # @return [Integer, nil] results per page
86
+ def per_page
87
+ pagination['per_page']
88
+ end
89
+
90
+ # Whether there are more pages of results to fetch.
91
+ # Works for both offset-based and seek-based pagination.
92
+ #
93
+ # @return [Boolean]
94
+ def next_page?
95
+ return seek_next_page? if seek_paginated?
96
+
97
+ page = current_page
98
+ pages = total_pages
99
+ return false if page.nil? || pages.nil?
100
+
101
+ page < pages
102
+ end
103
+
104
+ # @return [Integer, nil] the next page number (offset-based only)
105
+ def next_page_number
106
+ return nil unless next_page? && !seek_paginated?
107
+
108
+ current_page + 1
109
+ end
110
+
111
+ # Whether this response uses seek/cursor-based pagination.
112
+ # True for itemized transaction endpoints (Schedule A, Schedule B).
113
+ #
114
+ # @return [Boolean]
115
+ def seek_paginated?
116
+ pagination.key?('last_indexes')
117
+ end
118
+
119
+ # @return [Hash] cursor keys for seek-based pagination
120
+ def last_indexes
121
+ pagination['last_indexes'] || {}
122
+ end
123
+
124
+ # Returns the query params to fetch the next page of results.
125
+ # For offset-based: `{ page: N }`.
126
+ # For seek-based: `{ last_index: "...", last_<sort_field>: "..." }`.
127
+ #
128
+ # @return [Hash, nil] params for next page, or nil if no more pages
129
+ def next_page_params
130
+ return nil unless next_page?
131
+
132
+ if seek_paginated?
133
+ last_indexes.transform_keys(&:to_sym)
134
+ else
135
+ { page: next_page_number }
136
+ end
137
+ end
138
+
139
+ # --- Direct access ---
140
+
141
+ # Access a top-level key from the raw response.
142
+ # @param key [String]
143
+ # @return [Object, nil]
144
+ def [](key)
145
+ return nil unless raw.is_a?(Hash)
146
+
147
+ raw[key]
148
+ end
149
+
150
+ # Dig into nested keys in the raw response.
151
+ # @param keys [Array<String>]
152
+ # @return [Object, nil]
153
+ def dig(*keys)
154
+ return nil unless raw.is_a?(Hash)
155
+
156
+ raw.dig(*keys)
157
+ end
158
+
159
+ # @return [Hash]
160
+ def to_h
161
+ raw.is_a?(Hash) ? raw : { 'results' => raw }
162
+ end
163
+
164
+ # @return [String] JSON representation
165
+ def to_json(*)
166
+ to_h.to_json
167
+ end
168
+
169
+ # @return [Boolean] true if HTTP status is 2xx
170
+ def success?
171
+ status >= 200 && status < 300
172
+ end
173
+
174
+ def inspect
175
+ "#<#{self.class} status=#{status} size=#{size} total_count=#{total_count.inspect} next_page?=#{next_page?}>"
176
+ end
177
+
178
+ private
179
+
180
+ def seek_next_page?
181
+ !last_indexes.empty? && !results.empty?
182
+ end
183
+ end
184
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenFec
4
+ VERSION = '0.1.0'
5
+ end
data/lib/open_fec.rb ADDED
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open_fec/version'
4
+ require 'open_fec/configuration'
5
+ require 'open_fec/error'
6
+ require 'open_fec/response'
7
+ require 'open_fec/client'
8
+ require 'open_fec/resources/base'
9
+ require 'open_fec/resources/candidates'
10
+ require 'open_fec/resources/candidate_totals'
11
+ require 'open_fec/resources/committees'
12
+ require 'open_fec/resources/contributions'
13
+ require 'open_fec/resources/contribution_aggregates'
14
+ require 'open_fec/resources/disbursements'
15
+ require 'open_fec/resources/elections'
16
+ require 'open_fec/resources/independent_expenditures'
17
+
18
+ # Ruby client for the Federal Election Commission (FEC) API.
19
+ # @see https://api.open.fec.gov/developers/
20
+ #
21
+ # @example Basic usage
22
+ # OpenFec.configure { |c| c.api_key = 'your-api-key' }
23
+ # results = OpenFec.candidates.search(name: 'Pelosi')
24
+ # totals = OpenFec.candidate_totals.for_cycle('H8CA05035', 2024)
25
+ module OpenFec
26
+ # Mutex for thread-safe initialization of configuration and client.
27
+ @mutex = Mutex.new
28
+
29
+ class << self
30
+ # Returns the current configuration instance.
31
+ # Thread-safe via Mutex.
32
+ #
33
+ # @return [OpenFec::Configuration]
34
+ def configuration
35
+ @mutex.synchronize { @configuration ||= Configuration.new }
36
+ end
37
+
38
+ # Yields the configuration instance for modification.
39
+ #
40
+ # @yieldparam config [OpenFec::Configuration]
41
+ # @return [void]
42
+ def configure
43
+ @mutex.synchronize { yield(@configuration ||= Configuration.new) }
44
+ end
45
+
46
+ # Resets configuration and client to defaults.
47
+ # Thread-safe via Mutex.
48
+ #
49
+ # @return [void]
50
+ def reset!
51
+ @mutex.synchronize do
52
+ @configuration = Configuration.new
53
+ @client = nil
54
+ end
55
+ end
56
+
57
+ # Returns the shared API client instance.
58
+ # Thread-safe via Mutex.
59
+ #
60
+ # @return [OpenFec::Client]
61
+ def client
62
+ @mutex.synchronize { @client ||= Client.new(@configuration || Configuration.new) }
63
+ end
64
+
65
+ # Resource shorthand accessors
66
+
67
+ # @return [OpenFec::Resources::Candidates]
68
+ def candidates
69
+ Resources::Candidates.new(client)
70
+ end
71
+
72
+ # @return [OpenFec::Resources::CandidateTotals]
73
+ def candidate_totals
74
+ Resources::CandidateTotals.new(client)
75
+ end
76
+
77
+ # @return [OpenFec::Resources::Committees]
78
+ def committees
79
+ Resources::Committees.new(client)
80
+ end
81
+
82
+ # @return [OpenFec::Resources::Contributions]
83
+ def contributions
84
+ Resources::Contributions.new(client)
85
+ end
86
+
87
+ # @return [OpenFec::Resources::ContributionAggregates]
88
+ def contribution_aggregates
89
+ Resources::ContributionAggregates.new(client)
90
+ end
91
+
92
+ # @return [OpenFec::Resources::Disbursements]
93
+ def disbursements
94
+ Resources::Disbursements.new(client)
95
+ end
96
+
97
+ # @return [OpenFec::Resources::Elections]
98
+ def elections
99
+ Resources::Elections.new(client)
100
+ end
101
+
102
+ # @return [OpenFec::Resources::IndependentExpenditures]
103
+ def independent_expenditures
104
+ Resources::IndependentExpenditures.new(client)
105
+ end
106
+ end
107
+ end