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
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Craigslist
4
+ module API
5
+ module Resources
6
+ # Reading and editing postings that already exist.
7
+ #
8
+ # Postings are created through the RSS interface ({Client#post}); this
9
+ # covers everything afterwards.
10
+ class Postings < Base
11
+ # Statuses a posting can report.
12
+ STATUSES = %w[active deleted expired pending removed].freeze
13
+
14
+ # @param posting_id [String, Integer]
15
+ # @return [String] one of {STATUSES}
16
+ def status(posting_id)
17
+ fetch(transport.get(path("postings", posting_id, "status")), "status")
18
+ end
19
+
20
+ # @param posting_id [String, Integer]
21
+ # @return [String] the posting body
22
+ def body(posting_id)
23
+ fetch(transport.get(path("postings", posting_id, "body")), "body")
24
+ end
25
+
26
+ # Replaces the posting body.
27
+ #
28
+ # Postings in "ctd" (cars & trucks by dealer) must keep the VIN they
29
+ # were created with; the API rejects a body that changes it.
30
+ #
31
+ # @param posting_id [String, Integer]
32
+ # @param body [String]
33
+ # @return [String] the updated body as the API echoes it back
34
+ def update_body(posting_id, body)
35
+ envelope = transport.put(path("postings", posting_id, "body"), form: {"body" => body})
36
+ fetch(envelope, "body")
37
+ end
38
+
39
+ # @param posting_id [String, Integer]
40
+ # @return [Integer, nil] for categories that carry a price
41
+ def price(posting_id)
42
+ fetch(transport.get(path("postings", posting_id, "price")), "price")
43
+ end
44
+
45
+ # @param posting_id [String, Integer]
46
+ # @param price [Integer] in the currency of the posting's location
47
+ # @return [Integer] the updated price
48
+ def update_price(posting_id, price)
49
+ envelope = transport.put(path("postings", posting_id, "price"), form: {"price" => price.to_i})
50
+ fetch(envelope, "price")
51
+ end
52
+
53
+ # @param posting_id [String, Integer]
54
+ # @return [String, nil] for jobs and gigs postings
55
+ def remuneration(posting_id)
56
+ fetch(transport.get(path("postings", posting_id, "remuneration")), "remuneration")
57
+ end
58
+
59
+ # @param posting_id [String, Integer]
60
+ # @param remuneration [String] free text, e.g. "$19.95/hr plus tips"
61
+ # @return [String] the updated value
62
+ def update_remuneration(posting_id, remuneration)
63
+ envelope = transport.put(
64
+ path("postings", posting_id, "remuneration"),
65
+ form: {"remuneration" => remuneration}
66
+ )
67
+ fetch(envelope, "remuneration")
68
+ end
69
+
70
+ # @param posting_id [String, Integer]
71
+ # @return [true]
72
+ def delete(posting_id)
73
+ transport.delete(path("postings", posting_id))
74
+ true
75
+ end
76
+
77
+ # @param posting_id [String, Integer]
78
+ # @return [true]
79
+ def undelete(posting_id)
80
+ transport.put(path("postings", posting_id, "undelete"))
81
+ true
82
+ end
83
+
84
+ # Maps a US ZIP code onto the craigslist area that covers it, and the
85
+ # subarea within it when there is one.
86
+ #
87
+ # @param zip [String]
88
+ # @return [ZipLocation]
89
+ def area_for_zip(zip)
90
+ ZipLocation.from(transport.get(path("posting", "zip", zip, "area")).data)
91
+ end
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rexml/document"
4
+
5
+ module Craigslist
6
+ module API
7
+ # Turns the RSS document returned by validate/post into a {ResultSet}.
8
+ #
9
+ # Element lookups use the literal prefixed names as they appear on the wire
10
+ # rather than XPath namespace resolution, which REXML handles awkwardly when
11
+ # a default namespace is in play.
12
+ class ResponseParser
13
+ # Prefix craigslist puts on the channel description in post mode.
14
+ UPLOAD_ID_PREFIX = "upload-id:"
15
+
16
+ class << self
17
+ # @param xml [String] raw response body
18
+ # @return [ResultSet]
19
+ # @raise [ParseError] if the body is not well-formed XML
20
+ def parse(xml)
21
+ document = build(xml)
22
+ root = document.root
23
+ raise ParseError, "response contained no root element" if root.nil?
24
+
25
+ new(root, xml.to_s).result_set
26
+ end
27
+
28
+ private
29
+
30
+ def build(xml)
31
+ REXML::Document.new(xml.to_s)
32
+ rescue REXML::ParseException => e
33
+ raise ParseError, "could not parse the bulk posting response: #{e.message}"
34
+ end
35
+ end
36
+
37
+ def initialize(root, raw = nil)
38
+ @root = root
39
+ @raw = raw
40
+ end
41
+
42
+ # @return [ResultSet]
43
+ def result_set
44
+ ResultSet.new(results: results, upload_id: upload_id, raw: raw)
45
+ end
46
+
47
+ private
48
+
49
+ attr_reader :root, :raw
50
+
51
+ def results
52
+ root.get_elements("item").map { |item| build_result(item) }
53
+ end
54
+
55
+ def build_result(item)
56
+ Result.new(
57
+ key: item.attributes["rdf:about"].to_s,
58
+ status: value_of(item, "cl:postedStatus"),
59
+ explanation: value_of(item, "cl:postedExplanation"),
60
+ posting_id: value_of(item, "cl:postingID"),
61
+ manage_url: value_of(item, "cl:postingManageURL"),
62
+ view_url: value_of(item, "cl:postingViewURL"),
63
+ preview_html: value_of(item, "cl:previewHTML"),
64
+ warnings: item.get_elements("cl:warning").filter_map { |w| text_of(w) }
65
+ )
66
+ end
67
+
68
+ def upload_id
69
+ description = root.get_elements("channel/description").first
70
+ text = text_of(description)
71
+ return nil if text.nil? || !text.start_with?(UPLOAD_ID_PREFIX)
72
+
73
+ text.delete_prefix(UPLOAD_ID_PREFIX)
74
+ end
75
+
76
+ def value_of(parent, name)
77
+ text_of(parent.get_elements(name).first)
78
+ end
79
+
80
+ # Reads every text child, CDATA included, and strips surrounding
81
+ # whitespace — craigslist pretty-prints URLs onto their own lines, which
82
+ # leaves newlines inside the element.
83
+ def text_of(element)
84
+ return nil if element.nil?
85
+
86
+ text = element.texts.map(&:value).join.strip
87
+ text.empty? ? nil : text
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Craigslist
4
+ module API
5
+ # The outcome of a single posting within a bulk submission.
6
+ #
7
+ # Bulk submissions succeed and fail per item, and the HTTP status says
8
+ # nothing about it — a 200 can carry a document in which every posting
9
+ # failed. So submissions return results rather than raising: a batch with a
10
+ # few rejections is ordinary, not exceptional.
11
+ class Result
12
+ # Returned by validate when the posting looks acceptable.
13
+ VALID = "VALID"
14
+
15
+ # Validation failed. {#explanation} says why.
16
+ NOT_VALID = "NOT_VALID"
17
+
18
+ # The posting was accepted.
19
+ POSTED = "POSTED"
20
+
21
+ # Unexpected error at post time.
22
+ FAILED = "FAILED"
23
+
24
+ # The account has no remaining blocks for this area/category and is not
25
+ # invoiced. More must be purchased before the posting is processed.
26
+ INSUFFICIENT_BLOCKS = "INSUFFICIENT_BLOCKS"
27
+
28
+ # The invoiced account hit its credit limit.
29
+ CREDIT_LIMIT_REACHED = "CREDIT_LIMIT_REACHED"
30
+
31
+ # Billing the card named by +cl:purchaseWithCreditCardID+ failed.
32
+ CREDIT_CARD_ERROR = "CREDIT_CARD_ERROR"
33
+
34
+ # @return [String] the key you supplied for this posting
35
+ attr_reader :key
36
+
37
+ # @return [String, nil] one of the status constants above
38
+ attr_reader :status
39
+
40
+ # @return [String, nil] human-readable detail, populated on failure
41
+ attr_reader :explanation
42
+
43
+ # @return [String, nil] craigslist's id for the new posting, on success
44
+ attr_reader :posting_id
45
+
46
+ # @return [String, nil] URL for editing or deleting the posting
47
+ attr_reader :manage_url
48
+
49
+ # @return [String, nil] public URL of the posting
50
+ attr_reader :view_url
51
+
52
+ # @return [String, nil] HTML preview of the posting as submitted
53
+ attr_reader :preview_html
54
+
55
+ # @return [Array<String>] non-fatal warnings, typically XML parsing
56
+ # complaints. Worth logging even when the posting succeeded.
57
+ attr_reader :warnings
58
+
59
+ def initialize(key:, status: nil, explanation: nil, posting_id: nil,
60
+ manage_url: nil, view_url: nil, preview_html: nil, warnings: [])
61
+ @key = key
62
+ @status = status
63
+ @explanation = explanation
64
+ @posting_id = posting_id
65
+ @manage_url = manage_url
66
+ @view_url = view_url
67
+ @preview_html = preview_html
68
+ @warnings = Array(warnings).freeze
69
+ freeze
70
+ end
71
+
72
+ # @return [Boolean] validated successfully (validate mode only)
73
+ def valid?
74
+ status == VALID
75
+ end
76
+
77
+ # @return [Boolean] accepted for posting (post mode only)
78
+ def posted?
79
+ status == POSTED
80
+ end
81
+
82
+ # @return [Boolean] either valid or posted
83
+ def success?
84
+ valid? || posted?
85
+ end
86
+
87
+ # @return [Boolean]
88
+ def failure?
89
+ !success?
90
+ end
91
+
92
+ # @return [Boolean] rejected during validation
93
+ def not_valid?
94
+ status == NOT_VALID
95
+ end
96
+
97
+ # @return [Boolean] the account is out of posting blocks
98
+ def insufficient_blocks?
99
+ status == INSUFFICIENT_BLOCKS
100
+ end
101
+
102
+ # @return [Boolean] the invoiced account hit its credit limit
103
+ def credit_limit_reached?
104
+ status == CREDIT_LIMIT_REACHED
105
+ end
106
+
107
+ # @return [Boolean] the credit card could not be billed
108
+ def credit_card_error?
109
+ status == CREDIT_CARD_ERROR
110
+ end
111
+
112
+ # @return [Boolean] whether any non-fatal warnings came back
113
+ def warnings?
114
+ !warnings.empty?
115
+ end
116
+
117
+ def inspect
118
+ "#<#{self.class.name} key=#{key.inspect} status=#{status.inspect} " \
119
+ "posting_id=#{posting_id.inspect}>"
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Craigslist
4
+ module API
5
+ # Every {Result} from one bulk submission, plus the upload id Craigslist
6
+ # assigns to the batch.
7
+ #
8
+ # Enumerable, so it behaves like the array of results it wraps while still
9
+ # answering the questions you actually have after a submission.
10
+ class ResultSet
11
+ include Enumerable
12
+
13
+ # @return [Array<Result>]
14
+ attr_reader :results
15
+
16
+ # @return [String, nil] craigslist's identifier for this submission,
17
+ # taken from the channel description. Present in post mode.
18
+ attr_reader :upload_id
19
+
20
+ # The unparsed response body.
21
+ #
22
+ # Kept because a NOT_VALID with no explanation is otherwise a dead end,
23
+ # and reaching the raw XML should not require rebuilding the request by
24
+ # hand.
25
+ #
26
+ # @return [String, nil]
27
+ attr_reader :raw
28
+
29
+ def initialize(results:, upload_id: nil, raw: nil)
30
+ @results = Array(results).freeze
31
+ @upload_id = upload_id
32
+ @raw = raw
33
+ @index = @results.each_with_object({}) { |r, memo| memo[r.key] = r }.freeze
34
+ freeze
35
+ end
36
+
37
+ # @yieldparam result [Result]
38
+ # @return [Enumerator, ResultSet]
39
+ def each(&block)
40
+ results.each(&block)
41
+ end
42
+
43
+ # @param key [String] the key supplied when building the posting
44
+ # @return [Result, nil]
45
+ def [](key)
46
+ @index[key.to_s]
47
+ end
48
+
49
+ # @return [Array<Result>]
50
+ def successful
51
+ select(&:success?)
52
+ end
53
+
54
+ # @return [Array<Result>]
55
+ def failed
56
+ select(&:failure?)
57
+ end
58
+
59
+ # @return [Array<Result>] results carrying non-fatal warnings, which are
60
+ # easy to miss because they can accompany a success
61
+ def warned
62
+ select(&:warnings?)
63
+ end
64
+
65
+ # @return [Boolean] whether every posting succeeded
66
+ def all_successful?
67
+ failed.empty?
68
+ end
69
+
70
+ # @return [Boolean]
71
+ def any_failures?
72
+ !failed.empty?
73
+ end
74
+
75
+ # @return [Array<String>] ids of postings that were created
76
+ def posting_ids
77
+ select(&:posted?).map(&:posting_id).compact
78
+ end
79
+
80
+ # @return [Integer] number of results in the batch
81
+ def size
82
+ results.size
83
+ end
84
+ alias_method :length, :size
85
+
86
+ def empty?
87
+ results.empty?
88
+ end
89
+
90
+ def inspect
91
+ "#<#{self.class.name} size=#{size} successful=#{successful.size} " \
92
+ "failed=#{failed.size} upload_id=#{upload_id.inspect}>"
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,177 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rexml/document"
4
+
5
+ module Craigslist
6
+ module API
7
+ # Renders {Posting} objects into the RDF/RSS document the bulk interface
8
+ # expects.
9
+ #
10
+ # REXML lives here and nowhere else. Bulk submissions inline base64 images,
11
+ # so a large batch is a large document; if building a DOM ever becomes a
12
+ # memory problem this class can be reimplemented as a streaming writer
13
+ # without changing anything public.
14
+ class Serializer
15
+ # Default namespace for the RSS 1.0 elements.
16
+ RSS_NAMESPACE = "http://purl.org/rss/1.0/"
17
+
18
+ # RDF namespace, used for the item manifest.
19
+ RDF_NAMESPACE = "http://www.w3.org/1999/02/22-rdf-syntax-ns#"
20
+
21
+ # Craigslist's own namespace, carrying every cl:* element.
22
+ CL_NAMESPACE = "http://www.craigslist.org/about/cl-bulk-ns/1.0"
23
+
24
+ # @param config [Configuration] supplies the +cl:auth+ credentials
25
+ def initialize(config)
26
+ @config = config
27
+ end
28
+
29
+ # @param postings [Array<Posting>]
30
+ # @param io [IO, String, nil] destination; a new String is used if omitted
31
+ # @return [String, IO] whatever was written to
32
+ def serialize(postings, io: nil)
33
+ output = io || +""
34
+ build_document(Array(postings)).write(output)
35
+ output
36
+ end
37
+
38
+ private
39
+
40
+ attr_reader :config
41
+
42
+ def build_document(postings)
43
+ doc = REXML::Document.new
44
+ doc << REXML::XMLDecl.new("1.0", "UTF-8")
45
+
46
+ root = doc.add_element("rdf:RDF",
47
+ "xmlns" => RSS_NAMESPACE,
48
+ "xmlns:rdf" => RDF_NAMESPACE,
49
+ "xmlns:cl" => CL_NAMESPACE)
50
+
51
+ append_channel(root, postings)
52
+ postings.each { |posting| append_item(root, posting) }
53
+
54
+ doc
55
+ end
56
+
57
+ def append_channel(root, postings)
58
+ channel = root.add_element("channel")
59
+
60
+ items = channel.add_element("items")
61
+ postings.each do |posting|
62
+ items.add_element("rdf:li", "rdf:resource" => posting.key)
63
+ end
64
+
65
+ channel.add_element("cl:auth",
66
+ "username" => config.email,
67
+ "password" => config.password,
68
+ "accountID" => config.account_id)
69
+ end
70
+
71
+ def append_item(root, posting)
72
+ item = root.add_element("item", "rdf:about" => posting.key)
73
+
74
+ text_element(item, "cl:category", posting.category)
75
+ text_element(item, "cl:area", posting.area)
76
+ text_element(item, "cl:subarea", posting.subarea)
77
+ text_element(item, "cl:neighborhood", posting.neighborhood)
78
+ text_element(item, "cl:price", posting.price)
79
+
80
+ attribute_element(item, "cl:housing_basics", posting.housing_basics)
81
+ attribute_element(item, "cl:housing_pets", posting.housing_pets)
82
+ attribute_element(item, "cl:housing_terms", posting.housing_terms)
83
+ attribute_element(item, "cl:job_basics", posting.job_basics)
84
+ attribute_element(item, "cl:auto_basics", posting.auto_basics)
85
+ attribute_element(item, "cl:forsale", posting.forsale)
86
+ attribute_element(item, "cl:generic", posting.generic)
87
+
88
+ attribute_element(item, "cl:mapLocation",
89
+ rename(posting.location, Posting::LOCATION_ATTRIBUTES))
90
+ append_reply_email(item, posting)
91
+ attribute_element(item, "cl:brokerInfo",
92
+ rename(posting.broker, Posting::BROKER_ATTRIBUTES))
93
+
94
+ text_element(item, "title", posting.title)
95
+ cdata_element(item, "description", posting.description)
96
+ text_element(item, "cl:PONumber", posting.po_number)
97
+
98
+ posting.images.each { |image| append_image(item, image) }
99
+ end
100
+
101
+ def append_reply_email(item, posting)
102
+ return if posting.reply_email.nil? || posting.reply_email.empty?
103
+
104
+ attributes = {"privacy" => posting.reply_privacy}
105
+ unless posting.other_contact_info.nil? || posting.other_contact_info.empty?
106
+ attributes["otherContactInfo"] = posting.other_contact_info
107
+ end
108
+
109
+ item.add_element("cl:replyEmail", attributes).add_text(posting.reply_email)
110
+ end
111
+
112
+ def append_image(item, image)
113
+ attributes = image.position.nil? ? {} : {"position" => image.position.to_s}
114
+ element = item.add_element("cl:image", attributes)
115
+
116
+ # Written raw: base64 is alphanumeric plus "+/=", so it needs no
117
+ # escaping, and skipping the escape pass matters on megabyte payloads.
118
+ element.add(REXML::Text.new(image.data, true, nil, true))
119
+ end
120
+
121
+ def text_element(parent, name, value)
122
+ return if value.nil? || value.to_s.empty?
123
+
124
+ parent.add_element(name).add_text(value.to_s)
125
+ end
126
+
127
+ # Emits text as CDATA, split around any literal "]]>".
128
+ #
129
+ # A "]]>" in a posting body would otherwise close the section early and
130
+ # corrupt the whole document. REXML does not guard against this and
131
+ # posting bodies are arbitrary user text, so split the run across
132
+ # adjacent sections: "a]]>b" becomes CDATA("a]]") + CDATA(">b"), which
133
+ # concatenates back to the original on the far end.
134
+ def cdata_element(parent, name, value)
135
+ element = parent.add_element(name)
136
+ chunks = value.to_s.split("]]>", -1)
137
+ final = chunks.size - 1
138
+
139
+ chunks.each_with_index do |chunk, index|
140
+ content = +""
141
+ content << ">" unless index.zero?
142
+ content << chunk
143
+ content << "]]" unless index == final
144
+
145
+ element.add(REXML::CData.new(content))
146
+ end
147
+
148
+ element
149
+ end
150
+
151
+ def attribute_element(parent, name, attributes)
152
+ return if attributes.nil? || attributes.empty?
153
+
154
+ parent.add_element(name, stringify(attributes))
155
+ end
156
+
157
+ def rename(hash, mapping)
158
+ hash.each_with_object({}) do |(key, value), memo|
159
+ memo[mapping.fetch(key.to_sym, key.to_s)] = value
160
+ end
161
+ end
162
+
163
+ # The interface expresses booleans as 0/1, so accept Ruby booleans and
164
+ # translate rather than making callers remember.
165
+ def stringify(hash)
166
+ hash.each_with_object({}) do |(key, value), memo|
167
+ memo[key.to_s] =
168
+ case value
169
+ when true then "1"
170
+ when false then "0"
171
+ else value.to_s
172
+ end
173
+ end
174
+ end
175
+ end
176
+ end
177
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "uri"
5
+
6
+ module Craigslist
7
+ module API
8
+ # Acquires and caches the OAuth2 access token used by the JSON API.
9
+ #
10
+ # Tokens live an hour. This holds one per client instance, guarded by a
11
+ # mutex so concurrent callers on a shared client cannot stampede the token
12
+ # endpoint or read a half-replaced token. Deliberately an instance, not a
13
+ # class-level cache: one process may talk to several accounts.
14
+ class TokenProvider
15
+ # OAuth2 token endpoint.
16
+ TOKEN_PATH = "/bulkpost/oauth/access-token"
17
+
18
+ # @param config [Configuration]
19
+ # @param connection [Faraday::Connection] pointed at the bapi host
20
+ def initialize(config:, connection:)
21
+ @config = config
22
+ @connection = connection
23
+ @mutex = Mutex.new
24
+ @token = nil
25
+ end
26
+
27
+ # Returns a live token, fetching or refreshing if needed.
28
+ #
29
+ # @return [AccessToken]
30
+ def token
31
+ @mutex.synchronize do
32
+ @token = fetch if @token.nil? || @token.expired?
33
+ @token
34
+ end
35
+ end
36
+
37
+ # Drops the cached token so the next call re-authenticates.
38
+ #
39
+ # Called after a 401, which can happen before the recorded expiry if the
40
+ # token was revoked server-side.
41
+ #
42
+ # @return [void]
43
+ def invalidate!
44
+ @mutex.synchronize { @token = nil }
45
+ end
46
+
47
+ private
48
+
49
+ def fetch
50
+ response = Connection.perform do
51
+ @connection.post(TOKEN_PATH) do |req|
52
+ req.headers["Authorization"] = @config.basic_authorization
53
+ req.headers["Content-Type"] = "application/x-www-form-urlencoded"
54
+ req.headers["Accept"] = "application/json"
55
+ req.body = URI.encode_www_form(
56
+ grant_type: "client_credentials",
57
+ scope: @config.scopes.join(" ")
58
+ )
59
+ end
60
+ end
61
+
62
+ unless response.success?
63
+ raise AuthenticationError.new(
64
+ "token request failed with HTTP #{response.status}",
65
+ status: response.status,
66
+ body: response.body
67
+ )
68
+ end
69
+
70
+ AccessToken.from_payload(parse(response))
71
+ end
72
+
73
+ def parse(response)
74
+ JSON.parse(response.body.to_s)
75
+ rescue JSON::ParserError => e
76
+ raise ParseError, "token endpoint returned invalid JSON: #{e.message}"
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Craigslist
4
+ module API
5
+ # Current gem version.
6
+ VERSION = "0.1.0"
7
+ end
8
+ end