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,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Craigslist
4
+ module API
5
+ # Base class for every error raised by this library. Rescuing this catches
6
+ # anything the gem raises on purpose.
7
+ class Error < StandardError; end
8
+
9
+ # Raised when the client is constructed with missing or nonsensical
10
+ # credentials or options.
11
+ class ConfigurationError < Error; end
12
+
13
+ # Raised when a {Posting} is missing fields the interface requires, caught
14
+ # locally before a request is made.
15
+ class ValidationError < Error
16
+ # @return [Array<String>] every problem found, not just the first
17
+ attr_reader :errors
18
+
19
+ def initialize(errors)
20
+ @errors = Array(errors)
21
+ super(@errors.join("; "))
22
+ end
23
+ end
24
+
25
+ # Raised when a request never produced a usable HTTP response: DNS failure,
26
+ # connection refused, TLS problems, timeouts.
27
+ class ConnectionError < Error; end
28
+
29
+ # Raised when a request exceeded the configured timeout.
30
+ class TimeoutError < ConnectionError; end
31
+
32
+ # Raised when a response arrived but could not be parsed as the XML or JSON
33
+ # the endpoint promised.
34
+ class ParseError < Error; end
35
+
36
+ # Raised when Craigslist returned a response we understand as a failure.
37
+ #
38
+ # Carries the HTTP status and raw body so callers can inspect what actually
39
+ # came back, plus any structured errors extracted from a JSON envelope.
40
+ class ResponseError < Error
41
+ # @return [Integer, nil] HTTP status code
42
+ attr_reader :status
43
+
44
+ # @return [String, nil] raw response body
45
+ attr_reader :body
46
+
47
+ # @return [Array<Hash>] structured +{code:, message:}+ entries, when present
48
+ attr_reader :api_errors
49
+
50
+ def initialize(message = nil, status: nil, body: nil, api_errors: [])
51
+ @status = status
52
+ @body = body
53
+ @api_errors = api_errors
54
+ super(message)
55
+ end
56
+ end
57
+
58
+ # Raised for malformed requests. The RSS interface uses HTTP 415 for RSS it
59
+ # cannot parse; the JSON API uses 400.
60
+ class RequestError < ResponseError; end
61
+
62
+ # Raised when credentials were rejected, or the account is not authorized
63
+ # for bulk posting. Covers HTTP 401 and 403, and OAuth token failures.
64
+ class AuthenticationError < ResponseError; end
65
+
66
+ # Raised on HTTP 404.
67
+ class NotFoundError < ResponseError; end
68
+
69
+ # Raised on HTTP 429.
70
+ class RateLimitError < ResponseError; end
71
+
72
+ # Raised on any 5xx.
73
+ class ServerError < ResponseError; end
74
+
75
+ # Raised when a JSON response arrived with HTTP 200 but carried a non-empty
76
+ # +errors+ array in its envelope.
77
+ #
78
+ # This is separate from {ResponseError} subclasses keyed to status codes
79
+ # because the Bulkpost API reports application-level failures in-band.
80
+ class APIError < ResponseError; end
81
+ end
82
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ module Craigslist
6
+ module API
7
+ # An image attached to a bulk posting.
8
+ #
9
+ # The RSS interface takes images inline as base64 JPEG data, up to 24 per
10
+ # posting. Position is zero-based and the image at position 0 is the one
11
+ # featured on search pages.
12
+ class Image
13
+ # Craigslist rejects postings carrying more than this many images.
14
+ MAX_PER_POSTING = 24
15
+
16
+ # Highest valid zero-based position.
17
+ MAX_POSITION = MAX_PER_POSTING - 1
18
+
19
+ # @return [String] base64-encoded image data
20
+ attr_reader :data
21
+
22
+ # @return [Integer, nil] zero-based position within the posting
23
+ attr_reader :position
24
+
25
+ # @param data [String] already base64-encoded data
26
+ # @param position [Integer, nil]
27
+ def initialize(data, position: nil)
28
+ @data = data
29
+ @position = position
30
+ freeze
31
+ end
32
+
33
+ class << self
34
+ # Coerces a caller-supplied image into an {Image}.
35
+ #
36
+ # Accepts an existing {Image}, an IO-like object, or a path as a String
37
+ # or Pathname. Raw base64 has to go through {from_base64} — guessing
38
+ # whether a String is a path or a payload would be worse than asking.
39
+ #
40
+ # @param source [Image, IO, Pathname, String]
41
+ # @param position [Integer, nil]
42
+ # @return [Image]
43
+ # @raise [ValidationError] if the source type is not supported
44
+ def wrap(source, position: nil)
45
+ case source
46
+ when Image
47
+ position.nil? ? source : new(source.data, position: position)
48
+ when Pathname
49
+ from_file(source, position: position)
50
+ when String
51
+ from_file(source, position: position)
52
+ else
53
+ if source.respond_to?(:read)
54
+ from_io(source, position: position)
55
+ else
56
+ raise ValidationError, ["cannot build an image from #{source.class}"]
57
+ end
58
+ end
59
+ end
60
+
61
+ # @param path [String, Pathname]
62
+ # @return [Image]
63
+ def from_file(path, position: nil)
64
+ from_data(File.binread(path.to_s), position: position)
65
+ rescue SystemCallError => e
66
+ raise ValidationError, ["could not read image #{path}: #{e.message}"]
67
+ end
68
+
69
+ # @param io [IO]
70
+ # @return [Image]
71
+ def from_io(io, position: nil)
72
+ io.binmode if io.respond_to?(:binmode)
73
+ from_data(io.read, position: position)
74
+ end
75
+
76
+ # @param binary [String] raw (unencoded) image bytes
77
+ # @return [Image]
78
+ def from_data(binary, position: nil)
79
+ # pack("m") produces RFC 2045 base64 with line breaks, matching the
80
+ # form Craigslist's own documentation shows. Using Array#pack rather
81
+ # than the base64 gem keeps this dependency-free on Ruby 3.4+.
82
+ new([binary].pack("m"), position: position)
83
+ end
84
+
85
+ # @param encoded [String] data that is already base64
86
+ # @return [Image]
87
+ def from_base64(encoded, position: nil)
88
+ new(encoded, position: position)
89
+ end
90
+ end
91
+
92
+ # @return [Image] a copy pinned to +index+ when no position was set
93
+ def with_default_position(index)
94
+ position.nil? ? self.class.new(data, position: index) : self
95
+ end
96
+
97
+ def inspect
98
+ "#<#{self.class.name} position=#{position.inspect} bytes=#{data.bytesize}>"
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Craigslist
4
+ module API
5
+ # Metadata about an image attached to a live posting.
6
+ class ImageInfo
7
+ # @return [String] craigslist's image id, e.g. "4:00101_b1ztTgNtBAU"
8
+ attr_reader :id
9
+
10
+ # @return [String, nil]
11
+ attr_reader :filename
12
+
13
+ # @return [String, nil] e.g. "JPEG", "GIF", "WEBP"
14
+ attr_reader :format
15
+
16
+ # @return [Integer, nil]
17
+ attr_reader :width
18
+
19
+ # @return [Integer, nil]
20
+ attr_reader :height
21
+
22
+ # @return [Integer, nil] zero-based position within the posting
23
+ attr_reader :position
24
+
25
+ def initialize(id:, filename: nil, format: nil, width: nil, height: nil, position: nil)
26
+ @id = id
27
+ @filename = filename
28
+ @format = format
29
+ @width = width
30
+ @height = height
31
+ @position = position
32
+ freeze
33
+ end
34
+
35
+ # @param hash [Hash] raw payload
36
+ # @return [ImageInfo]
37
+ def self.from(hash)
38
+ new(
39
+ id: hash["id"],
40
+ filename: hash["filename"],
41
+ format: hash["format"],
42
+ width: hash["width"],
43
+ height: hash["height"],
44
+ position: hash["position"]
45
+ )
46
+ end
47
+
48
+ def inspect
49
+ "#<#{self.class.name} id=#{id.inspect} position=#{position.inspect} #{width}x#{height} #{format}>"
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Craigslist
6
+ module API
7
+ # Request plumbing for the JSON Bulkpost API.
8
+ #
9
+ # Owns three concerns the resource classes should not repeat: attaching a
10
+ # bearer token (refreshing once on a 401), mapping HTTP status onto this
11
+ # library's error classes, and unwrapping the response envelope.
12
+ class JsonTransport
13
+ # Writes to the Bulkpost API are form-encoded, not JSON, even though the
14
+ # responses are JSON.
15
+ FORM_CONTENT_TYPE = "application/x-www-form-urlencoded"
16
+
17
+ # @return [Array<Hash>] account notices seen on the most recent response
18
+ attr_reader :account_messages
19
+
20
+ def initialize(config:, connection:, token_provider:)
21
+ @config = config
22
+ @connection = connection
23
+ @token_provider = token_provider
24
+ @account_messages = []
25
+ end
26
+
27
+ # @return [Envelope]
28
+ def get(path, params: {})
29
+ run(:get, path, params: params)
30
+ end
31
+
32
+ # @return [Envelope]
33
+ def post(path, form: nil)
34
+ run(:post, path, form: form)
35
+ end
36
+
37
+ # @return [Envelope]
38
+ def put(path, form: nil)
39
+ run(:put, path, form: form)
40
+ end
41
+
42
+ # @return [Envelope]
43
+ def delete(path)
44
+ run(:delete, path)
45
+ end
46
+
47
+ # Uploads a file as +multipart/form-data+.
48
+ #
49
+ # @param path [String]
50
+ # @param part [Faraday::Multipart::FilePart]
51
+ # @param fields [Hash] additional form fields
52
+ # @return [Envelope]
53
+ def upload(path, part:, fields: {})
54
+ run(:put, path, body: fields.merge("file" => part), content_type: nil)
55
+ end
56
+
57
+ private
58
+
59
+ def run(method, path, params: {}, form: nil, body: nil, content_type: FORM_CONTENT_TYPE, retried: false)
60
+ response = Connection.perform do
61
+ @connection.public_send(method, path) do |req|
62
+ req.headers["Authorization"] = @token_provider.token.to_header
63
+ req.headers["Accept"] = "application/json"
64
+ req.params.update(stringify(params)) unless params.empty?
65
+
66
+ if form && !form.empty?
67
+ req.headers["Content-Type"] = content_type if content_type
68
+ req.body = URI.encode_www_form(form)
69
+ elsif body
70
+ req.body = body
71
+ end
72
+ end
73
+ end
74
+
75
+ # A 401 can precede the recorded expiry if the token was revoked, so
76
+ # give it exactly one refresh-and-retry before surfacing the failure.
77
+ if response.status == 401 && !retried
78
+ @token_provider.invalidate!
79
+ return run(method, path,
80
+ params: params, form: form, body: body,
81
+ content_type: content_type, retried: true)
82
+ end
83
+
84
+ handle(response)
85
+ end
86
+
87
+ def handle(response)
88
+ envelope = parse_envelope(response)
89
+ @account_messages = envelope&.account_messages || []
90
+
91
+ raise_for_status(response, envelope) unless response.success?
92
+
93
+ # Only reachable on a 2xx, so an unparseable body here is a genuine
94
+ # surprise rather than an error page.
95
+ if envelope.nil?
96
+ raise ParseError, "expected JSON from the Bulkpost API, got #{response.body.to_s[0, 200].inspect}"
97
+ end
98
+
99
+ # HTTP 200 with a populated errors array is a real failure mode here.
100
+ if envelope.error?
101
+ raise APIError.new(
102
+ envelope.error_message,
103
+ status: response.status,
104
+ body: response.body,
105
+ api_errors: envelope.errors
106
+ )
107
+ end
108
+
109
+ envelope
110
+ end
111
+
112
+ # Returns nil only when a non-empty body could not be parsed. An empty
113
+ # body is treated as an empty envelope, since some writes reply with no
114
+ # payload at all.
115
+ def parse_envelope(response)
116
+ body = response.body.to_s
117
+ return Envelope.new if body.strip.empty?
118
+
119
+ Envelope.parse(body)
120
+ rescue ParseError
121
+ nil
122
+ end
123
+
124
+ def raise_for_status(response, envelope)
125
+ message = envelope&.error? ? envelope.error_message : "HTTP #{response.status}"
126
+
127
+ raise error_class(response.status).new(
128
+ message,
129
+ status: response.status,
130
+ body: response.body,
131
+ api_errors: envelope&.errors || []
132
+ )
133
+ end
134
+
135
+ def error_class(status)
136
+ case status
137
+ when 400 then RequestError
138
+ when 401, 403 then AuthenticationError
139
+ when 404 then NotFoundError
140
+ when 429 then RateLimitError
141
+ when 500..599 then ServerError
142
+ else ResponseError
143
+ end
144
+ end
145
+
146
+ def stringify(params)
147
+ params.each_with_object({}) do |(key, value), memo|
148
+ memo[key.to_s] = value unless value.nil?
149
+ end
150
+ end
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Craigslist
4
+ module API
5
+ # A monetary amount as the Bulkpost API reports it: an integer in minor
6
+ # units plus the exponent needed to scale it.
7
+ #
8
+ # +{amount: 100000, currency: "USD", exponent: 2}+ is $1,000.00.
9
+ #
10
+ # Conversion goes through +Rational+ rather than +Float+ so the scaling is
11
+ # exact, and deliberately avoids +BigDecimal+, which stopped being a default
12
+ # gem in Ruby 3.4 and would add a dependency.
13
+ class Money
14
+ # @return [Integer] value in minor units
15
+ attr_reader :amount
16
+
17
+ # @return [String] ISO 4217 currency code
18
+ attr_reader :currency
19
+
20
+ # @return [Integer] power of ten separating minor units from major
21
+ attr_reader :exponent
22
+
23
+ def initialize(amount:, currency: "USD", exponent: 2)
24
+ @amount = amount.to_i
25
+ @currency = currency.to_s
26
+ @exponent = exponent.to_i
27
+ freeze
28
+ end
29
+
30
+ # @param hash [Hash, nil] raw +{"amount", "currency", "exponent"}+ payload
31
+ # @return [Money, nil]
32
+ def self.from(hash)
33
+ return nil if hash.nil?
34
+
35
+ new(
36
+ amount: hash["amount"],
37
+ currency: hash.fetch("currency", "USD"),
38
+ exponent: hash.fetch("exponent", 2)
39
+ )
40
+ end
41
+
42
+ # @return [Rational] exact value in major units
43
+ def to_r
44
+ Rational(amount, 10**exponent)
45
+ end
46
+
47
+ # @return [Float] value in major units, with the usual float caveats
48
+ def to_f
49
+ to_r.to_f
50
+ end
51
+
52
+ # @return [String] e.g. "1000.00 USD"
53
+ def to_s
54
+ "#{format("%.#{exponent}f", to_r)} #{currency}"
55
+ end
56
+
57
+ # @param other [Object]
58
+ # @return [Boolean] true when amount, currency and exponent all match
59
+ def ==(other)
60
+ other.is_a?(Money) &&
61
+ amount == other.amount &&
62
+ currency == other.currency &&
63
+ exponent == other.exponent
64
+ end
65
+ alias_method :eql?, :==
66
+
67
+ # @return [Integer] value-based hash, so Money can key a Hash
68
+ def hash
69
+ [amount, currency, exponent].hash
70
+ end
71
+
72
+ def inspect
73
+ "#<#{self.class.name} #{self}>"
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,216 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Craigslist
4
+ module API
5
+ # A posting to be submitted through the bulk RSS interface.
6
+ #
7
+ # Validated on construction, so a malformed posting fails locally instead of
8
+ # consuming a round trip. Everything is frozen afterwards.
9
+ #
10
+ # The interface exposes two generations of optional fields. This models the
11
+ # newer flat attribute groups (+cl:housing_basics+, +cl:job_basics+,
12
+ # +cl:auto_basics+, +cl:forsale+, +cl:generic+, +cl:housing_terms+,
13
+ # +cl:housing_pets+), whose keys are already snake_case and pass through
14
+ # unchanged. The legacy camelCase groups (+cl:housingInfo+, +cl:jobInfo+)
15
+ # are not modelled; +cl:brokerInfo+ is, because housing postings still need
16
+ # it and it has no modern equivalent.
17
+ #
18
+ # @example A minimal for-sale posting
19
+ # Craigslist::API::Posting.new(
20
+ # key: "listing-1",
21
+ # title: "1998 Toyota Hilux",
22
+ # description: "Runs great.",
23
+ # category: "ctd",
24
+ # area: "sfo",
25
+ # price: 4500,
26
+ # reply_email: "sales@example.com",
27
+ # location: {postal: "94110"}
28
+ # )
29
+ class Posting
30
+ # How the reply address is displayed on the posting.
31
+ PRIVACY = {
32
+ none: "A", # show no email address
33
+ anonymous: "C", # use an anonymous craigslist relay address
34
+ public: "P" # show the address as given
35
+ }.freeze
36
+
37
+ # Ruby-side names for +cl:mapLocation+ attributes, which are camelCase on
38
+ # the wire.
39
+ LOCATION_ATTRIBUTES = {
40
+ postal: "postal",
41
+ city: "city",
42
+ state: "state",
43
+ cross_street1: "crossStreet1",
44
+ cross_street2: "crossStreet2",
45
+ latitude: "latitude",
46
+ longitude: "longitude"
47
+ }.freeze
48
+
49
+ # Ruby-side names for +cl:brokerInfo+ attributes.
50
+ BROKER_ATTRIBUTES = {
51
+ company_name: "companyName",
52
+ fee_disclosure: "feeDisclosure"
53
+ }.freeze
54
+
55
+ attr_reader :key, :title, :description, :category, :area, :subarea,
56
+ :neighborhood, :price, :po_number, :reply_email, :reply_privacy,
57
+ :other_contact_info, :location, :images, :broker, :generic,
58
+ :housing_basics, :housing_pets, :housing_terms, :job_basics,
59
+ :auto_basics, :forsale
60
+
61
+ # @param key [String] identifier unique within the submission document.
62
+ # Echoed back on the matching result, and how you correlate a response
63
+ # to what you sent.
64
+ # @param title [String]
65
+ # @param description [String] the posting body
66
+ # @param category [String] category abbreviation, e.g. "ctd"
67
+ # @param area [String] area abbreviation, e.g. "sfo"
68
+ # @param subarea [String, nil] required in areas that have subareas
69
+ # @param neighborhood [String, nil]
70
+ # @param price [Integer, nil]
71
+ # @param po_number [String, nil] your own tracking reference
72
+ # @param reply_email [String, nil]
73
+ # @param reply_privacy [Symbol, String] one of +:none+, +:anonymous+,
74
+ # +:public+, or a raw "A"/"C"/"P"
75
+ # @param other_contact_info [String, nil] free-text alternate contact
76
+ # @param location [Hash] see {LOCATION_ATTRIBUTES}. Either +:postal+ or
77
+ # both +:latitude+ and +:longitude+ is required.
78
+ # @param images [Array<Image, IO, String, Pathname>] see {Image.wrap}
79
+ # @param broker [Hash] see {BROKER_ATTRIBUTES}
80
+ # @param generic [Hash] +cl:generic+ attributes
81
+ # @param housing_basics [Hash] +cl:housing_basics+ attributes
82
+ # @param housing_pets [Hash] +cl:housing_pets+ attributes
83
+ # @param housing_terms [Hash] +cl:housing_terms+ attributes
84
+ # @param job_basics [Hash] +cl:job_basics+ attributes
85
+ # @param auto_basics [Hash] +cl:auto_basics+ attributes
86
+ # @param forsale [Hash] +cl:forsale+ attributes
87
+ # @raise [ValidationError] if anything required is missing
88
+ def initialize(
89
+ key:,
90
+ title:,
91
+ description:,
92
+ category:,
93
+ area:,
94
+ subarea: nil,
95
+ neighborhood: nil,
96
+ price: nil,
97
+ po_number: nil,
98
+ reply_email: nil,
99
+ reply_privacy: :anonymous,
100
+ other_contact_info: nil,
101
+ location: {},
102
+ images: [],
103
+ broker: {},
104
+ generic: {},
105
+ housing_basics: {},
106
+ housing_pets: {},
107
+ housing_terms: {},
108
+ job_basics: {},
109
+ auto_basics: {},
110
+ forsale: {}
111
+ )
112
+ @key = key.to_s
113
+ @title = title.to_s
114
+ @description = description.to_s
115
+ @category = category.to_s
116
+ @area = area.to_s
117
+ @subarea = subarea&.to_s
118
+ @neighborhood = neighborhood&.to_s
119
+ @price = price
120
+ @po_number = po_number&.to_s
121
+ @reply_email = reply_email&.to_s
122
+ @reply_privacy = normalize_privacy(reply_privacy)
123
+ @other_contact_info = other_contact_info&.to_s
124
+ @location = compact(location).freeze
125
+ @images = build_images(images).freeze
126
+ @broker = compact(broker).freeze
127
+ @generic = compact(generic).freeze
128
+ @housing_basics = compact(housing_basics).freeze
129
+ @housing_pets = compact(housing_pets).freeze
130
+ @housing_terms = compact(housing_terms).freeze
131
+ @job_basics = compact(job_basics).freeze
132
+ @auto_basics = compact(auto_basics).freeze
133
+ @forsale = compact(forsale).freeze
134
+
135
+ validate!
136
+ freeze
137
+ end
138
+
139
+ # @return [Array<String>] problems with this posting, empty when valid
140
+ def errors
141
+ problems = []
142
+ problems << "key is required" if key.empty?
143
+ problems << "title is required" if title.empty?
144
+ problems << "description is required" if description.empty?
145
+ problems << "category is required" if category.empty?
146
+ problems << "area is required" if area.empty?
147
+ problems.concat(location_errors)
148
+ problems.concat(image_errors)
149
+ problems
150
+ end
151
+
152
+ # @return [Boolean]
153
+ def valid?
154
+ errors.empty?
155
+ end
156
+
157
+ def inspect
158
+ "#<#{self.class.name} key=#{key.inspect} category=#{category.inspect} " \
159
+ "area=#{area.inspect} images=#{images.size}>"
160
+ end
161
+
162
+ private
163
+
164
+ def validate!
165
+ found = errors
166
+ raise ValidationError, found unless found.empty?
167
+ end
168
+
169
+ def location_errors
170
+ has_postal = !location[:postal].to_s.empty?
171
+ has_coords = !location[:latitude].nil? && !location[:longitude].nil?
172
+ return [] if has_postal || has_coords
173
+
174
+ ["location requires :postal, or both :latitude and :longitude"]
175
+ end
176
+
177
+ def image_errors
178
+ problems = []
179
+
180
+ if images.size > Image::MAX_PER_POSTING
181
+ problems << "a posting accepts at most #{Image::MAX_PER_POSTING} images, got #{images.size}"
182
+ end
183
+
184
+ positions = images.map(&:position).compact
185
+ out_of_range = positions.reject { |p| p.between?(0, Image::MAX_POSITION) }
186
+ unless out_of_range.empty?
187
+ problems << "image positions must be 0..#{Image::MAX_POSITION}, got #{out_of_range.inspect}"
188
+ end
189
+
190
+ duplicates = positions.tally.select { |_, count| count > 1 }.keys
191
+ problems << "duplicate image positions: #{duplicates.inspect}" unless duplicates.empty?
192
+
193
+ problems
194
+ end
195
+
196
+ def build_images(sources)
197
+ Array(sources).each_with_index.map do |source, index|
198
+ Image.wrap(source).with_default_position(index)
199
+ end
200
+ end
201
+
202
+ def normalize_privacy(value)
203
+ return PRIVACY.fetch(value) if value.is_a?(Symbol) && PRIVACY.key?(value)
204
+
205
+ string = value.to_s.upcase
206
+ return string if PRIVACY.value?(string)
207
+
208
+ raise ValidationError, ["reply_privacy must be one of #{PRIVACY.keys.inspect} or #{PRIVACY.values.inspect}"]
209
+ end
210
+
211
+ def compact(hash)
212
+ (hash || {}).reject { |_, value| value.nil? }
213
+ end
214
+ end
215
+ end
216
+ end