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,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