sam_gov_opportunities 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 2fbefa3617aa3613df90d37c3103d0b6ec4b072564473fb3fbff7f9f98f3a81c
4
+ data.tar.gz: 664d76baf5841c00ee84567572d4d29565fb2120fdc507286107d1c696448e98
5
+ SHA512:
6
+ metadata.gz: 2a6442ef54f50e41914316ae96e8d7bdfc7af9d10710b8fda8a616c72b46efac967a23d0122d44165da8fdf652314f6a81280a022bc93d4915dba9d2ac92947d
7
+ data.tar.gz: 927ff95232caf02274891da984bcef08e4ba4ad391ef797d27876dfefe06beb3ba1aafc77f621bbd1634a3ec34bcf6c3edec8e4188c87a8371eef196d63a962f
data/CHANGELOG.md ADDED
@@ -0,0 +1,11 @@
1
+ # Changelog
2
+
3
+ ## [0.1.0] - 2025-03-13
4
+
5
+ ### Added
6
+ - Initial release
7
+ - Opportunities search with required date range
8
+ - Convenience methods: recent, by_state, by_zip, by_naics, award_notices, expiring_soon
9
+ - Offset-based pagination support
10
+ - Date helpers for SAM.gov date format (MM/dd/yyyy)
11
+ - API key authentication via query parameter
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 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 all
13
+ 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 THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,270 @@
1
+ # SAM Gov Opportunities
2
+
3
+ A Ruby client for the [SAM.gov](https://api.sam.gov) Opportunities API v2. Search federal contract opportunities with filtering, pagination, and date helpers.
4
+
5
+ **API key required.** See [API Key Setup](#api-key-setup) below.
6
+
7
+ ## Installation
8
+
9
+ Add to your Gemfile:
10
+
11
+ ```ruby
12
+ gem 'sam_gov_opportunities'
13
+ ```
14
+
15
+ Then `bundle install`, or install directly:
16
+
17
+ ```bash
18
+ gem install sam_gov_opportunities
19
+ ```
20
+
21
+ ## API Key Setup
22
+
23
+ 1. Go to [sam.gov](https://sam.gov/) and sign in (or create a [login.gov](https://login.gov) account)
24
+ 2. Navigate to Profile > API Keys (or visit the Entity Information page)
25
+ 3. Request a **Public API key** (type: "Opportunities Public API")
26
+ 4. Key arrives by email within a few minutes
27
+ 5. Set the environment variable:
28
+
29
+ ```bash
30
+ export SAM_GOV_API_KEY="your-key-here"
31
+ ```
32
+
33
+ ## Quick Start
34
+
35
+ ```ruby
36
+ require 'sam_gov_opportunities'
37
+
38
+ SAMGov.configure do |config|
39
+ config.api_key = ENV['SAM_GOV_API_KEY']
40
+ end
41
+
42
+ # Search recent opportunities
43
+ results = SAMGov.opportunities.recent(days: 30)
44
+ results.each { |opp| puts opp['title'] }
45
+ ```
46
+
47
+ ## Usage
48
+
49
+ ### Search Opportunities
50
+
51
+ ```ruby
52
+ # Search by date range
53
+ response = SAMGov.opportunities.search(
54
+ posted_from: Date.new(2024, 1, 1),
55
+ posted_to: Date.new(2024, 3, 31)
56
+ )
57
+
58
+ response.results # => [{ "noticeId" => "...", "title" => "...", ... }, ...]
59
+ response.size # => 100
60
+ response.total_records # => 1234
61
+ response.next_page? # => true
62
+ ```
63
+
64
+ ### Convenience Methods
65
+
66
+ ```ruby
67
+ # Recent opportunities (last N days)
68
+ SAMGov.opportunities.recent(days: 30)
69
+
70
+ # Filter by state
71
+ SAMGov.opportunities.by_state(
72
+ state: 'VA',
73
+ posted_from: Date.today - 30,
74
+ posted_to: Date.today
75
+ )
76
+
77
+ # Filter by ZIP code
78
+ SAMGov.opportunities.by_zip(
79
+ zip: '22201',
80
+ posted_from: Date.today - 30,
81
+ posted_to: Date.today
82
+ )
83
+
84
+ # Filter by NAICS code
85
+ SAMGov.opportunities.by_naics(
86
+ naics: '541511',
87
+ posted_from: Date.today - 30,
88
+ posted_to: Date.today
89
+ )
90
+
91
+ # Award notices only
92
+ SAMGov.opportunities.award_notices(
93
+ posted_from: Date.today - 30,
94
+ posted_to: Date.today
95
+ )
96
+
97
+ # Opportunities with deadlines in the next 7 days
98
+ SAMGov.opportunities.expiring_soon(
99
+ posted_from: Date.today - 90,
100
+ posted_to: Date.today,
101
+ days: 7
102
+ )
103
+ ```
104
+
105
+ ### Date Helpers
106
+
107
+ SAM.gov requires dates in `MM/dd/yyyy` format. The `DateHelpers` module handles conversion:
108
+
109
+ ```ruby
110
+ # Format any date type
111
+ SAMGov::DateHelpers.format(Date.today) # => "03/13/2026"
112
+ SAMGov::DateHelpers.format('2024-12-25') # => "12/25/2024"
113
+
114
+ # Date range for last N days
115
+ SAMGov::DateHelpers.last_n_days(30)
116
+ # => { posted_from: "02/11/2026", posted_to: "03/13/2026" }
117
+
118
+ # Federal fiscal year boundaries (Oct 1 - Sep 30)
119
+ SAMGov::DateHelpers.fiscal_year(2025)
120
+ # => { posted_from: "10/01/2024", posted_to: "09/30/2025" }
121
+ ```
122
+
123
+ ### Pagination
124
+
125
+ Iterate page by page with `each_page`:
126
+
127
+ ```ruby
128
+ SAMGov.opportunities.each_page(
129
+ posted_from: Date.new(2024, 1, 1),
130
+ posted_to: Date.new(2024, 12, 31)
131
+ ) do |page|
132
+ page.each { |opp| process(opp) }
133
+ end
134
+ ```
135
+
136
+ Fetch all results at once:
137
+
138
+ ```ruby
139
+ all = SAMGov.opportunities.all_opportunities(
140
+ posted_from: Date.new(2024, 1, 1),
141
+ posted_to: Date.new(2024, 3, 31)
142
+ )
143
+ all.size # => total count
144
+ ```
145
+
146
+ ## Configuration
147
+
148
+ ```ruby
149
+ SAMGov.configure do |config|
150
+ config.api_key = ENV['SAM_GOV_API_KEY'] # Required
151
+ config.timeout = 60 # Request timeout in seconds (default: 30)
152
+ config.retries = 5 # Max retries on 429/5xx (default: 3)
153
+ config.logger = Logger.new($stdout) # Optional request logging
154
+ end
155
+ ```
156
+
157
+ ### Rails Initializer
158
+
159
+ ```ruby
160
+ # config/initializers/sam_gov.rb
161
+ SAMGov.configure do |config|
162
+ config.api_key = ENV['SAM_GOV_API_KEY']
163
+ config.timeout = 30
164
+ config.retries = 3
165
+ config.logger = Rails.logger if Rails.env.development?
166
+ end
167
+ ```
168
+
169
+ ### Per-Instance Client
170
+
171
+ For different configurations in the same process:
172
+
173
+ ```ruby
174
+ client = SAMGov::Client.new(custom_config)
175
+ client.opportunities.search(posted_from: Date.today - 30, posted_to: Date.today)
176
+ ```
177
+
178
+ ## Response Object
179
+
180
+ All API calls return a `SAMGov::Response`:
181
+
182
+ ```ruby
183
+ response = SAMGov.opportunities.search(
184
+ posted_from: Date.today - 30,
185
+ posted_to: Date.today
186
+ )
187
+
188
+ response.results # Array of opportunity hashes
189
+ response.each { |r| ... } # Enumerable — supports map, select, first, etc.
190
+ response.size # Results on this page
191
+ response.empty? # No results?
192
+ response.total_records # Total matching records
193
+ response.limit # Page size
194
+ response.offset # Current offset
195
+ response.current_page # Current page number (1-indexed)
196
+ response.total_pages # Total number of pages
197
+ response.next_page? # More pages available?
198
+ response.next_offset # Offset for next page
199
+ response.success? # HTTP 2xx?
200
+
201
+ response['key'] # Direct hash access
202
+ response.dig('nested', 'key')
203
+ response.raw # Raw parsed JSON body
204
+ response.to_h # Always returns a Hash
205
+ response.to_json # JSON string
206
+ ```
207
+
208
+ ## Error Handling
209
+
210
+ ```ruby
211
+ begin
212
+ SAMGov.opportunities.search(posted_from: Date.today, posted_to: Date.today)
213
+ rescue SAMGov::BadRequestError => e
214
+ puts "Bad request: #{e.status} — #{e.body}"
215
+ rescue SAMGov::NotFoundError => e
216
+ puts "Not found: #{e.status}"
217
+ rescue SAMGov::UnauthorizedError => e
218
+ puts "Unauthorized — check your API key"
219
+ rescue SAMGov::ForbiddenError => e
220
+ puts "Forbidden: #{e.body}"
221
+ rescue SAMGov::RateLimitError
222
+ puts 'Rate limited — retries exhausted'
223
+ rescue SAMGov::ServerError => e
224
+ puts "Server error: #{e.status}"
225
+ rescue SAMGov::ConnectionError => e
226
+ puts "Network issue: #{e.message}"
227
+ rescue SAMGov::MissingApiKeyError
228
+ puts 'API key not configured'
229
+ rescue SAMGov::InvalidDateError => e
230
+ puts "Date format error: #{e.message}"
231
+ end
232
+ ```
233
+
234
+ Error hierarchy:
235
+
236
+ ```
237
+ SAMGov::Error
238
+ ├── ClientError
239
+ │ ├── BadRequestError (400)
240
+ │ ├── NotFoundError (404)
241
+ │ ├── UnauthorizedError (401)
242
+ │ ├── ForbiddenError (403)
243
+ │ └── RateLimitError (429)
244
+ ├── ServerError (500+)
245
+ ├── ConnectionError (timeouts, DNS, refused)
246
+ ├── MissingApiKeyError (no API key configured)
247
+ └── InvalidDateError (bad date format/value)
248
+ ```
249
+
250
+ All `ClientError` and `ServerError` subclasses expose `.status` and `.body`.
251
+
252
+ ## Development
253
+
254
+ ```bash
255
+ git clone https://github.com/xjackk/sam_gov_opportunities.git
256
+ cd sam_gov_opportunities
257
+ bundle install
258
+
259
+ bundle exec rspec # Run tests
260
+ bundle exec rubocop # Lint
261
+ ```
262
+
263
+ ## Requirements
264
+
265
+ - Ruby >= 3.1
266
+ - Faraday ~> 2.0
267
+
268
+ ## License
269
+
270
+ MIT License. See [LICENSE.txt](LICENSE.txt).
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'faraday'
4
+ require 'faraday/retry'
5
+
6
+ module SAMGov
7
+ class Client
8
+ MAX_PAGES = 100
9
+
10
+ attr_reader :config
11
+
12
+ def initialize(config = SAMGov.configuration)
13
+ @config = config
14
+ return if config.api_key
15
+
16
+ raise MissingApiKeyError,
17
+ 'SAM.gov API key is required. Set SAM_GOV_API_KEY or configure api_key.'
18
+ end
19
+
20
+ def connection
21
+ @connection ||= Faraday.new(url: config.base_url) do |f|
22
+ f.options.timeout = config.timeout
23
+ f.options.open_timeout = 10
24
+
25
+ f.headers['User-Agent'] = "sam_gov_opportunities-rb/#{SAMGov::VERSION}"
26
+
27
+ f.request :json
28
+ f.response :json, content_type: /\bjson$/
29
+
30
+ f.request :retry,
31
+ max: config.retries,
32
+ interval: 0.5,
33
+ interval_randomness: 0.5,
34
+ backoff_factor: 2,
35
+ retry_statuses: [429, 500, 502, 503, 504],
36
+ exceptions: [
37
+ Faraday::TimeoutError,
38
+ Faraday::ConnectionFailed,
39
+ Faraday::RetriableResponse
40
+ ]
41
+
42
+ f.response :logger, config.logger if config.logger
43
+ f.adapter config.adapter
44
+ end
45
+ end
46
+
47
+ def get(params = {})
48
+ response = connection.get('', compact_params(params.merge(api_key: config.api_key)))
49
+ handle_response(response)
50
+ rescue Faraday::TimeoutError => e
51
+ raise ConnectionError, "Request timed out: #{e.message}"
52
+ rescue Faraday::ConnectionFailed => e
53
+ raise ConnectionError, "Connection failed: #{e.message}"
54
+ end
55
+
56
+ def paginate(params = {})
57
+ current_offset = params.fetch(:offset, 0)
58
+ limit = params.fetch(:limit, 100)
59
+ raise ArgumentError, 'limit must be > 0' if limit <= 0
60
+
61
+ iterations = 0
62
+ loop do
63
+ iterations += 1
64
+ response = get(params.merge(offset: current_offset, limit: limit))
65
+ yield response
66
+ break if response.empty?
67
+ break unless response.next_page?
68
+
69
+ current_offset = response.next_offset
70
+ break if iterations >= MAX_PAGES
71
+ end
72
+ end
73
+
74
+ def all(params = {})
75
+ records = []
76
+ paginate(params) { |response| records.concat(response.results) }
77
+ records
78
+ end
79
+
80
+ # Resource accessor
81
+
82
+ def opportunities
83
+ @opportunities ||= Resources::Opportunities.new(self)
84
+ end
85
+
86
+ def inspect
87
+ "#<#{self.class} base_url=#{config.base_url.inspect} timeout=#{config.timeout}>"
88
+ end
89
+
90
+ private
91
+
92
+ def compact_params(params)
93
+ params.compact.transform_keys(&:to_s)
94
+ end
95
+
96
+ def handle_response(response)
97
+ case response.status
98
+ when 200..299
99
+ Response.new(response.body, response.status)
100
+ when 400
101
+ raise BadRequestError.new(status: response.status, body: response.body)
102
+ when 401
103
+ raise UnauthorizedError.new(status: response.status, body: response.body)
104
+ when 403
105
+ raise ForbiddenError.new(status: response.status, body: response.body)
106
+ when 404
107
+ raise NotFoundError.new(status: response.status, body: response.body)
108
+ when 429
109
+ raise RateLimitError.new(status: 429, body: response.body)
110
+ when 400..499
111
+ raise ClientError.new(status: response.status, body: response.body)
112
+ when 500..599
113
+ raise ServerError.new(status: response.status, body: response.body)
114
+ else
115
+ raise Error, "Unexpected HTTP status: #{response.status}"
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SAMGov
4
+ class Configuration
5
+ DEFAULT_BASE_URL = 'https://api.sam.gov/opportunities/v2/search'
6
+ DEFAULT_TIMEOUT = 30
7
+ DEFAULT_RETRIES = 3
8
+
9
+ attr_accessor :base_url, :timeout, :retries, :logger, :adapter, :api_key
10
+
11
+ def initialize
12
+ @base_url = DEFAULT_BASE_URL
13
+ @timeout = DEFAULT_TIMEOUT
14
+ @retries = DEFAULT_RETRIES
15
+ @logger = nil
16
+ @adapter = Faraday.default_adapter
17
+ @api_key = ENV.fetch('SAM_GOV_API_KEY', nil)
18
+ end
19
+
20
+ def to_h
21
+ {
22
+ base_url: base_url,
23
+ timeout: timeout,
24
+ retries: retries,
25
+ logger: logger,
26
+ adapter: adapter,
27
+ api_key: api_key
28
+ }
29
+ end
30
+
31
+ def inspect
32
+ "#<#{self.class} base_url=#{base_url.inspect} timeout=#{timeout} retries=#{retries}>"
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'date'
4
+
5
+ module SAMGov
6
+ module DateHelpers
7
+ module_function
8
+
9
+ def format(date)
10
+ case date
11
+ when Date, Time
12
+ date.strftime('%m/%d/%Y')
13
+ when String
14
+ parsed = Date.parse(date)
15
+ parsed.strftime('%m/%d/%Y')
16
+ else
17
+ raise InvalidDateError, "Cannot format #{date.class} as date"
18
+ end
19
+ rescue ArgumentError => e
20
+ raise InvalidDateError, e.message
21
+ end
22
+
23
+ def last_n_days(days = 30)
24
+ raise InvalidDateError, 'days must be <= 365' if days > 365
25
+ raise InvalidDateError, 'days must be positive' if days <= 0
26
+
27
+ today = Date.today
28
+ from_date = today - days
29
+ { posted_from: format(from_date), posted_to: format(today) }
30
+ end
31
+
32
+ def fiscal_year(year)
33
+ raise InvalidDateError, 'year must be >= 2' if year < 2
34
+
35
+ from_date = Date.new(year - 1, 10, 1)
36
+ to_date = Date.new(year, 9, 30)
37
+ { posted_from: format(from_date), posted_to: format(to_date) }
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SAMGov
4
+ class Error < StandardError; end
5
+
6
+ class ClientError < Error
7
+ attr_reader :status, :body
8
+
9
+ def initialize(message = nil, status: nil, body: nil)
10
+ @status = status
11
+ @body = body
12
+ super(message || "HTTP #{status}: #{body}")
13
+ end
14
+ end
15
+
16
+ class BadRequestError < ClientError; end
17
+ class NotFoundError < ClientError; end
18
+ class UnauthorizedError < ClientError; end
19
+ class ForbiddenError < ClientError; end
20
+ class RateLimitError < ClientError; end
21
+
22
+ class ServerError < Error
23
+ attr_reader :status, :body
24
+
25
+ def initialize(message = nil, status: nil, body: nil)
26
+ @status = status
27
+ @body = body
28
+ super(message || "Server error HTTP #{status}")
29
+ end
30
+ end
31
+
32
+ class ConnectionError < Error; end
33
+ class MissingApiKeyError < Error; end
34
+ class InvalidDateError < Error; end
35
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SAMGov
4
+ module Resources
5
+ class Base
6
+ def initialize(client = SAMGov.client)
7
+ @client = client
8
+ end
9
+
10
+ private
11
+
12
+ attr_reader :client
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SAMGov
4
+ module Resources
5
+ class Opportunities < Base
6
+ def search(posted_from:, posted_to:, **params)
7
+ client.get(
8
+ params.merge(
9
+ postedFrom: DateHelpers.format(posted_from),
10
+ postedTo: DateHelpers.format(posted_to)
11
+ )
12
+ )
13
+ end
14
+
15
+ def recent(days: 30, **params)
16
+ dates = DateHelpers.last_n_days(days)
17
+ client.get(
18
+ params.merge(
19
+ postedFrom: dates[:posted_from],
20
+ postedTo: dates[:posted_to]
21
+ )
22
+ )
23
+ end
24
+
25
+ def by_state(state:, posted_from:, posted_to:, **params)
26
+ search(posted_from: posted_from, posted_to: posted_to, state: state, **params)
27
+ end
28
+
29
+ def by_zip(zip:, posted_from:, posted_to:, **params)
30
+ search(posted_from: posted_from, posted_to: posted_to, zipcode: zip, **params)
31
+ end
32
+
33
+ def by_naics(naics:, posted_from:, posted_to:, **params)
34
+ search(posted_from: posted_from, posted_to: posted_to, naics: naics, **params)
35
+ end
36
+
37
+ def award_notices(posted_from:, posted_to:, **params)
38
+ search(posted_from: posted_from, posted_to: posted_to, ptype: 'a', **params)
39
+ end
40
+
41
+ def expiring_soon(posted_from:, posted_to:, days: 7, **params)
42
+ today = Date.today
43
+ deadline = today + days
44
+ search(
45
+ posted_from: posted_from,
46
+ posted_to: posted_to,
47
+ rdlfrom: DateHelpers.format(today),
48
+ rdlto: DateHelpers.format(deadline),
49
+ **params
50
+ )
51
+ end
52
+
53
+ def each_page(posted_from:, posted_to:, **params, &block)
54
+ search_params = params.merge(
55
+ postedFrom: DateHelpers.format(posted_from),
56
+ postedTo: DateHelpers.format(posted_to)
57
+ )
58
+ client.paginate(search_params, &block)
59
+ end
60
+
61
+ def all_opportunities(posted_from:, posted_to:, **params)
62
+ search_params = params.merge(
63
+ postedFrom: DateHelpers.format(posted_from),
64
+ postedTo: DateHelpers.format(posted_to)
65
+ )
66
+ client.all(search_params)
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SAMGov
4
+ class Response
5
+ include Enumerable
6
+
7
+ attr_reader :raw, :status
8
+
9
+ def initialize(body, status)
10
+ @raw = body
11
+ @status = status
12
+ end
13
+
14
+ def each(&)
15
+ results.each(&)
16
+ end
17
+
18
+ def results
19
+ return raw if raw.is_a?(Array)
20
+ return [] unless raw.is_a?(Hash)
21
+
22
+ raw['opportunitiesData'] || []
23
+ end
24
+
25
+ def size
26
+ results.size
27
+ end
28
+ alias length size
29
+
30
+ def empty?
31
+ results.empty?
32
+ end
33
+
34
+ def total_records
35
+ return nil unless raw.is_a?(Hash)
36
+
37
+ raw['totalRecords']
38
+ end
39
+
40
+ def limit
41
+ return nil unless raw.is_a?(Hash)
42
+
43
+ raw['limit']
44
+ end
45
+
46
+ def offset
47
+ return nil unless raw.is_a?(Hash)
48
+
49
+ raw['offset']
50
+ end
51
+
52
+ def next_page?
53
+ return false unless raw.is_a?(Hash)
54
+ return false unless total_records && offset && limit
55
+
56
+ (offset + limit) < total_records
57
+ end
58
+
59
+ def next_offset
60
+ return nil unless next_page?
61
+
62
+ offset + limit
63
+ end
64
+
65
+ def current_page
66
+ return nil unless offset && limit&.positive?
67
+
68
+ (offset / limit) + 1
69
+ end
70
+
71
+ def total_pages
72
+ return nil unless total_records && limit&.positive?
73
+
74
+ (total_records.to_f / limit).ceil
75
+ end
76
+
77
+ def [](key)
78
+ return nil unless raw.is_a?(Hash)
79
+
80
+ raw[key]
81
+ end
82
+
83
+ def dig(*keys)
84
+ return nil unless raw.is_a?(Hash)
85
+
86
+ raw.dig(*keys)
87
+ end
88
+
89
+ def to_h
90
+ raw.is_a?(Hash) ? raw : { 'opportunitiesData' => raw }
91
+ end
92
+
93
+ def to_json(*)
94
+ to_h.to_json
95
+ end
96
+
97
+ def success?
98
+ status >= 200 && status < 300
99
+ end
100
+
101
+ def inspect
102
+ "#<#{self.class} status=#{status} size=#{size} total_records=#{total_records.inspect} next_page?=#{next_page?}>"
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SAMGov
4
+ VERSION = '0.1.0'
5
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'sam_gov_opportunities/version'
4
+ require 'sam_gov_opportunities/configuration'
5
+ require 'sam_gov_opportunities/error'
6
+ require 'sam_gov_opportunities/date_helpers'
7
+ require 'sam_gov_opportunities/response'
8
+ require 'sam_gov_opportunities/client'
9
+ require 'sam_gov_opportunities/resources/base'
10
+ require 'sam_gov_opportunities/resources/opportunities'
11
+
12
+ module SAMGov
13
+ @mutex = Mutex.new
14
+
15
+ class << self
16
+ def configuration
17
+ @mutex.synchronize { @configuration ||= Configuration.new }
18
+ end
19
+
20
+ def configure
21
+ @mutex.synchronize { yield(@configuration ||= Configuration.new) }
22
+ end
23
+
24
+ def reset!
25
+ @mutex.synchronize do
26
+ @configuration = Configuration.new
27
+ @client = nil
28
+ end
29
+ end
30
+
31
+ def client
32
+ @mutex.synchronize { @client ||= Client.new(@configuration || Configuration.new) }
33
+ end
34
+
35
+ def opportunities
36
+ Resources::Opportunities.new(client)
37
+ end
38
+ end
39
+ end
metadata ADDED
@@ -0,0 +1,174 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: sam_gov_opportunities
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Jack Killilea
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-03-14 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: faraday
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '2.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '2.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: faraday-retry
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '2.0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '2.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rspec
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '3.13'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '3.13'
55
+ - !ruby/object:Gem::Dependency
56
+ name: vcr
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '6.2'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '6.2'
69
+ - !ruby/object:Gem::Dependency
70
+ name: webmock
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '3.23'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '3.23'
83
+ - !ruby/object:Gem::Dependency
84
+ name: rubocop
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '1.65'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '1.65'
97
+ - !ruby/object:Gem::Dependency
98
+ name: rubocop-rspec
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - "~>"
102
+ - !ruby/object:Gem::Version
103
+ version: '3.0'
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - "~>"
109
+ - !ruby/object:Gem::Version
110
+ version: '3.0'
111
+ - !ruby/object:Gem::Dependency
112
+ name: simplecov
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - "~>"
116
+ - !ruby/object:Gem::Version
117
+ version: '0.22'
118
+ type: :development
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - "~>"
123
+ - !ruby/object:Gem::Version
124
+ version: '0.22'
125
+ description: |
126
+ A Ruby client for the SAM.gov Opportunities API v2. Search federal contract
127
+ opportunities, filter by NAICS code, state, zip code, and more.
128
+ email:
129
+ - xkillilea@gmail.com
130
+ executables: []
131
+ extensions: []
132
+ extra_rdoc_files: []
133
+ files:
134
+ - CHANGELOG.md
135
+ - LICENSE.txt
136
+ - README.md
137
+ - lib/sam_gov_opportunities.rb
138
+ - lib/sam_gov_opportunities/client.rb
139
+ - lib/sam_gov_opportunities/configuration.rb
140
+ - lib/sam_gov_opportunities/date_helpers.rb
141
+ - lib/sam_gov_opportunities/error.rb
142
+ - lib/sam_gov_opportunities/resources/base.rb
143
+ - lib/sam_gov_opportunities/resources/opportunities.rb
144
+ - lib/sam_gov_opportunities/response.rb
145
+ - lib/sam_gov_opportunities/version.rb
146
+ homepage: https://github.com/xjackk/sam_gov_opportunities
147
+ licenses:
148
+ - MIT
149
+ metadata:
150
+ homepage_uri: https://github.com/xjackk/sam_gov_opportunities
151
+ source_code_uri: https://github.com/xjackk/sam_gov_opportunities/tree/main/lib
152
+ changelog_uri: https://github.com/xjackk/sam_gov_opportunities/blob/main/CHANGELOG.md
153
+ bug_tracker_uri: https://github.com/xjackk/sam_gov_opportunities/issues
154
+ rubygems_mfa_required: 'true'
155
+ post_install_message:
156
+ rdoc_options: []
157
+ require_paths:
158
+ - lib
159
+ required_ruby_version: !ruby/object:Gem::Requirement
160
+ requirements:
161
+ - - ">="
162
+ - !ruby/object:Gem::Version
163
+ version: 3.1.0
164
+ required_rubygems_version: !ruby/object:Gem::Requirement
165
+ requirements:
166
+ - - ">="
167
+ - !ruby/object:Gem::Version
168
+ version: '0'
169
+ requirements: []
170
+ rubygems_version: 3.5.11
171
+ signing_key:
172
+ specification_version: 4
173
+ summary: Ruby client for the SAM.gov Opportunities API
174
+ test_files: []