craigslist-api 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.
Files changed (37) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +53 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +405 -0
  5. data/lib/craigslist/api/access_token.rb +68 -0
  6. data/lib/craigslist/api/area.rb +98 -0
  7. data/lib/craigslist/api/bulk_transport.rb +94 -0
  8. data/lib/craigslist/api/category.rb +47 -0
  9. data/lib/craigslist/api/client.rb +207 -0
  10. data/lib/craigslist/api/configuration.rb +130 -0
  11. data/lib/craigslist/api/connection.rb +54 -0
  12. data/lib/craigslist/api/credit_summary.rb +39 -0
  13. data/lib/craigslist/api/envelope.rb +65 -0
  14. data/lib/craigslist/api/errors.rb +82 -0
  15. data/lib/craigslist/api/image.rb +102 -0
  16. data/lib/craigslist/api/image_info.rb +53 -0
  17. data/lib/craigslist/api/json_transport.rb +153 -0
  18. data/lib/craigslist/api/money.rb +77 -0
  19. data/lib/craigslist/api/posting.rb +216 -0
  20. data/lib/craigslist/api/posting_block.rb +44 -0
  21. data/lib/craigslist/api/posting_handle.rb +142 -0
  22. data/lib/craigslist/api/posting_stats.rb +85 -0
  23. data/lib/craigslist/api/reference.rb +84 -0
  24. data/lib/craigslist/api/resources/account.rb +73 -0
  25. data/lib/craigslist/api/resources/base.rb +45 -0
  26. data/lib/craigslist/api/resources/billing.rb +47 -0
  27. data/lib/craigslist/api/resources/images.rb +104 -0
  28. data/lib/craigslist/api/resources/postings.rb +95 -0
  29. data/lib/craigslist/api/response_parser.rb +91 -0
  30. data/lib/craigslist/api/result.rb +123 -0
  31. data/lib/craigslist/api/result_set.rb +96 -0
  32. data/lib/craigslist/api/serializer.rb +177 -0
  33. data/lib/craigslist/api/token_provider.rb +80 -0
  34. data/lib/craigslist/api/version.rb +8 -0
  35. data/lib/craigslist/api/zip_location.rb +80 -0
  36. data/lib/craigslist/api.rb +63 -0
  37. metadata +127 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: f9a564a5e0b54cd65ef0712f045d2fc7aee5cf9c321e7c5cfa6d00112bb91352
4
+ data.tar.gz: 7c456966c241b46bff3d55d3175c39b823e149bfd39d4d7522b7673a302190ae
5
+ SHA512:
6
+ metadata.gz: f39a965ba62a0c0c1b0854b4e67f56d4c61ba03265ec3544d29d9f8b3672cf2b7cfec9fce6a8a5407002d0707e7e22a099a9973d0d66aed1fefdb1ac4285f629
7
+ data.tar.gz: 797c855211e745fc1aa3830af823707fbaae3f60752bf2dfb00d4b7ac6034d9e401d6d05d4ba6a0d74b41aac2d326d661a0cd8663ff8ec55619882f46517fcf9
data/CHANGELOG.md ADDED
@@ -0,0 +1,53 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here.
4
+
5
+ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ While the version is below 1.0.0 the public API may change between minor
9
+ versions. The gem wraps an API that cannot be exercised without access granted
10
+ by Craigslist, so real-world use may surface corrections.
11
+
12
+ ## [Unreleased]
13
+
14
+ ## [0.1.0] - 2026-08-17
15
+
16
+ Initial release.
17
+
18
+ ### Added
19
+
20
+ - `Craigslist::API::Client`, a single client covering both halves of the bulk
21
+ posting platform: the RSS interface used to create postings and the JSON
22
+ Bulkpost API used to manage them afterwards.
23
+ - Posting creation via `#validate` and `#post`, returning a `ResultSet` of
24
+ per-posting outcomes rather than raising, since a bulk submission succeeds
25
+ and fails per item.
26
+ - `Posting` and `Image` models with local validation, covering the newer flat
27
+ attribute groups (`housing_basics`, `job_basics`, `auto_basics`, `forsale`,
28
+ `generic`, `housing_terms`, `housing_pets`) and `brokerInfo`.
29
+ - `PostingHandle` for live postings, via `client.posting(id)`: status, body,
30
+ price, remuneration, delete/undelete, images, and statistics.
31
+ - Resource groups for postings, images, billing, and account.
32
+ - Areas and categories from the public reference service, memoized per client.
33
+ - OAuth2 client credentials flow with automatic token caching and refresh,
34
+ including a single retry on a 401.
35
+ - Value objects for `Money`, `CreditSummary`, `PostingBlock`, `PostingStats`,
36
+ `ImageInfo`, `Area`, and `Category`.
37
+ - An error hierarchy rooted at `Craigslist::API::Error`, including `APIError`
38
+ for the case where an HTTP 200 carries a populated `errors` array.
39
+ - `ZipLocation`, returned by `#area_for_zip`. Carries the subarea, which a
40
+ flat return value would discard -- and a missing subarea is the most common
41
+ cause of a `NOT_VALID` posting. `#to_h` splats straight into `Posting.new`.
42
+ - `ResultSet#raw`, the unparsed response body, so an unexplained rejection can
43
+ be inspected without rebuilding the request by hand.
44
+
45
+ ### Notes
46
+
47
+ Verified against a live bulk posting account: postings created and removed in
48
+ `pit`/`ctd`, plus management, billing, image and error paths. Two corrections
49
+ came out of that run, both folded in above -- the zip lookup shape, and the
50
+ missing subarea in the smoke-test example.
51
+
52
+ [Unreleased]: https://github.com/biggeektx/craigslist-api-gem/compare/v0.1.0...HEAD
53
+ [0.1.0]: https://github.com/biggeektx/craigslist-api-gem/releases/tag/v0.1.0
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Nick Chewning
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,405 @@
1
+ # craigslist-api
2
+
3
+ [![CI](https://github.com/biggeektx/craigslist-api-gem/actions/workflows/ci.yml/badge.svg)](https://github.com/biggeektx/craigslist-api-gem/actions/workflows/ci.yml)
4
+
5
+ A Ruby client for Craigslist's bulk posting platform.
6
+
7
+ Craigslist splits this platform across two services that look nothing alike:
8
+
9
+ | | Creating postings | Managing them afterwards |
10
+ |---|---|---|
11
+ | Service | `post.craigslist.org/bulk-rss` | `bapi.craigslist.org/bulkpost/v1` |
12
+ | Format | RDF/RSS XML | JSON (writes are form-encoded) |
13
+ | Auth | credentials inside the XML body | OAuth2 bearer token |
14
+
15
+ One set of credentials covers both, and neither can do the other's job — you
16
+ cannot create a posting over the JSON API, and you cannot edit one over RSS.
17
+ This gem hides that seam behind a single client.
18
+
19
+ ```ruby
20
+ client = Craigslist::API.new(email: ..., password: ..., account_id: ...)
21
+ results = client.post(postings) # RSS under the hood
22
+ client.posting(results.posting_ids.first).price = 4200 # JSON under the hood
23
+ ```
24
+
25
+ ## Before you start
26
+
27
+ **Bulk posting access is not self-serve.** Craigslist grants it case by case to
28
+ high-volume posters (hundreds of postings per month), and only for paid US
29
+ categories: jobs (offered), apartment rentals in NYC, and for-sale-by-dealer.
30
+ Contact them at 415-399-5200 x8283.
31
+
32
+ Without a granted account, every call in this README will return HTTP 403.
33
+
34
+ ## Installation
35
+
36
+ ```ruby
37
+ gem "craigslist-api"
38
+ ```
39
+
40
+ Then `bundle install`. Requires Ruby 3.3 or newer.
41
+
42
+ ## Quick start
43
+
44
+ ```ruby
45
+ require "craigslist/api"
46
+
47
+ client = Craigslist::API.new(
48
+ email: "you@example.com",
49
+ password: ENV.fetch("CRAIGSLIST_PASSWORD"),
50
+ account_id: 1234
51
+ )
52
+
53
+ posting = Craigslist::API::Posting.new(
54
+ key: "listing-1", # your own identifier for this posting
55
+ title: "1998 Toyota Hilux",
56
+ description: "Runs great. Highway miles.",
57
+ category: "ctd", # cars & trucks - by dealer
58
+ area: "sfo",
59
+ price: 4500,
60
+ reply_email: "sales@example.com",
61
+ location: {postal: "94110"}
62
+ )
63
+
64
+ # Dry run first. This sends the identical document that #post would.
65
+ check = client.validate(posting)
66
+ abort check.failed.map(&:explanation).join("\n") if check.any_failures?
67
+
68
+ results = client.post(posting)
69
+ results.posting_ids #=> ["7123456780"]
70
+ ```
71
+
72
+ ## Creating postings
73
+
74
+ ### Results, not exceptions
75
+
76
+ A bulk submission succeeds and fails *per posting*, and the HTTP status tells
77
+ you nothing about it — a 200 can carry a document in which every posting
78
+ failed. So `#post` and `#validate` return a `ResultSet` rather than raising.
79
+ A batch with a few rejections is ordinary, not exceptional.
80
+
81
+ ```ruby
82
+ results = client.post(postings)
83
+
84
+ results.all_successful? #=> false
85
+ results.successful #=> [Result, ...]
86
+ results.failed #=> [Result, ...]
87
+ results.posting_ids #=> ids of postings that were created
88
+ results.upload_id #=> craigslist's id for the whole batch
89
+ results["listing-1"] #=> the Result for the key you submitted
90
+
91
+ results.failed.each do |result|
92
+ warn "#{result.key}: #{result.status} — #{result.explanation}"
93
+ end
94
+ ```
95
+
96
+ Transport and authentication failures *do* raise. See [Errors](#errors).
97
+
98
+ When a rejection is opaque, the unparsed response is always on hand:
99
+
100
+ ```ruby
101
+ puts results.raw # the RSS document exactly as craigslist sent it
102
+ ```
103
+
104
+ Each `Result` answers the status it came back with:
105
+
106
+ | Predicate | Status | Meaning |
107
+ |---|---|---|
108
+ | `valid?` | `VALID` | passed validation (validate mode only) |
109
+ | `posted?` | `POSTED` | accepted (post mode only) |
110
+ | `not_valid?` | `NOT_VALID` | rejected; see `explanation` |
111
+ | `insufficient_blocks?` | `INSUFFICIENT_BLOCKS` | out of prepaid blocks for this area/category |
112
+ | `credit_limit_reached?` | `CREDIT_LIMIT_REACHED` | invoiced account hit its limit |
113
+ | `credit_card_error?` | `CREDIT_CARD_ERROR` | card could not be billed |
114
+ | — | `FAILED` | unexpected error at post time |
115
+
116
+ `success?` covers `VALID` and `POSTED`; `failure?` is everything else.
117
+
118
+ ### Don't skip the warnings
119
+
120
+ Craigslist returns non-fatal warnings — usually XML it had to paper over —
121
+ **alongside successes**, which makes them easy to miss entirely:
122
+
123
+ ```ruby
124
+ results.warned.each do |result|
125
+ warn "#{result.key} posted with warnings: #{result.warnings.join(", ")}"
126
+ end
127
+ ```
128
+
129
+ ### Posting fields
130
+
131
+ Only `key`, `title`, `description`, `category`, `area` and a location are
132
+ required. A location means either `:postal`, or both `:latitude` and
133
+ `:longitude`.
134
+
135
+ > **Subarea is required in any area that has subareas**, and omitting it is the
136
+ > most common reason a posting comes back `NOT_VALID`. `sfo` has six; `pit` has
137
+ > none. Let the ZIP lookup tell you rather than guessing:
138
+ >
139
+ > ```ruby
140
+ > place = client.area_for_zip("94110") #=> area "sfo", subarea "sfc"
141
+ > Craigslist::API::Posting.new(**place.to_h, key: ..., title: ...)
142
+ > ```
143
+ >
144
+ > Or check directly with `client.reference.area("sfo").subareas?`.
145
+
146
+ ```ruby
147
+ Craigslist::API::Posting.new(
148
+ key: "listing-2",
149
+ title: "1BR in Chelsea",
150
+ description: "Sunny, quiet block.",
151
+ category: "apa",
152
+ area: "nyc",
153
+ subarea: "mnh", # required where an area has subareas
154
+ neighborhood: "Chelsea",
155
+ price: 4875,
156
+ po_number: "PO-094122", # your own tracking reference
157
+ reply_email: "leasing@example.com",
158
+ reply_privacy: :anonymous, # :none | :anonymous | :public
159
+ other_contact_info: "212.555.1212",
160
+
161
+ location: {
162
+ postal: "10011", city: "New York", state: "NY",
163
+ cross_street1: "23rd Street", cross_street2: "9th Avenue",
164
+ latitude: 40.746492, longitude: -74.001326
165
+ },
166
+
167
+ housing_basics: {bedrooms: 1, bathrooms: 1, surface_area: 850,
168
+ housing_type: "apartment", laundry: "w/d in unit",
169
+ is_furnished: false},
170
+ housing_terms: {rent_period: "monthly", broker_fee: true},
171
+ housing_pets: {pets_cat: true, pets_dog: false},
172
+ broker: {company_name: "Sample & Associates", fee_disclosure: "One month"},
173
+
174
+ images: ["photos/living-room.jpg", "photos/kitchen.jpg"]
175
+ )
176
+ ```
177
+
178
+ Booleans are written as the `0`/`1` the interface expects — pass `true`/`false`
179
+ and the gem translates.
180
+
181
+ The attribute groups map straight onto the interface's newer flat field sets:
182
+ `generic`, `housing_basics`, `housing_pets`, `housing_terms`, `job_basics`,
183
+ `auto_basics`, `forsale`, plus `broker` for `cl:brokerInfo`. Their keys pass
184
+ through unchanged, so anything Craigslist documents for a group works without
185
+ this gem needing to know about it.
186
+
187
+ Postings are validated on construction and raise `ValidationError` listing
188
+ *every* problem, not just the first:
189
+
190
+ ```ruby
191
+ Craigslist::API::Posting.new(key: "x", title: "", description: "b",
192
+ category: "", area: "sfo", location: {})
193
+ # => Craigslist::API::ValidationError:
194
+ # title is required; category is required;
195
+ # location requires :postal, or both :latitude and :longitude
196
+ ```
197
+
198
+ ### Images
199
+
200
+ Up to 24 per posting, sent inline as base64. Position is zero-based and
201
+ position 0 is the image featured on search pages.
202
+
203
+ ```ruby
204
+ images: [
205
+ "photos/front.jpg", # path
206
+ Pathname("photos/side.jpg"), # Pathname
207
+ File.open("photos/rear.jpg", "rb"), # any IO
208
+ Craigslist::API::Image.from_base64(encoded, position: 5)
209
+ ]
210
+ ```
211
+
212
+ Positions are assigned in order when you don't set them. Raw base64 must go
213
+ through `Image.from_base64` — guessing whether a String is a path or a payload
214
+ would be worse than asking.
215
+
216
+ > Bulk submissions with many images get large fast: 24 images at 200KB each is
217
+ > roughly 6MB of base64 *per posting*. Submit in modest batches.
218
+
219
+ ## Managing live postings
220
+
221
+ `client.posting(id)` returns a handle. It caches nothing, so it can never go
222
+ stale:
223
+
224
+ ```ruby
225
+ posting = client.posting("7123456780")
226
+
227
+ posting.status #=> "active" | "deleted" | "expired" | "pending" | "removed"
228
+ posting.active? #=> true
229
+
230
+ posting.body #=> "The posting body"
231
+ posting.body = "New text"
232
+ posting.price = 4200
233
+ posting.remuneration = "$19.95/hr plus tips" # jobs and gigs
234
+
235
+ posting.images #=> [ImageInfo, ...]
236
+ posting.add_image("new.jpg")
237
+ posting.add_image("hero.jpg", insert_position: 0)
238
+ posting.reorder_images(%w[4:00202_x 4:00101_y])
239
+ posting.remove_image("4:00101_y")
240
+
241
+ posting.stats.total_views #=> 1_284
242
+
243
+ posting.delete
244
+ posting.undelete
245
+ ```
246
+
247
+ Two API quirks worth knowing: postings in `ctd` (cars & trucks by dealer) must
248
+ keep the VIN they were created with, and removing an image only *detaches* it —
249
+ the image itself stays publicly retrievable.
250
+
251
+ The same operations are available grouped by resource if you prefer:
252
+ `client.postings`, `client.images`, `client.billing`, `client.account`.
253
+
254
+ ## Billing, stats, and reference data
255
+
256
+ ```ruby
257
+ credit = client.credit
258
+ credit.remaining.to_s #=> "3.00 USD"
259
+ credit.remaining.to_f #=> 3.0
260
+
261
+ client.posting_blocks.each do |block|
262
+ puts "#{block.area} #{block.product_class}: #{block.remaining_posts} left"
263
+ end
264
+
265
+ client.pricing(area: "sfo", category: "ofc").to_s #=> "1000.00 USD"
266
+ client.billing.create_invoice #=> ["1234321"]
267
+
268
+ place = client.area_for_zip("02134")
269
+ place.area #=> "bos"
270
+ place.subarea #=> "gbs"
271
+ place.to_h #=> {area: "bos", subarea: "gbs"}
272
+ ```
273
+
274
+ Money keeps Craigslist's minor-unit representation and converts through
275
+ `Rational`, so scaling is exact.
276
+
277
+ Statistics cover a rolling 30-day window ending midnight UTC yesterday, and
278
+ count requests rather than unique viewers:
279
+
280
+ ```ruby
281
+ stats = client.stats(start: "2026-01-01", stop: "2026-01-31")
282
+
283
+ stats.each do |posting|
284
+ puts "#{posting.posting_id}: #{posting.total_views} views, #{posting.total_contact} contacts"
285
+ end
286
+
287
+ stats.first[:impressions] #=> [[2026-01-01 00:00:00 UTC, 57], ...]
288
+ ```
289
+
290
+ Metrics: `impressions`, `views`, `contact`, `contact_chat`, `contact_phone`,
291
+ `contact_email`, `share`, `favorite`.
292
+
293
+ Areas and categories come from Craigslist's public reference service (no auth
294
+ needed) and are memoized per client:
295
+
296
+ ```ruby
297
+ client.reference.area("sfo").subareas.map(&:abbreviation) #=> ["sfc", "sby", ...]
298
+ client.reference.area("sfo").subareas? #=> true
299
+ client.reference.category("ctd").description #=> "cars & trucks - by dealer"
300
+ client.reference.category("prk").unsupported? #=> true
301
+ ```
302
+
303
+ ### Account messages
304
+
305
+ Craigslist attaches notices to every JSON response and repeats them until
306
+ acknowledged. Surface them somewhere a human will look:
307
+
308
+ ```ruby
309
+ client.account_messages.each do |message|
310
+ logger.info("craigslist: #{message["message"]}")
311
+ client.account.acknowledge(message["messageId"])
312
+ end
313
+ ```
314
+
315
+ ## Errors
316
+
317
+ Everything raised derives from `Craigslist::API::Error`.
318
+
319
+ ```
320
+ Error
321
+ ├── ConfigurationError missing or blank credentials
322
+ ├── ValidationError caught locally, before any request; carries #errors
323
+ ├── ConnectionError DNS, refused, TLS
324
+ │ └── TimeoutError
325
+ ├── ParseError response was not the XML/JSON it promised
326
+ └── ResponseError carries #status, #body, #api_errors
327
+ ├── RequestError 400, or 415 for unparseable RSS
328
+ ├── AuthenticationError 401/403, or a rejected token
329
+ ├── NotFoundError 404
330
+ ├── RateLimitError 429
331
+ ├── ServerError 5xx
332
+ └── APIError HTTP 200 with a populated errors array
333
+ ```
334
+
335
+ `APIError` exists because the JSON API reports application-level failures
336
+ in-band. A 200 is not proof of success:
337
+
338
+ ```ruby
339
+ begin
340
+ client.posting("7123456780").body = "updated"
341
+ rescue Craigslist::API::APIError => e
342
+ e.api_errors #=> [{"code" => 0, "message" => "posting 7123456780 not found"}]
343
+ end
344
+ ```
345
+
346
+ ## Configuration
347
+
348
+ ```ruby
349
+ Craigslist::API.new(
350
+ email: "you@example.com",
351
+ password: ENV.fetch("CRAIGSLIST_PASSWORD"),
352
+ account_id: 1234,
353
+
354
+ timeout: 120, # read timeout; generous, submissions can be large
355
+ open_timeout: 15,
356
+ logger: Rails.logger, # logs request lines via Faraday
357
+ adapter: :net_http, # any Faraday adapter
358
+ scopes: %w[bulkpost.posting bulkpost.account.billing],
359
+ user_agent: "my-app/1.0"
360
+ )
361
+ ```
362
+
363
+ Scopes are hierarchical — `bulkpost.posting` grants `bulkpost.posting.delete`
364
+ and the rest beneath it. The default requests all four top-level scopes.
365
+
366
+ ### Threads and multiple accounts
367
+
368
+ Configuration is frozen and nothing mutates at request time, so a client is
369
+ safe to share across threads. The OAuth token is cached per client behind a
370
+ mutex and refreshed automatically, including once on a 401 in case it was
371
+ revoked early.
372
+
373
+ There is no global to configure. Talking to several accounts means building
374
+ several clients, which is deliberate — ambient credentials are exactly the
375
+ thing that breaks when one process serves more than one account.
376
+
377
+ ```ruby
378
+ CLIENTS = accounts.to_h { |a| [a.id, Craigslist::API.new(**a.credentials)] }
379
+ ```
380
+
381
+ Passwords and tokens are redacted from `inspect` output, so a stray `inspect`
382
+ in a log line or exception backtrace will not leak them.
383
+
384
+ ## Development
385
+
386
+ ```bash
387
+ bin/setup # install dependencies
388
+ bundle exec rake # specs + linter
389
+ bundle exec rspec
390
+ bundle exec standardrb --fix
391
+ bin/console # IRB with the gem loaded
392
+ ```
393
+
394
+ The suite is fully offline — WebMock blocks net connections outright. Response
395
+ parsing is tested against fixtures transcribed from Craigslist's own published
396
+ samples.
397
+
398
+ ## Contributing
399
+
400
+ Bug reports and pull requests are welcome at
401
+ <https://github.com/biggeektx/craigslist-api-gem>.
402
+
403
+ ## License
404
+
405
+ Released under the [MIT License](LICENSE.txt).
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Craigslist
4
+ module API
5
+ # An OAuth2 bearer token with an expiry.
6
+ #
7
+ # Immutable. {TokenProvider} discards and re-fetches rather than mutating.
8
+ class AccessToken
9
+ # Treat a token as expired this many seconds early, so a token does not
10
+ # die in flight between the expiry check and the server receiving it.
11
+ LEEWAY = 60
12
+
13
+ # @return [String] the raw token value
14
+ attr_reader :value
15
+
16
+ # @return [Time] the moment the token stops being valid
17
+ attr_reader :expires_at
18
+
19
+ # @return [Array<String>] scopes the token was granted
20
+ attr_reader :scopes
21
+
22
+ # @return [String] usually "Bearer"
23
+ attr_reader :token_type
24
+
25
+ def initialize(value:, expires_in:, scopes: [], token_type: "Bearer", now: Time.now)
26
+ @value = value
27
+ @expires_at = now + expires_in.to_i
28
+ @scopes = Array(scopes).flat_map { |s| s.to_s.split(/\s+/) }.freeze
29
+ @token_type = token_type || "Bearer"
30
+ freeze
31
+ end
32
+
33
+ # Builds a token from the token endpoint's JSON payload.
34
+ #
35
+ # @param payload [Hash]
36
+ # @return [AccessToken]
37
+ # @raise [AuthenticationError] if the payload carries no token
38
+ def self.from_payload(payload, now: Time.now)
39
+ value = payload["access_token"]
40
+ raise AuthenticationError, "token endpoint returned no access_token" if value.nil? || value.empty?
41
+
42
+ new(
43
+ value: value,
44
+ expires_in: payload.fetch("expires_in", 3600),
45
+ scopes: payload["scopes"] || payload["scope"] || [],
46
+ token_type: payload["token_type"],
47
+ now: now
48
+ )
49
+ end
50
+
51
+ # @return [Boolean] whether the token is expired, or close enough to it
52
+ def expired?(now: Time.now)
53
+ now >= (expires_at - LEEWAY)
54
+ end
55
+
56
+ # @return [String] value for the +Authorization+ header
57
+ def to_header
58
+ "#{token_type} #{value}"
59
+ end
60
+
61
+ # Keeps the token value out of logs and exception output.
62
+ def inspect
63
+ "#<#{self.class.name} token=[FILTERED] expires_at=#{expires_at.iso8601} scopes=#{scopes.inspect}>"
64
+ end
65
+ alias_method :to_s, :inspect
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Craigslist
4
+ module API
5
+ # A craigslist area (a city or region), as published by the public
6
+ # reference service.
7
+ class Area
8
+ # A subdivision of an {Area}. Postings in areas that have subareas are
9
+ # expected to name one.
10
+ class SubArea
11
+ attr_reader :abbreviation, :description, :short_description, :id
12
+
13
+ def initialize(abbreviation:, description: nil, short_description: nil, id: nil)
14
+ @abbreviation = abbreviation
15
+ @description = description
16
+ @short_description = short_description
17
+ @id = id
18
+ freeze
19
+ end
20
+
21
+ # @param hash [Hash] raw payload
22
+ # @return [SubArea]
23
+ def self.from(hash)
24
+ new(
25
+ abbreviation: hash["Abbreviation"],
26
+ description: hash["Description"],
27
+ short_description: hash["ShortDescription"],
28
+ id: hash["SubAreaID"]
29
+ )
30
+ end
31
+
32
+ def to_s
33
+ abbreviation.to_s
34
+ end
35
+
36
+ def inspect
37
+ "#<#{self.class.name} #{abbreviation.inspect} #{description.inspect}>"
38
+ end
39
+ end
40
+
41
+ attr_reader :abbreviation, :description, :short_description, :hostname,
42
+ :country, :region, :latitude, :longitude, :id, :subareas
43
+
44
+ def initialize(abbreviation:, description: nil, short_description: nil,
45
+ hostname: nil, country: nil, region: nil, latitude: nil,
46
+ longitude: nil, id: nil, subareas: [])
47
+ @abbreviation = abbreviation
48
+ @description = description
49
+ @short_description = short_description
50
+ @hostname = hostname
51
+ @country = country
52
+ @region = region
53
+ @latitude = latitude
54
+ @longitude = longitude
55
+ @id = id
56
+ @subareas = subareas.freeze
57
+ freeze
58
+ end
59
+
60
+ # @param hash [Hash] raw payload
61
+ # @return [Area]
62
+ def self.from(hash)
63
+ new(
64
+ abbreviation: hash["Abbreviation"],
65
+ description: hash["Description"],
66
+ short_description: hash["ShortDescription"],
67
+ hostname: hash["Hostname"],
68
+ country: hash["Country"],
69
+ region: hash["Region"],
70
+ latitude: hash["Latitude"],
71
+ longitude: hash["Longitude"],
72
+ id: hash["AreaID"],
73
+ subareas: Array(hash["SubAreas"]).map { |sub| SubArea.from(sub) }
74
+ )
75
+ end
76
+
77
+ # @return [Boolean] whether a subarea must be supplied when posting here
78
+ def subareas?
79
+ !subareas.empty?
80
+ end
81
+
82
+ # @param abbreviation [String]
83
+ # @return [SubArea, nil]
84
+ def subarea(abbreviation)
85
+ subareas.find { |sub| sub.abbreviation == abbreviation.to_s }
86
+ end
87
+
88
+ def to_s
89
+ abbreviation.to_s
90
+ end
91
+
92
+ def inspect
93
+ "#<#{self.class.name} #{abbreviation.inspect} #{description.inspect} " \
94
+ "subareas=#{subareas.size}>"
95
+ end
96
+ end
97
+ end
98
+ end