guesty-client 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 (63) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +34 -0
  3. data/LICENSE +21 -0
  4. data/README.md +178 -0
  5. data/lib/guesty/access_tokens/upsert.rb +61 -0
  6. data/lib/guesty/api_error.rb +73 -0
  7. data/lib/guesty/client.rb +138 -0
  8. data/lib/guesty/config.rb +29 -0
  9. data/lib/guesty/conversations/attachments/index.rb +42 -0
  10. data/lib/guesty/conversations/index.rb +58 -0
  11. data/lib/guesty/conversations/messages/reply.rb +55 -0
  12. data/lib/guesty/conversations/posts/index.rb +121 -0
  13. data/lib/guesty/conversations/posts/since.rb +37 -0
  14. data/lib/guesty/conversations/search.rb +31 -0
  15. data/lib/guesty/conversations/show.rb +25 -0
  16. data/lib/guesty/conversations.rb +15 -0
  17. data/lib/guesty/error.rb +32 -0
  18. data/lib/guesty/guest_folio/invoice_items/index.rb +61 -0
  19. data/lib/guesty/guests/profiles/create.rb +25 -0
  20. data/lib/guesty/guests/profiles/show.rb +23 -0
  21. data/lib/guesty/guests/profiles/update.rb +36 -0
  22. data/lib/guesty/guests.rb +5 -0
  23. data/lib/guesty/homes/calendars/show.rb +134 -0
  24. data/lib/guesty/homes/search.rb +46 -0
  25. data/lib/guesty/memory_rate_limiter.rb +28 -0
  26. data/lib/guesty/memory_token_store.rb +33 -0
  27. data/lib/guesty/operation.rb +37 -0
  28. data/lib/guesty/rate_limiter_middleware.rb +23 -0
  29. data/lib/guesty/reservations/accommodation_fare/update.rb +44 -0
  30. data/lib/guesty/reservations/approve.rb +23 -0
  31. data/lib/guesty/reservations/create.rb +50 -0
  32. data/lib/guesty/reservations/custom_fields/update.rb +26 -0
  33. data/lib/guesty/reservations/dates/update.rb +41 -0
  34. data/lib/guesty/reservations/decline.rb +30 -0
  35. data/lib/guesty/reservations/flags/update.rb +33 -0
  36. data/lib/guesty/reservations/payments/create.rb +72 -0
  37. data/lib/guesty/reservations/pre_approve.rb +23 -0
  38. data/lib/guesty/reservations/recover.rb +27 -0
  39. data/lib/guesty/reservations/reviews/extractors/airbnb.rb +66 -0
  40. data/lib/guesty/reservations/reviews/extractors/base.rb +36 -0
  41. data/lib/guesty/reservations/reviews/extractors/booking_com.rb +35 -0
  42. data/lib/guesty/reservations/reviews/extractors/vrbo.rb +46 -0
  43. data/lib/guesty/reservations/reviews/index.rb +67 -0
  44. data/lib/guesty/reservations/search.rb +63 -0
  45. data/lib/guesty/reservations/show.rb +28 -0
  46. data/lib/guesty/reservations/status/cancel.rb +34 -0
  47. data/lib/guesty/reservations/status/update.rb +21 -0
  48. data/lib/guesty/reservations/stays/update.rb +42 -0
  49. data/lib/guesty/reservations/update.rb +19 -0
  50. data/lib/guesty/reservations.rb +13 -0
  51. data/lib/guesty/reviews/respond.rb +19 -0
  52. data/lib/guesty/version.rb +3 -0
  53. data/lib/guesty/views/create.rb +19 -0
  54. data/lib/guesty/views/update.rb +33 -0
  55. data/lib/guesty/webhooks/create.rb +21 -0
  56. data/lib/guesty/webhooks/index.rb +18 -0
  57. data/lib/guesty/webhooks/signatures/verify.rb +76 -0
  58. data/lib/guesty/webhooks/sync.rb +36 -0
  59. data/lib/guesty/webhooks/update.rb +22 -0
  60. data/lib/guesty-client.rb +1 -0
  61. data/lib/guesty.rb +78 -0
  62. data/sig/guesty.rbs +427 -0
  63. metadata +191 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 83257fa001c6c49e93fc8e82a589f7021c5924e79aa37c4aba1ac39016190b91
4
+ data.tar.gz: 47f958e92a9567eaea516c9e65e1842fbf801e0f2bf298156f043e90ace89abc
5
+ SHA512:
6
+ metadata.gz: b7d52343654868afa7b48544b81f088214c7201c89f408ea24104ad8c9fb7642466b53aab13be87e4d56cba4dada62578871c322dddc778255df010023b0b79b
7
+ data.tar.gz: faec56e53c7a5477e98a7db05c5d4156ed0e096764793a5a23ccd68ba93d50a0da102ad9cb7ce16c699e2cb30b9ae272a53c7960582d47474d7e9a453192fdaf
data/CHANGELOG.md ADDED
@@ -0,0 +1,34 @@
1
+ # Changelog
2
+
3
+ ## [Unreleased]
4
+
5
+ ## [0.1.0] - 2026-11-08
6
+
7
+ First release as `guesty-client`, extracted from the Guesty integration Guestit runs in production. The specs are
8
+ that integration's provider specs, ported with their recorded VCR cassettes.
9
+
10
+ ### Added
11
+
12
+ - `Guesty.configure` for credentials per priority lane, the token store, the rate limiter, the slot picker, custom
13
+ field ids and the default currency. No Rails dependency.
14
+ - Wire API on the `Guesty` module: `connection`, `do_request` with retries of known Guesty glitches, `with_priority`
15
+ lanes, credential slots (`token_slots`, `pick_slot`), the error helpers `retryable_read?`, `unapplied?` and
16
+ `delivery_unknown?`, and `webhook_key`.
17
+ - Rate limits of 10 requests a second, 120 a minute and 5,000 an hour per credential slot.
18
+ - `sig/guesty.rbs` declaring the public API.
19
+ - Operations, each returning a dry-monads `Result`:
20
+ - `AccessTokens::Upsert`: OAuth client-credentials token, one stored token per slot.
21
+ - Reservations: `Search`, `Show`, `Create`, `Update`, `Recover`, `Approve`, `Decline`, `PreApprove`,
22
+ `Status::Update`, `Status::Cancel`, `Dates::Update`, `Stays::Update`, `Flags::Update`,
23
+ `AccommodationFare::Update`, `CustomFields::Update`, `Payments::Create`.
24
+ - Conversations: `Index`, `Show`, `Search`, `Posts::Index`, `Posts::Since`, `Messages::Reply`,
25
+ `Attachments::Index`, and the `Conversations.channel` and `Reservations.from_conversation` helpers.
26
+ - Reviews: `Reviews::Respond` and `Reservations::Reviews::Index` with extractors for Airbnb, Booking.com and Vrbo.
27
+ - Guests: `Profiles::Create`, `Profiles::Show`, `Profiles::Update`.
28
+ - Homes: `Search`, `Calendars::Show`.
29
+ - `GuestFolio::InvoiceItems::Index` for Airbnb Resolution Center items.
30
+ - Webhooks: `Index`, `Create`, `Update`, `Sync`, `Signatures::Verify` for Svix signatures.
31
+ - Views: `Create`, `Update`.
32
+
33
+ [Unreleased]: https://github.com/guestitab/guesty-client/compare/v0.1.0...staging
34
+ [0.1.0]: https://github.com/guestitab/guesty-client/releases/tag/v0.1.0
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Guestit
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,178 @@
1
+ # guesty-client
2
+
3
+ A Ruby client for the [Guesty Open API](https://open-api-docs.guesty.com/). Each endpoint is one operation class that
4
+ returns a [dry-monads](https://dry-rb.org/gems/dry-monads/) `Result`. The gem handles OAuth tokens, rate limits,
5
+ retries and webhook signatures. It does not depend on Rails, only on ActiveSupport and Faraday. Your app supplies the
6
+ credentials, the token storage and the rate limiter.
7
+
8
+ [Guestit](https://guestit.se) built it and runs it in production.
9
+
10
+ Requires Ruby 4.0 or later.
11
+
12
+ ## Installation
13
+
14
+ ```ruby
15
+ # Gemfile
16
+ gem 'guesty-client'
17
+ ```
18
+
19
+ Bundler loads it for you. Outside Bundler, `require 'guesty'`.
20
+
21
+ ## Configuration
22
+
23
+ ```ruby
24
+ Guesty.configure do |config|
25
+ config.credentials = {
26
+ default: [
27
+ { client_id: ENV.fetch('GUESTY_CLIENT_ID'), secret: ENV.fetch('GUESTY_CLIENT_SECRET') }
28
+ ],
29
+ high: [
30
+ { client_id: ENV.fetch('GUESTY_HIGH_CLIENT_ID'), secret: ENV.fetch('GUESTY_HIGH_CLIENT_SECRET') }
31
+ ],
32
+ webhook_key: ENV.fetch('GUESTY_WEBHOOK_KEY')
33
+ }
34
+ config.token_store = MyTokenStore.new
35
+ config.rate_limiter = MyRateLimiter.new
36
+ end
37
+ ```
38
+
39
+ | Setting | Default | What it does |
40
+ | --- | --- | --- |
41
+ | `credentials` | `{}` | Client credentials per priority lane, plus the webhook signing key. |
42
+ | `token_store` | `Guesty::MemoryTokenStore` | Holds one access token per credential slot. |
43
+ | `rate_limiter` | `Guesty::MemoryRateLimiter` | Counts requests per slot and window. |
44
+ | `slot_picker` | first slot | `->(slots, priority) { slot }`, called when a lane has several credentials. |
45
+ | `custom_fields` | `{}` | Account custom field ids by flag name, read by `Reservations::Flags::Update`. |
46
+ | `default_currency` | `nil` | When set, `Homes::Calendars::Show` refuses a calendar priced in another currency. |
47
+ | `base_url` | `https://open-api.guesty.com/v1/` | API root. |
48
+
49
+ ### Access tokens
50
+
51
+ Guesty mints 5 access tokens per client id per 24 hours and answers 429 to every request after that. The default
52
+ memory store loses its tokens when the process exits, so it suits a console or a single long-running process. Once
53
+ you run more than one process, or restart often, inject a shared store that keeps each token until it expires:
54
+
55
+ ```ruby
56
+ class MyTokenStore
57
+ # Returns an object that responds to #access_token, or nil when no live token is stored.
58
+ def read(slot_key); end
59
+
60
+ # Persists the token and returns an object that responds to #access_token.
61
+ def save(slot_key:, access_token:, expires_at:); end
62
+ end
63
+ ```
64
+
65
+ ### Rate limits
66
+
67
+ Every request passes three windows per credential slot: 10 a second, 120 a minute and 5,000 an hour. The limiter
68
+ raises `Guesty::ThrottledError` when a window is full, and the request is never sent. The memory limiter counts per
69
+ process. With several processes, inject one backed by Redis or your cache:
70
+
71
+ ```ruby
72
+ class MyRateLimiter
73
+ # Raises Guesty::ThrottledError.new(key:, threshold:, interval:, retry_after:) once
74
+ # more than threshold calls arrive for key within interval.
75
+ def throttle!(key:, threshold:, interval:); end
76
+ end
77
+ ```
78
+
79
+ ### Priority lanes and slots
80
+
81
+ Each key in `credentials` other than `webhook_key` is a lane. Requests run on `:default` unless wrapped:
82
+
83
+ ```ruby
84
+ Guesty.with_priority(:high) do
85
+ Guesty::Reservations::Approve.new.call(reservation_id:)
86
+ end
87
+ ```
88
+
89
+ A lane with several credentials gets one slot per credential. Each slot has its own token and rate limit windows, and
90
+ `slot_picker` chooses the slot for each request.
91
+
92
+ ## Usage
93
+
94
+ Operations live under `Guesty::<Resource>::<Action>`. Build a fresh instance for every call:
95
+
96
+ ```ruby
97
+ result = Guesty::Reservations::Show.new.call(reservation_id: '67ebc9c7c5904c0010502f89', fields: %w[status money])
98
+
99
+ if result.success?
100
+ result.value! # => { _id: '67ebc9c7c5904c0010502f89', status: 'confirmed', money: { ... } }
101
+ else
102
+ result.failure # => a Guesty::Error or a Faraday::Error
103
+ end
104
+ ```
105
+
106
+ `call` never raises. It returns `Success(value)` or `Failure(error)`. `call!` returns the `Success` and raises the
107
+ error where `call` would return a `Failure`. Response bodies come back as hashes with symbol keys.
108
+
109
+ The gem covers reservations (search, create, update, approve, decline, cancel, payments, custom fields, reviews),
110
+ conversations and messages, guest profiles, listings and calendars, guest folio items, review responses, webhooks and
111
+ saved views. [CHANGELOG.md](CHANGELOG.md) lists every operation.
112
+
113
+ ### Errors
114
+
115
+ | Class | Raised when |
116
+ | --- | --- |
117
+ | `Guesty::ArgumentError` | The arguments are unusable. Raised before any request. |
118
+ | `Guesty::ApiError` | Guesty answered with an error, including one tunnelled through an HTTP 200. `#status` and `#raw_body` hold the details. |
119
+ | `Guesty::ThrottledError` | The rate limiter refused the request. Nothing was sent. |
120
+ | `Guesty::Error` | The parent of the three above, also raised for refusals inside an operation. |
121
+
122
+ `error.fatal` is true for a refusal that no retry can fix.
123
+
124
+ ### Retries
125
+
126
+ The client retries a 429 and network failures with backoff on every method but POST. Reads also retry the known Guesty
127
+ errors that a second attempt fixes; operations that must not write twice send once. After a failed write, these
128
+ helpers tell you what happened:
129
+
130
+ ```ruby
131
+ Guesty.unapplied?(error) # the write certainly did not take, so a retry is safe
132
+ Guesty.delivery_unknown?(error) # it may have taken, so read back before retrying
133
+ Guesty.retryable_read?(error) # a read failed for a reason a retry can fix
134
+ ```
135
+
136
+ ### Webhooks
137
+
138
+ Guesty sends webhooks through Svix. Verify each delivery against the raw body and its headers before trusting it:
139
+
140
+ ```ruby
141
+ Guesty::Webhooks::Signatures::Verify.new.call(payload: request.raw_post, headers: request.headers)
142
+ # => Success(true), or a Failure when no signature matches or the timestamp is more than 5 minutes off
143
+ ```
144
+
145
+ `Guesty::Webhooks::Sync.new.call(urls:, events:)` subscribes each url to the events. It creates missing webhooks,
146
+ updates drifted ones and returns the urls grouped by `:created`, `:updated` and `:unchanged`.
147
+
148
+ ## Type signatures
149
+
150
+ The gem ships RBS for its public API in `sig/guesty.rbs`. `rbs collection install` picks it up from your
151
+ `Gemfile.lock`, so Steep sees it with no extra setup.
152
+
153
+ ## Development
154
+
155
+ ```sh
156
+ bundle install
157
+ bundle exec rake # specs and RuboCop
158
+ bundle exec rspec # specs only
159
+ bundle exec rubocop -A # autocorrect; review every change
160
+ ```
161
+
162
+ The specs run offline against recorded VCR cassettes in `spec/fixtures/cassettes`. Recording a new cassette needs
163
+ live credentials for a Guesty account. Scrub tokens, guest names and contact details from the cassette before you
164
+ commit it.
165
+
166
+ ## Releasing
167
+
168
+ 1. Bump `Guesty::VERSION` in `lib/guesty/version.rb` and move the `Unreleased` notes in `CHANGELOG.md` under the new
169
+ version.
170
+ 2. Commit on `staging` and run `bundle exec rake`.
171
+ 3. Run `gem signin` once per machine. The gem requires MFA, so every push asks for a one-time code.
172
+ 4. Run `bundle exec rake release`. It builds `pkg/guesty-client-<version>.gem`, tags `v<version>`, pushes the branch
173
+ and the tag, and pushes the gem to RubyGems.
174
+ 5. Fast-forward `production` to the release tag.
175
+
176
+ ## License
177
+
178
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,61 @@
1
+ module Guesty
2
+ module AccessTokens
3
+ # Ensures a live access token for the current credential slot: reuse the
4
+ # stored one, else mint one. Persistence, caching and expiry policy live in
5
+ # the injected token store (Guesty.config.token_store).
6
+ #
7
+ # Guesty mints only 5 access tokens per client id within 24 hours and
8
+ # answers 429 to everything after, so a token must never be discarded.
9
+ class Upsert
10
+ include Guesty::Operation
11
+
12
+ OAUTH_ENDPOINT = 'https://open-api.guesty.com/oauth2/token'.freeze #: ::String
13
+
14
+ # @rbs () -> Dry::Monads::Result[untyped]
15
+ def call
16
+ if (token = store.read(Guesty.current_slot[:key]))
17
+ return Success(token)
18
+ end
19
+
20
+ response = request_access_token
21
+ raise ApiError, response unless Guesty.successful_response?(response)
22
+
23
+ Success(save_token(response))
24
+ end
25
+
26
+ private
27
+
28
+ def store
29
+ Guesty.config.token_store
30
+ end
31
+
32
+ def client_id
33
+ Guesty.current_slot[:client_id]
34
+ end
35
+
36
+ def client_secret
37
+ Guesty.current_slot[:secret]
38
+ end
39
+
40
+ def request_access_token
41
+ Guesty.connection(url: OAUTH_ENDPOINT, skip_auth: true).post do |req|
42
+ req.headers['Content-Type'] = 'application/x-www-form-urlencoded'
43
+ req.body = {
44
+ grant_type: 'client_credentials',
45
+ scope: 'open-api',
46
+ client_secret:,
47
+ client_id:
48
+ }
49
+ end
50
+ end
51
+
52
+ def save_token(response)
53
+ store.save(
54
+ slot_key: Guesty.current_slot[:key],
55
+ access_token: response.body[:access_token],
56
+ expires_at: response.body[:expires_in].seconds.from_now
57
+ )
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,73 @@
1
+ module Guesty
2
+ # A rejected Guesty response. Carries the response and its parsed body, and
3
+ # classifies transience so callers can tell a retryable glitch from a write
4
+ # that never landed (see Guesty.unapplied? and Guesty.delivery_unknown?).
5
+ class ApiError < Error
6
+ GLITCH_MESSAGES = [
7
+ /An invalid response was received from the upstream server/,
8
+ /Forbidden err/,
9
+ /Reservation Meta not found/,
10
+ /Write conflict during plan execution/,
11
+ /You don't have permission to access, please contact Guesty support/
12
+ ].freeze
13
+
14
+ RETRYABLE_NON_DELIVERY_STATUSES = [401, 403, 429].freeze #: ::Array[::Integer]
15
+ PERMANENT_REJECTION_STATUSES = [400, 404, 405, 409, 410, 415, 422].freeze #: ::Array[::Integer]
16
+ UPSTREAM_STATUS = /\ARequest failed with status code (\d{3})\z/ #: ::Regexp
17
+
18
+ attr_reader :raw_body, :response
19
+
20
+ # @rbs (Faraday::Response response) -> void
21
+ def initialize(response)
22
+ @response = response
23
+ @raw_body = response.body
24
+
25
+ super(error_message.to_s.presence || response.status.to_s)
26
+ end
27
+
28
+ # @rbs () -> Integer
29
+ def status
30
+ body_status = @raw_body[:status] if @raw_body.is_a?(Hash)
31
+ parsed_status = Integer(body_status, exception: false) || Integer(error_message.to_s[UPSTREAM_STATUS, 1], exception: false)
32
+
33
+ parsed_status && parsed_status >= 300 ? parsed_status : @response.status
34
+ end
35
+
36
+ def guesty_glitch?
37
+ GLITCH_MESSAGES.any? do |message|
38
+ error_message.to_s =~ message
39
+ end
40
+ end
41
+
42
+ # @rbs () -> bool
43
+ def retryable_non_delivery?
44
+ RETRYABLE_NON_DELIVERY_STATUSES.include?(status)
45
+ end
46
+
47
+ # @rbs () -> bool
48
+ def permanent_rejection?
49
+ PERMANENT_REJECTION_STATUSES.include?(status)
50
+ end
51
+
52
+ # @rbs () -> bool
53
+ def transient?
54
+ retryable_non_delivery? || guesty_glitch? || status == 408 || status >= 500
55
+ end
56
+
57
+ private
58
+
59
+ # @rbs () -> untyped
60
+ def error_message
61
+ return @raw_body unless @raw_body.respond_to?(:dig)
62
+
63
+ error_paths = [%i[error data], %i[error message], :message, :data, :error, :code]
64
+
65
+ error_message = error_paths.find do |path|
66
+ value = path.is_a?(Array) ? @raw_body.dig(*path) : @raw_body[path]
67
+ break value if value.present?
68
+ end
69
+
70
+ error_message.is_a?(Array) ? error_message.join(', ') : error_message || @raw_body
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,138 @@
1
+ module Guesty
2
+ extend Dry::Monads::Result::Mixin
3
+
4
+ BASE_URL = 'https://open-api.guesty.com/v1/'.freeze #: ::String
5
+ TIMEOUT = 20 #: ::Integer
6
+
7
+ RETRY_OPTIONS = {
8
+ max: 50,
9
+ interval: 1.0,
10
+ interval_randomness: 0.5,
11
+ backoff_factor: 1,
12
+ retry_statuses: [429],
13
+ exceptions: [*Faraday::Retry::Middleware::DEFAULT_EXCEPTIONS, Faraday::ConnectionFailed],
14
+ methods: [*Faraday::Retry::Middleware::IDEMPOTENT_METHODS, :patch]
15
+ }.freeze #: ::Hash[::Symbol, untyped]
16
+
17
+ # @rbs (?url: ::String, ?skip_auth: bool, ?timeout: ::Integer) -> Faraday::Connection
18
+ def self.connection(url: config.base_url, skip_auth: false, timeout: TIMEOUT)
19
+ Faraday.new(url:) do |builder|
20
+ builder.request :authorization, 'Bearer', -> { auth_token } unless skip_auth
21
+
22
+ builder.request :json
23
+ builder.request :retry, RETRY_OPTIONS
24
+
25
+ builder.request :guesty_rate_limiter, threshold: 10, interval: 1.second, key: -> { rate_limit_key(:second) }
26
+ builder.request :guesty_rate_limiter, threshold: 120, interval: 1.minute, key: -> { rate_limit_key(:minute) }
27
+ builder.request :guesty_rate_limiter, threshold: 5000, interval: 1.hour, key: -> { rate_limit_key(:hour) }
28
+
29
+ builder.request :url_encoded
30
+ builder.response :json, parser_options: { symbolize_names: true, mode: :rails, decoder: [Oj, :load] }
31
+ builder.options[:timeout] = timeout
32
+ end
33
+ end
34
+
35
+ # @rbs (::Symbol | ::String request_method, ::String path, ?::Hash[::Symbol, untyped] params, ?retries: ::Integer, ?timeout: ::Integer) -> untyped
36
+ def self.do_request(request_method, path, params = {}, retries: 3, timeout: TIMEOUT)
37
+ Fiber[:guesty__slot] = pick_slot(current_priority)
38
+
39
+ http = connection(timeout:)
40
+ response = params.present? ? http.send(request_method, path, params) : http.send(request_method, path)
41
+ result = successful_response?(response) ? Success(response) : Failure(ApiError.new(response))
42
+
43
+ if retries.positive? && result.failure? && result.failure.guesty_glitch?
44
+ sleep(1.0)
45
+ result = do_request(request_method, path, params, retries: (retries - 1), timeout:)
46
+ end
47
+
48
+ result
49
+ end
50
+
51
+ # @rbs (::Symbol priority) { (?) -> untyped } -> untyped
52
+ def self.with_priority(priority, &block)
53
+ previous_priority = current_priority
54
+ Fiber[:guesty__priority] = priority
55
+ block.call
56
+ ensure
57
+ Fiber[:guesty__priority] = previous_priority
58
+ end
59
+
60
+ # @rbs () -> ::Symbol
61
+ def self.current_priority
62
+ Fiber[:guesty__priority] || :default
63
+ end
64
+
65
+ # @rbs () -> slot
66
+ def self.current_slot
67
+ Fiber[:guesty__slot] || token_slots(current_priority).first
68
+ end
69
+
70
+ # @rbs (::Symbol window) -> ::Symbol
71
+ def self.rate_limit_key(window)
72
+ :"guesty_http_limit_#{window}_#{current_slot[:key]}"
73
+ end
74
+
75
+ # @rbs (?::Symbol priority) -> ::Array[slot]
76
+ def self.token_slots(priority = current_priority)
77
+ creds = Array.wrap(config.credentials[priority])
78
+
79
+ creds.each_with_index.map do |cred, index|
80
+ key = creds.one? ? priority.to_s : "#{priority}_#{index}"
81
+
82
+ {
83
+ key:,
84
+ client_id: cred[:client_id],
85
+ secret: cred[:secret]
86
+ }
87
+ end
88
+ end
89
+
90
+ # @rbs () -> ::String?
91
+ def self.webhook_key
92
+ config.credentials[:webhook_key]
93
+ end
94
+
95
+ # @rbs () -> bool
96
+ def self.sync_guest_and_reservation_changes?
97
+ !!config.sync_guest_and_reservation_changes
98
+ end
99
+
100
+ # @rbs (::Symbol priority) -> slot
101
+ def self.pick_slot(priority)
102
+ slots = token_slots(priority)
103
+ return slots.fetch(0) if slots.one?
104
+
105
+ config.slot_picker.call(slots, priority)
106
+ end
107
+
108
+ # @rbs (Faraday::Response response) -> bool
109
+ def self.successful_response?(response)
110
+ return false unless response.success?
111
+
112
+ !response.body.is_a?(Hash) || response.body[:status].blank? || response.body[:status].to_i < 300
113
+ end
114
+
115
+ # @rbs (::StandardError error) -> bool
116
+ def self.retryable_read?(error)
117
+ return true if error.is_a?(Faraday::TimeoutError) || error.is_a?(Faraday::ConnectionFailed)
118
+
119
+ error.is_a?(ApiError) && error.transient?
120
+ end
121
+
122
+ # @rbs (::StandardError error) -> bool
123
+ def self.unapplied?(error)
124
+ return true if error.is_a?(Guesty::ThrottledError)
125
+
126
+ error.is_a?(ApiError) && (error.retryable_non_delivery? || error.permanent_rejection?)
127
+ end
128
+
129
+ # @rbs (::StandardError error) -> bool
130
+ def self.delivery_unknown?(error)
131
+ (error.is_a?(ApiError) || error.is_a?(Faraday::Error)) && !unapplied?(error)
132
+ end
133
+
134
+ # @rbs () -> ::String
135
+ def self.auth_token
136
+ AccessTokens::Upsert.new.call!.value!.access_token
137
+ end
138
+ end
@@ -0,0 +1,29 @@
1
+ module Guesty
2
+ # Injected by the host app (see the Guesty.configure initializer): the API
3
+ # credentials per priority lane, the webhook signing key, a token store, a
4
+ # rate limiter and a slot picker. Keeping these out of the gem is what makes
5
+ # it host-agnostic.
6
+ #
7
+ # credentials maps each priority to its client credentials, plus webhook_key:
8
+ #
9
+ # {
10
+ # default: [{ client_id: '...', secret: '...' }, ...],
11
+ # high: [{ client_id: '...', secret: '...' }],
12
+ # webhook_key: 'whsec_...'
13
+ # }
14
+ class Config
15
+ attr_accessor :credentials, :token_store, :rate_limiter, :slot_picker, :base_url, :sync_guest_and_reservation_changes, :custom_fields,
16
+ :default_currency
17
+
18
+ def initialize
19
+ @base_url = BASE_URL
20
+ @credentials = {}
21
+ @custom_fields = {}
22
+ @default_currency = nil
23
+ @token_store = MemoryTokenStore.new
24
+ @rate_limiter = MemoryRateLimiter.new
25
+ @slot_picker = ->(slots, _priority) { slots.first }
26
+ @sync_guest_and_reservation_changes = false
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,42 @@
1
+ module Guesty
2
+ module Conversations
3
+ module Attachments
4
+ # Trades attachment storage keys for signed URLs (they expire after about
5
+ # an hour). Keys that are already links pass through unchanged.
6
+ class Index
7
+ include Guesty::Operation
8
+
9
+ BATCH_SIZE = 20
10
+ INVALID_RESPONSE = 'Guesty attachments response has an unexpected shape.'.freeze #: ::String
11
+
12
+ # @rbs (keys: ::Array[::String]) -> Dry::Monads::Result[::Hash[::String, ::String]]
13
+ def call(keys:)
14
+ argument! keys: :required, fatal: true if keys.blank?
15
+ argument! keys: :invalid unless keys.all?(&:present?)
16
+
17
+ links, storage_keys = keys.uniq.partition { |key| key.start_with?('http://', 'https://') }
18
+ urls = links.index_with(&:itself)
19
+
20
+ storage_keys.each_slice(BATCH_SIZE) do |batch|
21
+ response = Guesty.do_request :get, 'communication/conversations/get-attachments', { keys: batch }
22
+ return response unless response.success?
23
+
24
+ urls.merge!(normalize_response(batch, response.value!.body))
25
+ end
26
+
27
+ Success(urls)
28
+ end
29
+
30
+ private
31
+
32
+ # @rbs (::Array[::String] batch, untyped body) -> ::Hash[::String, ::String]
33
+ def normalize_response(batch, body)
34
+ links = body[:data] if body.is_a?(Hash)
35
+ error! INVALID_RESPONSE unless links.is_a?(Array) && links.size == batch.size && links.all? { |link| link.is_a?(String) && link.present? }
36
+
37
+ batch.zip(links).to_h
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,58 @@
1
+ module Guesty
2
+ module Conversations
3
+ class Index
4
+ include Guesty::Operation
5
+
6
+ FIELDS = 'updatedAt meta.reservations._id meta.reservations.status'.freeze #: ::String
7
+ LIMIT = 100
8
+ MAX_PAGES = 20
9
+
10
+ # @rbs (since: ::Time) -> Dry::Monads::Result[::Array[::Hash[::Symbol, untyped]]]
11
+ def call(since:)
12
+ results = collect_updated(since)
13
+
14
+ Success(results)
15
+ end
16
+
17
+ private
18
+
19
+ def collect_updated(since)
20
+ results = [] #: ::Array[untyped]
21
+ cursor_after = nil
22
+
23
+ MAX_PAGES.times do
24
+ data = fetch_page(cursor_after)
25
+ page = data[:conversations].map { |conversation| normalize(conversation) }
26
+ results.concat(page.select { |conversation| conversation[:updated_at] >= since })
27
+
28
+ cursor_after = data.dig(:cursor, :after)
29
+ return without_timestamps(results) if page.empty? || cursor_after.blank? || page.last[:updated_at] < since
30
+ end
31
+
32
+ error! "Guesty conversations pagination exceeded #{MAX_PAGES} pages since #{since.iso8601}"
33
+ end
34
+
35
+ def without_timestamps(conversations)
36
+ conversations.map { |conversation| conversation.except(:updated_at) }
37
+ end
38
+
39
+ def fetch_page(cursor_after)
40
+ params = { fields: FIELDS, sort: '-updatedAt', limit: LIMIT }
41
+ params[:cursorAfter] = cursor_after if cursor_after.present?
42
+
43
+ response = Guesty.do_request(:get, 'communication/conversations', params)
44
+ error! response.failure.message unless response.success?
45
+
46
+ response.value!.body[:data]
47
+ end
48
+
49
+ def normalize(conversation)
50
+ {
51
+ id: conversation.fetch(:_id),
52
+ updated_at: Time.iso8601(conversation.fetch(:updatedAt)),
53
+ reservations: Guesty::Reservations.from_conversation(conversation)
54
+ }
55
+ end
56
+ end
57
+ end
58
+ end