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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +34 -0
- data/LICENSE +21 -0
- data/README.md +178 -0
- data/lib/guesty/access_tokens/upsert.rb +61 -0
- data/lib/guesty/api_error.rb +73 -0
- data/lib/guesty/client.rb +138 -0
- data/lib/guesty/config.rb +29 -0
- data/lib/guesty/conversations/attachments/index.rb +42 -0
- data/lib/guesty/conversations/index.rb +58 -0
- data/lib/guesty/conversations/messages/reply.rb +55 -0
- data/lib/guesty/conversations/posts/index.rb +121 -0
- data/lib/guesty/conversations/posts/since.rb +37 -0
- data/lib/guesty/conversations/search.rb +31 -0
- data/lib/guesty/conversations/show.rb +25 -0
- data/lib/guesty/conversations.rb +15 -0
- data/lib/guesty/error.rb +32 -0
- data/lib/guesty/guest_folio/invoice_items/index.rb +61 -0
- data/lib/guesty/guests/profiles/create.rb +25 -0
- data/lib/guesty/guests/profiles/show.rb +23 -0
- data/lib/guesty/guests/profiles/update.rb +36 -0
- data/lib/guesty/guests.rb +5 -0
- data/lib/guesty/homes/calendars/show.rb +134 -0
- data/lib/guesty/homes/search.rb +46 -0
- data/lib/guesty/memory_rate_limiter.rb +28 -0
- data/lib/guesty/memory_token_store.rb +33 -0
- data/lib/guesty/operation.rb +37 -0
- data/lib/guesty/rate_limiter_middleware.rb +23 -0
- data/lib/guesty/reservations/accommodation_fare/update.rb +44 -0
- data/lib/guesty/reservations/approve.rb +23 -0
- data/lib/guesty/reservations/create.rb +50 -0
- data/lib/guesty/reservations/custom_fields/update.rb +26 -0
- data/lib/guesty/reservations/dates/update.rb +41 -0
- data/lib/guesty/reservations/decline.rb +30 -0
- data/lib/guesty/reservations/flags/update.rb +33 -0
- data/lib/guesty/reservations/payments/create.rb +72 -0
- data/lib/guesty/reservations/pre_approve.rb +23 -0
- data/lib/guesty/reservations/recover.rb +27 -0
- data/lib/guesty/reservations/reviews/extractors/airbnb.rb +66 -0
- data/lib/guesty/reservations/reviews/extractors/base.rb +36 -0
- data/lib/guesty/reservations/reviews/extractors/booking_com.rb +35 -0
- data/lib/guesty/reservations/reviews/extractors/vrbo.rb +46 -0
- data/lib/guesty/reservations/reviews/index.rb +67 -0
- data/lib/guesty/reservations/search.rb +63 -0
- data/lib/guesty/reservations/show.rb +28 -0
- data/lib/guesty/reservations/status/cancel.rb +34 -0
- data/lib/guesty/reservations/status/update.rb +21 -0
- data/lib/guesty/reservations/stays/update.rb +42 -0
- data/lib/guesty/reservations/update.rb +19 -0
- data/lib/guesty/reservations.rb +13 -0
- data/lib/guesty/reviews/respond.rb +19 -0
- data/lib/guesty/version.rb +3 -0
- data/lib/guesty/views/create.rb +19 -0
- data/lib/guesty/views/update.rb +33 -0
- data/lib/guesty/webhooks/create.rb +21 -0
- data/lib/guesty/webhooks/index.rb +18 -0
- data/lib/guesty/webhooks/signatures/verify.rb +76 -0
- data/lib/guesty/webhooks/sync.rb +36 -0
- data/lib/guesty/webhooks/update.rb +22 -0
- data/lib/guesty-client.rb +1 -0
- data/lib/guesty.rb +78 -0
- data/sig/guesty.rbs +427 -0
- 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
|