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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +53 -0
- data/LICENSE.txt +21 -0
- data/README.md +405 -0
- data/lib/craigslist/api/access_token.rb +68 -0
- data/lib/craigslist/api/area.rb +98 -0
- data/lib/craigslist/api/bulk_transport.rb +94 -0
- data/lib/craigslist/api/category.rb +47 -0
- data/lib/craigslist/api/client.rb +207 -0
- data/lib/craigslist/api/configuration.rb +130 -0
- data/lib/craigslist/api/connection.rb +54 -0
- data/lib/craigslist/api/credit_summary.rb +39 -0
- data/lib/craigslist/api/envelope.rb +65 -0
- data/lib/craigslist/api/errors.rb +82 -0
- data/lib/craigslist/api/image.rb +102 -0
- data/lib/craigslist/api/image_info.rb +53 -0
- data/lib/craigslist/api/json_transport.rb +153 -0
- data/lib/craigslist/api/money.rb +77 -0
- data/lib/craigslist/api/posting.rb +216 -0
- data/lib/craigslist/api/posting_block.rb +44 -0
- data/lib/craigslist/api/posting_handle.rb +142 -0
- data/lib/craigslist/api/posting_stats.rb +85 -0
- data/lib/craigslist/api/reference.rb +84 -0
- data/lib/craigslist/api/resources/account.rb +73 -0
- data/lib/craigslist/api/resources/base.rb +45 -0
- data/lib/craigslist/api/resources/billing.rb +47 -0
- data/lib/craigslist/api/resources/images.rb +104 -0
- data/lib/craigslist/api/resources/postings.rb +95 -0
- data/lib/craigslist/api/response_parser.rb +91 -0
- data/lib/craigslist/api/result.rb +123 -0
- data/lib/craigslist/api/result_set.rb +96 -0
- data/lib/craigslist/api/serializer.rb +177 -0
- data/lib/craigslist/api/token_provider.rb +80 -0
- data/lib/craigslist/api/version.rb +8 -0
- data/lib/craigslist/api/zip_location.rb +80 -0
- data/lib/craigslist/api.rb +63 -0
- metadata +127 -0
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Craigslist
|
|
4
|
+
module API
|
|
5
|
+
# Submits postings to the RSS bulk interface.
|
|
6
|
+
#
|
|
7
|
+
# Validate and post take identical documents and differ only in the URL, so
|
|
8
|
+
# validating is a genuine dry run of the exact payload that would be posted.
|
|
9
|
+
class BulkTransport
|
|
10
|
+
# The protocol section of the documentation asks for text/xml. The
|
|
11
|
+
# changelog mentions application/xml and the sample client still sends
|
|
12
|
+
# form encoding; text/xml is the one the protocol itself specifies.
|
|
13
|
+
CONTENT_TYPE = "text/xml; charset=utf-8"
|
|
14
|
+
|
|
15
|
+
# Dry-run endpoint. Takes the same document as {POST_PATH}.
|
|
16
|
+
VALIDATE_PATH = "/bulk-rss/validate"
|
|
17
|
+
|
|
18
|
+
# Endpoint that actually creates postings.
|
|
19
|
+
POST_PATH = "/bulk-rss/post"
|
|
20
|
+
|
|
21
|
+
def initialize(config:, connection:)
|
|
22
|
+
@config = config
|
|
23
|
+
@connection = connection
|
|
24
|
+
@serializer = Serializer.new(config)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Dry run: checks the document without creating anything.
|
|
28
|
+
#
|
|
29
|
+
# @param postings [Posting, Array<Posting>]
|
|
30
|
+
# @return [ResultSet]
|
|
31
|
+
def validate(postings)
|
|
32
|
+
submit(VALIDATE_PATH, postings)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Creates the postings.
|
|
36
|
+
#
|
|
37
|
+
# @param postings [Posting, Array<Posting>]
|
|
38
|
+
# @return [ResultSet]
|
|
39
|
+
def post(postings)
|
|
40
|
+
submit(POST_PATH, postings)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
attr_reader :config, :connection, :serializer
|
|
46
|
+
|
|
47
|
+
def submit(path, postings)
|
|
48
|
+
list = Array(postings)
|
|
49
|
+
raise ValidationError, ["at least one posting is required"] if list.empty?
|
|
50
|
+
|
|
51
|
+
assert_unique_keys!(list)
|
|
52
|
+
|
|
53
|
+
response = Connection.perform do
|
|
54
|
+
connection.post(path) do |req|
|
|
55
|
+
req.headers["Content-Type"] = CONTENT_TYPE
|
|
56
|
+
req.headers["Accept"] = "text/xml"
|
|
57
|
+
req.body = serializer.serialize(list)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
raise_for_status(response)
|
|
62
|
+
ResponseParser.parse(response.body)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Keys identify postings within the document and are how results are
|
|
66
|
+
# matched back to submissions, so a collision would silently lose one.
|
|
67
|
+
def assert_unique_keys!(postings)
|
|
68
|
+
duplicates = postings.map(&:key).tally.select { |_, count| count > 1 }.keys
|
|
69
|
+
return if duplicates.empty?
|
|
70
|
+
|
|
71
|
+
raise ValidationError, ["duplicate posting keys: #{duplicates.inspect}"]
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def raise_for_status(response)
|
|
75
|
+
return if response.success?
|
|
76
|
+
|
|
77
|
+
body = response.body.to_s
|
|
78
|
+
message = "bulk submission failed with HTTP #{response.status}"
|
|
79
|
+
message += ": #{body.strip}" unless body.strip.empty?
|
|
80
|
+
|
|
81
|
+
error_class =
|
|
82
|
+
case response.status
|
|
83
|
+
when 403 then AuthenticationError
|
|
84
|
+
when 400, 415 then RequestError
|
|
85
|
+
when 429 then RateLimitError
|
|
86
|
+
when 500..599 then ServerError
|
|
87
|
+
else ResponseError
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
raise error_class.new(message, status: response.status, body: body)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Craigslist
|
|
4
|
+
module API
|
|
5
|
+
# A craigslist posting category, as published by the public reference
|
|
6
|
+
# service.
|
|
7
|
+
class Category
|
|
8
|
+
# Categories the bulk posting interface explicitly does not support.
|
|
9
|
+
# Submitting to one of these is documented as something not to do.
|
|
10
|
+
UNSUPPORTED = %w[sbw rew swp sub reo prk hou sha].freeze
|
|
11
|
+
|
|
12
|
+
attr_reader :abbreviation, :description, :type, :id
|
|
13
|
+
|
|
14
|
+
def initialize(abbreviation:, description: nil, type: nil, id: nil)
|
|
15
|
+
@abbreviation = abbreviation
|
|
16
|
+
@description = description
|
|
17
|
+
@type = type
|
|
18
|
+
@id = id
|
|
19
|
+
freeze
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# @param hash [Hash] raw payload
|
|
23
|
+
# @return [Category]
|
|
24
|
+
def self.from(hash)
|
|
25
|
+
new(
|
|
26
|
+
abbreviation: hash["Abbreviation"],
|
|
27
|
+
description: hash["Description"],
|
|
28
|
+
type: hash["Type"],
|
|
29
|
+
id: hash["CategoryID"]
|
|
30
|
+
)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# @return [Boolean] whether bulk posting to this category is disallowed
|
|
34
|
+
def unsupported?
|
|
35
|
+
UNSUPPORTED.include?(abbreviation)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def to_s
|
|
39
|
+
abbreviation.to_s
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def inspect
|
|
43
|
+
"#<#{self.class.name} #{abbreviation.inspect} #{description.inspect} type=#{type.inspect}>"
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Craigslist
|
|
4
|
+
module API
|
|
5
|
+
# The single entry point for both halves of the Craigslist bulk posting
|
|
6
|
+
# platform.
|
|
7
|
+
#
|
|
8
|
+
# Craigslist splits the work across two services with different formats and
|
|
9
|
+
# different authentication: postings are *created* through an RSS interface
|
|
10
|
+
# authenticated with credentials embedded in the XML, and *managed*
|
|
11
|
+
# afterwards through a JSON API authenticated with an OAuth2 bearer token.
|
|
12
|
+
# One set of credentials covers both. This client owns that seam so callers
|
|
13
|
+
# do not have to think about it — {#post} and {#posting} are the same
|
|
14
|
+
# object's methods, and the token lifecycle is invisible.
|
|
15
|
+
#
|
|
16
|
+
# Configuration is frozen and nothing mutates at request time, so a client
|
|
17
|
+
# is safe to share across threads. Talking to several accounts means
|
|
18
|
+
# building several clients, which is deliberate: there is no global to
|
|
19
|
+
# reconfigure and no ambient state to get wrong.
|
|
20
|
+
#
|
|
21
|
+
# @example Creating postings
|
|
22
|
+
# client = Craigslist::API::Client.new(
|
|
23
|
+
# email: "you@example.com",
|
|
24
|
+
# password: ENV.fetch("CRAIGSLIST_PASSWORD"),
|
|
25
|
+
# account_id: 1234
|
|
26
|
+
# )
|
|
27
|
+
#
|
|
28
|
+
# posting = Craigslist::API::Posting.new(
|
|
29
|
+
# key: "listing-1",
|
|
30
|
+
# title: "1998 Toyota Hilux",
|
|
31
|
+
# description: "Runs great.",
|
|
32
|
+
# category: "ctd",
|
|
33
|
+
# area: "sfo",
|
|
34
|
+
# price: 4500,
|
|
35
|
+
# reply_email: "sales@example.com",
|
|
36
|
+
# location: {postal: "94110"}
|
|
37
|
+
# )
|
|
38
|
+
#
|
|
39
|
+
# results = client.validate(posting) # dry run
|
|
40
|
+
# results = client.post(posting) if results.all_successful?
|
|
41
|
+
#
|
|
42
|
+
# @example Managing what you created
|
|
43
|
+
# live = client.posting(results.posting_ids.first)
|
|
44
|
+
# live.price = 4200
|
|
45
|
+
# live.add_image("front.jpg")
|
|
46
|
+
class Client
|
|
47
|
+
# @return [Configuration]
|
|
48
|
+
attr_reader :config
|
|
49
|
+
|
|
50
|
+
# @param email [String] craigslist account email
|
|
51
|
+
# @param password [String] craigslist account password
|
|
52
|
+
# @param account_id [String, Integer] craigslist account number
|
|
53
|
+
# @param options [Hash] any other {Configuration} keyword
|
|
54
|
+
# @raise [ConfigurationError] when credentials are missing
|
|
55
|
+
def initialize(email:, password:, account_id:, **options)
|
|
56
|
+
@config = Configuration.new(
|
|
57
|
+
email: email,
|
|
58
|
+
password: password,
|
|
59
|
+
account_id: account_id,
|
|
60
|
+
**options
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
build_components
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Builds a client from an existing {Configuration}.
|
|
67
|
+
#
|
|
68
|
+
# @param config [Configuration]
|
|
69
|
+
# @return [Client]
|
|
70
|
+
def self.from_config(config)
|
|
71
|
+
allocate.tap do |client|
|
|
72
|
+
client.instance_variable_set(:@config, config)
|
|
73
|
+
client.send(:build_components)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Checks postings without creating anything.
|
|
78
|
+
#
|
|
79
|
+
# Sends the identical document {#post} would, so a clean validation is a
|
|
80
|
+
# real rehearsal rather than an approximation.
|
|
81
|
+
#
|
|
82
|
+
# @param postings [Posting, Array<Posting>]
|
|
83
|
+
# @return [ResultSet]
|
|
84
|
+
# @raise [ValidationError] if no postings were given, or keys collide
|
|
85
|
+
def validate(postings)
|
|
86
|
+
bulk.validate(postings)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Creates postings.
|
|
90
|
+
#
|
|
91
|
+
# Does not raise when individual postings fail — a batch with some
|
|
92
|
+
# rejections is ordinary. Inspect the returned {ResultSet}.
|
|
93
|
+
#
|
|
94
|
+
# @param postings [Posting, Array<Posting>]
|
|
95
|
+
# @return [ResultSet]
|
|
96
|
+
def post(postings)
|
|
97
|
+
bulk.post(postings)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# A handle for working with one live posting.
|
|
101
|
+
#
|
|
102
|
+
# @param posting_id [String, Integer]
|
|
103
|
+
# @return [PostingHandle]
|
|
104
|
+
def posting(posting_id)
|
|
105
|
+
PostingHandle.new(self, posting_id)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# @return [Resources::Postings]
|
|
109
|
+
attr_reader :postings
|
|
110
|
+
|
|
111
|
+
# @return [Resources::Images]
|
|
112
|
+
attr_reader :images
|
|
113
|
+
|
|
114
|
+
# @return [Resources::Billing]
|
|
115
|
+
attr_reader :billing
|
|
116
|
+
|
|
117
|
+
# @return [Resources::Account]
|
|
118
|
+
attr_reader :account
|
|
119
|
+
|
|
120
|
+
# @return [Reference] public areas and categories data
|
|
121
|
+
attr_reader :reference
|
|
122
|
+
|
|
123
|
+
# @return [CreditSummary]
|
|
124
|
+
def credit
|
|
125
|
+
billing.credit
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# @return [Array<PostingBlock>]
|
|
129
|
+
def posting_blocks
|
|
130
|
+
billing.posting_blocks
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# @param area [String]
|
|
134
|
+
# @param category [String]
|
|
135
|
+
# @return [Money, nil]
|
|
136
|
+
def pricing(area:, category:)
|
|
137
|
+
billing.pricing(area: area, category: category)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# @param zip [String]
|
|
141
|
+
# @return [Hash{Symbol => String}]
|
|
142
|
+
def area_for_zip(zip)
|
|
143
|
+
postings.area_for_zip(zip)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# @see Resources::Account#stats
|
|
147
|
+
# @return [Array<PostingStats>]
|
|
148
|
+
def stats(start: nil, stop: nil)
|
|
149
|
+
account.stats(start: start, stop: stop)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Notices attached to the most recent JSON API response.
|
|
153
|
+
#
|
|
154
|
+
# Craigslist repeats these on every response until acknowledged, so it is
|
|
155
|
+
# worth surfacing them somewhere a human will look.
|
|
156
|
+
#
|
|
157
|
+
# @return [Array<Hash>] +{"messageId", "message"}+ entries
|
|
158
|
+
def account_messages
|
|
159
|
+
@json_transport.account_messages
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Discards the cached OAuth token, forcing the next JSON call to
|
|
163
|
+
# re-authenticate. Rarely needed; the token refreshes itself.
|
|
164
|
+
#
|
|
165
|
+
# @return [void]
|
|
166
|
+
def reset_token!
|
|
167
|
+
@token_provider.invalidate!
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def inspect
|
|
171
|
+
"#<#{self.class.name} email=#{config.email.inspect} account_id=#{config.account_id.inspect}>"
|
|
172
|
+
end
|
|
173
|
+
alias_method :to_s, :inspect
|
|
174
|
+
|
|
175
|
+
private
|
|
176
|
+
|
|
177
|
+
# Built eagerly rather than memoized: connections are cheap, and lazy
|
|
178
|
+
# construction would race two threads into two token providers.
|
|
179
|
+
def build_components
|
|
180
|
+
bapi_connection = Connection.build(url: config.bapi_host, config: config)
|
|
181
|
+
|
|
182
|
+
@token_provider = TokenProvider.new(config: config, connection: bapi_connection)
|
|
183
|
+
|
|
184
|
+
@json_transport = JsonTransport.new(
|
|
185
|
+
config: config,
|
|
186
|
+
connection: Connection.build(url: config.bapi_host, config: config, multipart: true),
|
|
187
|
+
token_provider: @token_provider
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
@bulk = BulkTransport.new(
|
|
191
|
+
config: config,
|
|
192
|
+
connection: Connection.build(url: config.bulk_host, config: config)
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
@postings = Resources::Postings.new(@json_transport)
|
|
196
|
+
@images = Resources::Images.new(@json_transport)
|
|
197
|
+
@billing = Resources::Billing.new(@json_transport)
|
|
198
|
+
@account = Resources::Account.new(@json_transport)
|
|
199
|
+
@reference = Reference.new(
|
|
200
|
+
Connection.build(url: config.reference_host, config: config)
|
|
201
|
+
)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
attr_reader :bulk
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Craigslist
|
|
4
|
+
module API
|
|
5
|
+
# Immutable configuration for a {Client}.
|
|
6
|
+
#
|
|
7
|
+
# Instances are frozen on construction. Nothing mutates configuration at
|
|
8
|
+
# request time, which keeps a single client safe to share across threads.
|
|
9
|
+
# Multi-account setups build one client per account rather than swapping
|
|
10
|
+
# credentials on a global.
|
|
11
|
+
class Configuration
|
|
12
|
+
# Host serving the RSS bulk posting interface (posting creation).
|
|
13
|
+
DEFAULT_BULK_HOST = "https://post.craigslist.org"
|
|
14
|
+
|
|
15
|
+
# Host serving the JSON Bulkpost API (everything after creation).
|
|
16
|
+
DEFAULT_BAPI_HOST = "https://bapi.craigslist.org"
|
|
17
|
+
|
|
18
|
+
# Host serving public areas/categories reference data. No auth required.
|
|
19
|
+
DEFAULT_REFERENCE_HOST = "https://reference.craigslist.org"
|
|
20
|
+
|
|
21
|
+
# Top-level OAuth scopes. Scopes are hierarchical, so requesting
|
|
22
|
+
# +bulkpost.posting+ also grants +bulkpost.posting.delete+ and friends.
|
|
23
|
+
DEFAULT_SCOPES = %w[
|
|
24
|
+
bulkpost.posting
|
|
25
|
+
bulkpost.account.billing
|
|
26
|
+
bulkpost.account.message
|
|
27
|
+
bulkpost.account.stats
|
|
28
|
+
].freeze
|
|
29
|
+
|
|
30
|
+
# Bulk submissions can carry base64 image payloads, so the default read
|
|
31
|
+
# timeout is generous.
|
|
32
|
+
DEFAULT_TIMEOUT = 120
|
|
33
|
+
|
|
34
|
+
# Connection establishment timeout, in seconds.
|
|
35
|
+
DEFAULT_OPEN_TIMEOUT = 15
|
|
36
|
+
|
|
37
|
+
attr_reader :email, :password, :account_id, :scopes, :bulk_host, :bapi_host,
|
|
38
|
+
:reference_host, :timeout, :open_timeout, :user_agent, :logger, :adapter
|
|
39
|
+
|
|
40
|
+
# @param email [String] the craigslist account email used to log in
|
|
41
|
+
# @param password [String] the craigslist account password
|
|
42
|
+
# @param account_id [String, Integer] craigslist account number with
|
|
43
|
+
# posting credit, for which +email+ is an authorized buyer
|
|
44
|
+
# @param scopes [Array<String>] OAuth scopes to request
|
|
45
|
+
# @param bulk_host [String] override the RSS interface host
|
|
46
|
+
# @param bapi_host [String] override the JSON API host
|
|
47
|
+
# @param reference_host [String] override the reference data host
|
|
48
|
+
# @param timeout [Integer] read timeout in seconds
|
|
49
|
+
# @param open_timeout [Integer] connection timeout in seconds
|
|
50
|
+
# @param user_agent [String] value sent as +User-Agent+
|
|
51
|
+
# @param logger [Logger, nil] when set, Faraday logs requests to it
|
|
52
|
+
# @param adapter [Symbol] Faraday adapter to use
|
|
53
|
+
# @raise [ConfigurationError] if any credential is blank
|
|
54
|
+
def initialize(
|
|
55
|
+
email:,
|
|
56
|
+
password:,
|
|
57
|
+
account_id:,
|
|
58
|
+
scopes: DEFAULT_SCOPES,
|
|
59
|
+
bulk_host: DEFAULT_BULK_HOST,
|
|
60
|
+
bapi_host: DEFAULT_BAPI_HOST,
|
|
61
|
+
reference_host: DEFAULT_REFERENCE_HOST,
|
|
62
|
+
timeout: DEFAULT_TIMEOUT,
|
|
63
|
+
open_timeout: DEFAULT_OPEN_TIMEOUT,
|
|
64
|
+
user_agent: "craigslist-api-ruby/#{VERSION}",
|
|
65
|
+
logger: nil,
|
|
66
|
+
adapter: Faraday.default_adapter
|
|
67
|
+
)
|
|
68
|
+
@email = presence!(email, :email)
|
|
69
|
+
@password = presence!(password, :password)
|
|
70
|
+
@account_id = presence!(account_id, :account_id).to_s
|
|
71
|
+
@scopes = Array(scopes).map(&:to_s).freeze
|
|
72
|
+
@bulk_host = bulk_host
|
|
73
|
+
@bapi_host = bapi_host
|
|
74
|
+
@reference_host = reference_host
|
|
75
|
+
@timeout = timeout
|
|
76
|
+
@open_timeout = open_timeout
|
|
77
|
+
@user_agent = user_agent
|
|
78
|
+
@logger = logger
|
|
79
|
+
@adapter = adapter
|
|
80
|
+
|
|
81
|
+
freeze
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# The OAuth2 +client_id+, which Craigslist defines as the account email
|
|
85
|
+
# and account id joined by a semicolon.
|
|
86
|
+
#
|
|
87
|
+
# @return [String]
|
|
88
|
+
def client_id
|
|
89
|
+
"#{email};#{account_id}"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# HTTP Basic credential for the token endpoint.
|
|
93
|
+
#
|
|
94
|
+
# Encoded with +Array#pack+ rather than the +base64+ gem, which stopped
|
|
95
|
+
# being a default gem in Ruby 3.4 and would otherwise add a dependency.
|
|
96
|
+
#
|
|
97
|
+
# @return [String]
|
|
98
|
+
def basic_authorization
|
|
99
|
+
"Basic #{["#{client_id}:#{password}"].pack("m0")}"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# @return [String] the URL RSS submissions are validated against
|
|
103
|
+
def validate_url
|
|
104
|
+
"#{bulk_host}/bulk-rss/validate"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# @return [String] the URL RSS submissions are posted to
|
|
108
|
+
def post_url
|
|
109
|
+
"#{bulk_host}/bulk-rss/post"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Redacts the password so credentials never leak into logs or exception
|
|
113
|
+
# output through a stray +inspect+.
|
|
114
|
+
#
|
|
115
|
+
# @return [String]
|
|
116
|
+
def inspect
|
|
117
|
+
"#<#{self.class.name} email=#{email.inspect} account_id=#{account_id.inspect} password=[FILTERED]>"
|
|
118
|
+
end
|
|
119
|
+
alias_method :to_s, :inspect
|
|
120
|
+
|
|
121
|
+
private
|
|
122
|
+
|
|
123
|
+
def presence!(value, name)
|
|
124
|
+
string = value.to_s.strip
|
|
125
|
+
raise ConfigurationError, "#{name} is required" if string.empty?
|
|
126
|
+
value.is_a?(String) ? string : value
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "faraday"
|
|
4
|
+
require "faraday/multipart"
|
|
5
|
+
|
|
6
|
+
module Craigslist
|
|
7
|
+
module API
|
|
8
|
+
# Builds the Faraday connections the client uses.
|
|
9
|
+
#
|
|
10
|
+
# Connections are built once per client and reused. The adapter is taken
|
|
11
|
+
# from configuration so host applications can swap in their own (or a test
|
|
12
|
+
# stub) without this gem forcing a choice on them.
|
|
13
|
+
module Connection
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
# @param url [String] base URL for the connection
|
|
17
|
+
# @param config [Configuration]
|
|
18
|
+
# @param multipart [Boolean] enable the multipart request middleware
|
|
19
|
+
# @return [Faraday::Connection]
|
|
20
|
+
def build(url:, config:, multipart: false)
|
|
21
|
+
Faraday.new(url: url) do |f|
|
|
22
|
+
f.request :multipart if multipart
|
|
23
|
+
|
|
24
|
+
f.headers["User-Agent"] = config.user_agent
|
|
25
|
+
f.options.timeout = config.timeout
|
|
26
|
+
f.options.open_timeout = config.open_timeout
|
|
27
|
+
|
|
28
|
+
f.response :logger, config.logger, headers: false, bodies: false if config.logger
|
|
29
|
+
|
|
30
|
+
f.adapter config.adapter
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Runs a request, translating Faraday's transport exceptions into this
|
|
35
|
+
# library's error hierarchy.
|
|
36
|
+
#
|
|
37
|
+
# Deliberately does not use Faraday's +:raise_error+ middleware. That
|
|
38
|
+
# middleware keys purely on HTTP status, and neither Craigslist API treats
|
|
39
|
+
# status as the source of truth — so status handling lives with the code
|
|
40
|
+
# that also understands in-band failures.
|
|
41
|
+
#
|
|
42
|
+
# @yieldreturn [Faraday::Response]
|
|
43
|
+
# @return [Faraday::Response]
|
|
44
|
+
# @raise [TimeoutError, ConnectionError]
|
|
45
|
+
def perform
|
|
46
|
+
yield
|
|
47
|
+
rescue Faraday::TimeoutError => e
|
|
48
|
+
raise TimeoutError, "request timed out: #{e.message}"
|
|
49
|
+
rescue Faraday::ConnectionFailed, Faraday::SSLError => e
|
|
50
|
+
raise ConnectionError, "connection failed: #{e.message}"
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Craigslist
|
|
4
|
+
module API
|
|
5
|
+
# Credit standing for an invoiced account.
|
|
6
|
+
class CreditSummary
|
|
7
|
+
# @return [Money, nil] total credit extended
|
|
8
|
+
attr_reader :credit_line
|
|
9
|
+
|
|
10
|
+
# @return [Money, nil] credit still available
|
|
11
|
+
attr_reader :remaining
|
|
12
|
+
|
|
13
|
+
# @return [Money, nil] credit consumed so far
|
|
14
|
+
attr_reader :used
|
|
15
|
+
|
|
16
|
+
def initialize(credit_line: nil, remaining: nil, used: nil)
|
|
17
|
+
@credit_line = credit_line
|
|
18
|
+
@remaining = remaining
|
|
19
|
+
@used = used
|
|
20
|
+
freeze
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @param hash [Hash] raw payload
|
|
24
|
+
# @return [CreditSummary]
|
|
25
|
+
def self.from(hash)
|
|
26
|
+
hash ||= {}
|
|
27
|
+
new(
|
|
28
|
+
credit_line: Money.from(hash["creditLine"]),
|
|
29
|
+
remaining: Money.from(hash["creditRemaining"]),
|
|
30
|
+
used: Money.from(hash["creditUsed"])
|
|
31
|
+
)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def inspect
|
|
35
|
+
"#<#{self.class.name} line=#{credit_line} remaining=#{remaining} used=#{used}>"
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Craigslist
|
|
6
|
+
module API
|
|
7
|
+
# The response wrapper every JSON Bulkpost endpoint returns.
|
|
8
|
+
#
|
|
9
|
+
# {"apiVersion": 1, "data": {...}, "errors": [], "accountMessages": []}
|
|
10
|
+
#
|
|
11
|
+
# Note that +errors+ can be populated on an HTTP 200 — status alone is not
|
|
12
|
+
# enough to tell whether a call worked.
|
|
13
|
+
class Envelope
|
|
14
|
+
# @return [Numeric, nil]
|
|
15
|
+
attr_reader :api_version
|
|
16
|
+
|
|
17
|
+
# @return [Hash, Array, nil] the useful payload
|
|
18
|
+
attr_reader :data
|
|
19
|
+
|
|
20
|
+
# @return [Array<Hash>] +{"code", "message"}+ entries
|
|
21
|
+
attr_reader :errors
|
|
22
|
+
|
|
23
|
+
# @return [Array<Hash>] +{"messageId", "message"}+ notices, which keep
|
|
24
|
+
# appearing until acknowledged via {Resources::Account#acknowledge}
|
|
25
|
+
attr_reader :account_messages
|
|
26
|
+
|
|
27
|
+
def initialize(api_version: nil, data: nil, errors: [], account_messages: [])
|
|
28
|
+
@api_version = api_version
|
|
29
|
+
@data = data
|
|
30
|
+
@errors = Array(errors)
|
|
31
|
+
@account_messages = Array(account_messages)
|
|
32
|
+
freeze
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# @param body [String] raw JSON response body
|
|
36
|
+
# @return [Envelope]
|
|
37
|
+
# @raise [ParseError] if the body is not JSON
|
|
38
|
+
def self.parse(body)
|
|
39
|
+
payload = JSON.parse(body.to_s)
|
|
40
|
+
|
|
41
|
+
# A few error paths return a bare object rather than a full envelope.
|
|
42
|
+
payload = {} unless payload.is_a?(Hash)
|
|
43
|
+
|
|
44
|
+
new(
|
|
45
|
+
api_version: payload["apiVersion"],
|
|
46
|
+
data: payload["data"],
|
|
47
|
+
errors: payload["errors"],
|
|
48
|
+
account_messages: payload["accountMessages"]
|
|
49
|
+
)
|
|
50
|
+
rescue JSON::ParserError => e
|
|
51
|
+
raise ParseError, "expected JSON from the Bulkpost API: #{e.message}"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# @return [Boolean]
|
|
55
|
+
def error?
|
|
56
|
+
!errors.empty?
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# @return [String] errors joined into one human-readable line
|
|
60
|
+
def error_message
|
|
61
|
+
errors.map { |e| e["message"] }.compact.join("; ")
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|