channex 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 +21 -0
- data/LICENSE +21 -0
- data/README.md +281 -0
- data/Rakefile +12 -0
- data/lib/channex/client.rb +283 -0
- data/lib/channex/configuration.rb +52 -0
- data/lib/channex/errors.rb +62 -0
- data/lib/channex/object.rb +201 -0
- data/lib/channex/objects/booking_revision.rb +124 -0
- data/lib/channex/objects/booking_room.rb +68 -0
- data/lib/channex/objects/company.rb +16 -0
- data/lib/channex/objects/customer.rb +43 -0
- data/lib/channex/objects/guarantee.rb +21 -0
- data/lib/channex/objects/guest.rb +21 -0
- data/lib/channex/objects/message.rb +95 -0
- data/lib/channex/objects/message_thread.rb +76 -0
- data/lib/channex/objects/occupancy.rb +29 -0
- data/lib/channex/objects/review.rb +87 -0
- data/lib/channex/objects/service.rb +44 -0
- data/lib/channex/objects/tax.rb +36 -0
- data/lib/channex/objects.rb +22 -0
- data/lib/channex/resource.rb +130 -0
- data/lib/channex/resources/attachments.rb +36 -0
- data/lib/channex/resources/availability.rb +87 -0
- data/lib/channex/resources/availability_rules.rb +52 -0
- data/lib/channex/resources/booking_revisions.rb +112 -0
- data/lib/channex/resources/bookings.rb +72 -0
- data/lib/channex/resources/channels.rb +96 -0
- data/lib/channex/resources/facilities.rb +29 -0
- data/lib/channex/resources/group_users.rb +76 -0
- data/lib/channex/resources/groups.rb +26 -0
- data/lib/channex/resources/hotel_policies.rb +54 -0
- data/lib/channex/resources/message_threads.rb +89 -0
- data/lib/channex/resources/messages.rb +64 -0
- data/lib/channex/resources/photos.rb +117 -0
- data/lib/channex/resources/properties.rb +42 -0
- data/lib/channex/resources/property_users.rb +32 -0
- data/lib/channex/resources/rate_plans.rb +34 -0
- data/lib/channex/resources/restrictions.rb +98 -0
- data/lib/channex/resources/reviews.rb +41 -0
- data/lib/channex/resources/room_types.rb +34 -0
- data/lib/channex/resources/tax_sets.rb +34 -0
- data/lib/channex/resources/taxes.rb +60 -0
- data/lib/channex/resources/webhooks.rb +87 -0
- data/lib/channex/resources.rb +5 -0
- data/lib/channex/response.rb +102 -0
- data/lib/channex/version.rb +3 -0
- data/lib/channex.rb +52 -0
- data/sig/channex.rbs +4 -0
- metadata +124 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 29c78c4808964bdee4372d293bcc895d30e521fdced531a03ce8690d2dbdf7a0
|
|
4
|
+
data.tar.gz: 025a53b3e8b4afe6b7545260166b9f11bd3d3daf0abb4beb36f1b21da9d07de7
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 101bed4097f1783b0c36364bcbfcbfcc361ba4486ba0cf9cff6199c7a61d3b5f3134ac6b1b5c53061af676d8daa535f1a110af86e46a56d0dc8c3154bbf637f0
|
|
7
|
+
data.tar.gz: 33177e0c598657274ee3ca389d7ffba016b9232844cf71e8666048d202886693f3f510a2530af77f631ae2fd4ce91bf22f951a24dcdaf81005ff8c3228a508bd
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [0.1.0](https://github.com/servos-app/channex/compare/channex-v0.1.0...channex/v0.1.0) (2026-11-11)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* extract Channex API client into standalone gem ([7d52d80](https://github.com/servos-app/channex/commit/7d52d80616a4b2cdb94a631446d1a903c612ba9c))
|
|
9
|
+
* read and reply to guest reviews ([ce897c3](https://github.com/servos-app/channex/commit/ce897c390814e88f8cafbaa414c880a975aea23e))
|
|
10
|
+
* read and reply to guest reviews ([96089ba](https://github.com/servos-app/channex/commit/96089bab7260731998beb53e72f69663c7eccd29))
|
|
11
|
+
* sync the client with the copy in servos-app/servos ([0052022](https://github.com/servos-app/channex/commit/0052022ff14d6c27aa420d29b74168b390e72f25))
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
### Bug Fixes
|
|
15
|
+
|
|
16
|
+
* cancel a booking through the Booking CRS update endpoint ([2845c18](https://github.com/servos-app/channex/commit/2845c18e70d69a66ff83f3966d3333ad870621bd))
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
### Miscellaneous Chores
|
|
20
|
+
|
|
21
|
+
* release 0.1.0 ([1f54b4d](https://github.com/servos-app/channex/commit/1f54b4d45bfa3ff0029535b9990f3521a10f76aa))
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 asgerb
|
|
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,281 @@
|
|
|
1
|
+
# Channex
|
|
2
|
+
|
|
3
|
+
A clean, idiomatic Ruby client for the [Channex.io](https://channex.io) channel
|
|
4
|
+
manager API — properties, room types, rate plans, availability, restrictions,
|
|
5
|
+
bookings, webhooks, and more.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
Add it to your `Gemfile`:
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
gem "channex"
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Then:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
bundle install
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Requires Ruby >= 3.2.
|
|
22
|
+
|
|
23
|
+
## Configuration
|
|
24
|
+
|
|
25
|
+
Configure globally once, typically in an initializer:
|
|
26
|
+
|
|
27
|
+
```ruby
|
|
28
|
+
Channex.configure do |config|
|
|
29
|
+
config.api_key = ENV["CHANNEX_API_KEY"]
|
|
30
|
+
config.environment = :production # :staging (default), :production,
|
|
31
|
+
# :secure_staging, :secure_production
|
|
32
|
+
config.timeout = 30 # read timeout (seconds)
|
|
33
|
+
config.open_timeout = 10 # connection timeout (seconds)
|
|
34
|
+
config.logger = Rails.logger # optional; logs requests/responses at debug
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
client = Channex::Client.new
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Or configure per-client, overriding the globals:
|
|
41
|
+
|
|
42
|
+
```ruby
|
|
43
|
+
client = Channex::Client.new(
|
|
44
|
+
api_key: "your-api-key",
|
|
45
|
+
environment: :staging
|
|
46
|
+
)
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Usage
|
|
50
|
+
|
|
51
|
+
### Resources
|
|
52
|
+
|
|
53
|
+
Each resource is reachable from the client and exposes CRUD plus pagination:
|
|
54
|
+
|
|
55
|
+
```ruby
|
|
56
|
+
client.properties.list
|
|
57
|
+
client.properties.retrieve("property-uuid")
|
|
58
|
+
client.properties.create(title: "My Hotel", currency: "USD", ...)
|
|
59
|
+
client.properties.update("property-uuid", title: "New Name")
|
|
60
|
+
client.properties.delete("property-uuid")
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Available resources: `properties`, `room_types`, `rate_plans`, `availability`,
|
|
64
|
+
`restrictions`, `bookings`, `booking_revisions`, `channels`, `webhooks`,
|
|
65
|
+
`taxes`, `tax_sets`, `photos`, `groups`, `group_users`, `property_users`,
|
|
66
|
+
`facilities`, `messages`, `message_threads`, `reviews`, `attachments`, `hotel_policies`,
|
|
67
|
+
`availability_rules`.
|
|
68
|
+
|
|
69
|
+
### Pagination
|
|
70
|
+
|
|
71
|
+
```ruby
|
|
72
|
+
# One page
|
|
73
|
+
response = client.properties.list(page: 1, limit: 50)
|
|
74
|
+
response.data
|
|
75
|
+
response.pagination.has_next?
|
|
76
|
+
|
|
77
|
+
# Every page, collected
|
|
78
|
+
all = client.properties.list_all
|
|
79
|
+
|
|
80
|
+
# Every page, streamed (avoids loading everything into memory)
|
|
81
|
+
client.properties.list_all do |page|
|
|
82
|
+
page.each { |property| process(property) }
|
|
83
|
+
end
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### Filtering and ordering
|
|
87
|
+
|
|
88
|
+
```ruby
|
|
89
|
+
client.bookings.list(
|
|
90
|
+
property_id: "property-uuid",
|
|
91
|
+
status: "new",
|
|
92
|
+
date_from: "2024-01-01",
|
|
93
|
+
date_to: "2024-01-31",
|
|
94
|
+
order: { inserted_at: :asc }
|
|
95
|
+
)
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### Availability & Restrictions (ARI)
|
|
99
|
+
|
|
100
|
+
Read by property and date range; write in bulk:
|
|
101
|
+
|
|
102
|
+
```ruby
|
|
103
|
+
client.availability.list(
|
|
104
|
+
property_id: "property-uuid",
|
|
105
|
+
date_from: "2024-01-01",
|
|
106
|
+
date_to: "2024-01-31"
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
client.availability.update([
|
|
110
|
+
{
|
|
111
|
+
property_id: "property-uuid",
|
|
112
|
+
room_type_id: "room-uuid",
|
|
113
|
+
date_from: "2024-01-15",
|
|
114
|
+
date_to: "2024-01-20",
|
|
115
|
+
availability: 5
|
|
116
|
+
}
|
|
117
|
+
])
|
|
118
|
+
|
|
119
|
+
client.restrictions.update([
|
|
120
|
+
{
|
|
121
|
+
property_id: "property-uuid",
|
|
122
|
+
rate_plan_id: "rate-plan-uuid",
|
|
123
|
+
date_from: "2024-01-15",
|
|
124
|
+
date_to: "2024-01-20",
|
|
125
|
+
rate: 30000,
|
|
126
|
+
min_stay_arrival: 2,
|
|
127
|
+
closed_to_arrival: false,
|
|
128
|
+
stop_sell: false
|
|
129
|
+
}
|
|
130
|
+
])
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
### Receiving bookings
|
|
134
|
+
|
|
135
|
+
New bookings arrive through the booking revisions feed. **You must acknowledge
|
|
136
|
+
each revision after processing** — unacknowledged revisions are re-served for 30
|
|
137
|
+
minutes and then trigger email warnings.
|
|
138
|
+
|
|
139
|
+
```ruby
|
|
140
|
+
response = client.booking_revisions.feed
|
|
141
|
+
|
|
142
|
+
response.objects.each do |revision| # typed Channex::Objects::BookingRevision
|
|
143
|
+
puts revision.customer.full_name
|
|
144
|
+
puts revision.check_in_date # Date
|
|
145
|
+
revision.rooms.each { |room| puts room.nights }
|
|
146
|
+
|
|
147
|
+
client.booking_revisions.acknowledge(revision.id)
|
|
148
|
+
end
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Raw hash access is available via `response.data` if you prefer.
|
|
152
|
+
|
|
153
|
+
### Guest messages
|
|
154
|
+
|
|
155
|
+
Channex unifies the guest inboxes of Booking.com, Airbnb and Expedia behind one
|
|
156
|
+
API. A **message thread** is one conversation; threads are the channel-agnostic
|
|
157
|
+
entry point because they also cover conversations with no booking behind them —
|
|
158
|
+
an Airbnb inquiry is a thread about a stay that does not exist yet.
|
|
159
|
+
|
|
160
|
+
```ruby
|
|
161
|
+
threads = client.message_threads.list(property_id: "property-uuid")
|
|
162
|
+
thread = Channex::Objects::MessageThread.from_resource(threads.data.first)
|
|
163
|
+
|
|
164
|
+
thread.title # => "Maldonado Roxy"
|
|
165
|
+
thread.provider # => "BookingCom"
|
|
166
|
+
thread.booking_id # => nil for an Airbnb inquiry
|
|
167
|
+
thread.last_message # => Channex::Objects::Message
|
|
168
|
+
|
|
169
|
+
messages = client.message_threads.messages(thread.id, order: {inserted_at: :asc})
|
|
170
|
+
messages.data.map { |node| Channex::Objects::Message.from_resource(node) }
|
|
171
|
+
.each { |message| puts "#{message.sender}: #{message.message}" }
|
|
172
|
+
|
|
173
|
+
client.message_threads.send_message(thread.id, message: "Check-in is from 15:00.")
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
The same conversation is reachable from the booking it belongs to:
|
|
177
|
+
|
|
178
|
+
```ruby
|
|
179
|
+
client.messages.list(booking_id: "booking-uuid")
|
|
180
|
+
client.messages.send_message(booking_id: "booking-uuid", message: "See you soon!")
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
Sending a file is two calls — upload, then send the returned id:
|
|
184
|
+
|
|
185
|
+
```ruby
|
|
186
|
+
attachment = client.attachments.create(
|
|
187
|
+
file: Base64.strict_encode64(File.binread("directions.jpeg")),
|
|
188
|
+
file_name: "directions.jpeg",
|
|
189
|
+
file_type: "image/jpeg"
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
client.message_threads.send_message(thread.id, attachment_id: attachment.data["id"])
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
Two thread actions round out the inbox: `close(id)` archives a thread, and
|
|
196
|
+
`no_reply_needed(id)` tells Booking.com a guest message needs no answer so its
|
|
197
|
+
response-time score stays honest.
|
|
198
|
+
|
|
199
|
+
Every call here needs the Messages application installed on the property,
|
|
200
|
+
otherwise Channex answers `403 Forbidden`. Bookings from an OTA without a
|
|
201
|
+
messaging API answer `422 Unprocessable Entity` (`not_supported`).
|
|
202
|
+
|
|
203
|
+
### Guest reviews
|
|
204
|
+
|
|
205
|
+
Reviews from Booking.com, Airbnb and Expedia come through the same Messages &
|
|
206
|
+
Reviews application, so they answer `403 Forbidden` without it too. Scores are
|
|
207
|
+
out of 10 whatever the OTA rates on, and categories keep the OTA's own names.
|
|
208
|
+
|
|
209
|
+
```ruby
|
|
210
|
+
reviews = client.reviews.list(property_id: "property-uuid")
|
|
211
|
+
review = Channex::Objects::Review.from_resource(reviews.data.first)
|
|
212
|
+
|
|
213
|
+
review.ota # => "BookingCom"
|
|
214
|
+
review.overall_score # => 9.0
|
|
215
|
+
review.scores # => [{"category" => "clean", "score" => 10}, ...]
|
|
216
|
+
review.booking_id # => the Channex booking the review is about
|
|
217
|
+
review.replied? # => false
|
|
218
|
+
|
|
219
|
+
client.reviews.reply(review.id, reply: "Thank you for staying with us.")
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
### Typed objects
|
|
223
|
+
|
|
224
|
+
Booking data is wrapped in typed objects that still behave like hashes:
|
|
225
|
+
|
|
226
|
+
```ruby
|
|
227
|
+
revision.status # typed accessor
|
|
228
|
+
revision.new? # => true
|
|
229
|
+
revision["booking_id"] # hash access
|
|
230
|
+
revision.dig("customer", "mail")
|
|
231
|
+
revision.to_h # underlying hash
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
## Error handling
|
|
235
|
+
|
|
236
|
+
All errors inherit from `Channex::Errors::Error` and carry `#code` and
|
|
237
|
+
`#response`:
|
|
238
|
+
|
|
239
|
+
```ruby
|
|
240
|
+
begin
|
|
241
|
+
client.properties.retrieve("missing-uuid")
|
|
242
|
+
rescue Channex::Errors::NotFoundError => e
|
|
243
|
+
e.code # => 404
|
|
244
|
+
rescue Channex::Errors::ValidationError => e
|
|
245
|
+
e.errors # => [...] validation details
|
|
246
|
+
rescue Channex::Errors::RateLimitError => e
|
|
247
|
+
e.retry_after
|
|
248
|
+
rescue Channex::Errors::Error => e
|
|
249
|
+
e.response
|
|
250
|
+
end
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
| Status | Error class |
|
|
254
|
+
| ------ | ---------------------- |
|
|
255
|
+
| 401 | `AuthenticationError` |
|
|
256
|
+
| 403 | `ForbiddenError` |
|
|
257
|
+
| 404 | `NotFoundError` |
|
|
258
|
+
| 422 | `ValidationError` |
|
|
259
|
+
| 429 | `RateLimitError` |
|
|
260
|
+
| 5xx | `ServerError` |
|
|
261
|
+
| — | `ConnectionError`, `TimeoutError`, `ConfigurationError` |
|
|
262
|
+
|
|
263
|
+
## Development
|
|
264
|
+
|
|
265
|
+
After checking out the repo, run `bin/setup` to install dependencies. Run the
|
|
266
|
+
test suite with:
|
|
267
|
+
|
|
268
|
+
```bash
|
|
269
|
+
bundle exec rake test
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
Or `bin/console` for an interactive prompt.
|
|
273
|
+
|
|
274
|
+
Class loading uses [Zeitwerk](https://github.com/fxn/zeitwerk): files under
|
|
275
|
+
`lib/channex/` are autoloaded by convention (file path mirrors constant name),
|
|
276
|
+
so no manual `require` statements are needed when adding new resources or
|
|
277
|
+
objects.
|
|
278
|
+
|
|
279
|
+
## License
|
|
280
|
+
|
|
281
|
+
MIT.
|
data/Rakefile
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
module Channex
|
|
2
|
+
# Main client for interacting with the Channex API
|
|
3
|
+
#
|
|
4
|
+
# @example Basic usage
|
|
5
|
+
# client = Channex::Client.new(api_key: "your-api-key")
|
|
6
|
+
# client.properties.list
|
|
7
|
+
#
|
|
8
|
+
# @example With environment override
|
|
9
|
+
# client = Channex::Client.new(
|
|
10
|
+
# api_key: "your-api-key",
|
|
11
|
+
# environment: :production
|
|
12
|
+
# )
|
|
13
|
+
#
|
|
14
|
+
class Client
|
|
15
|
+
attr_reader :api_key, :configuration
|
|
16
|
+
|
|
17
|
+
def initialize(api_key: nil, environment: nil, timeout: nil, open_timeout: nil)
|
|
18
|
+
@configuration = Configuration.new
|
|
19
|
+
@configuration.api_key = api_key || Channex.configuration.api_key
|
|
20
|
+
@configuration.environment = environment || Channex.configuration.environment
|
|
21
|
+
@configuration.timeout = timeout || Channex.configuration.timeout
|
|
22
|
+
@configuration.open_timeout = open_timeout || Channex.configuration.open_timeout
|
|
23
|
+
@configuration.logger = Channex.configuration.logger
|
|
24
|
+
|
|
25
|
+
@api_key = @configuration.api_key
|
|
26
|
+
|
|
27
|
+
validate_configuration!
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Resource accessors
|
|
31
|
+
|
|
32
|
+
def properties
|
|
33
|
+
@properties ||= Resources::Properties.new(self)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def room_types
|
|
37
|
+
@room_types ||= Resources::RoomTypes.new(self)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def rate_plans
|
|
41
|
+
@rate_plans ||= Resources::RatePlans.new(self)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def availability
|
|
45
|
+
@availability ||= Resources::Availability.new(self)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def restrictions
|
|
49
|
+
@restrictions ||= Resources::Restrictions.new(self)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def bookings
|
|
53
|
+
@bookings ||= Resources::Bookings.new(self)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def booking_revisions
|
|
57
|
+
@booking_revisions ||= Resources::BookingRevisions.new(self)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def channels
|
|
61
|
+
@channels ||= Resources::Channels.new(self)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def webhooks
|
|
65
|
+
@webhooks ||= Resources::Webhooks.new(self)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def taxes
|
|
69
|
+
@taxes ||= Resources::Taxes.new(self)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def tax_sets
|
|
73
|
+
@tax_sets ||= Resources::TaxSets.new(self)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def photos
|
|
77
|
+
@photos ||= Resources::Photos.new(self)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def groups
|
|
81
|
+
@groups ||= Resources::Groups.new(self)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def group_users
|
|
85
|
+
@group_users ||= Resources::GroupUsers.new(self)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def property_users
|
|
89
|
+
@property_users ||= Resources::PropertyUsers.new(self)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def facilities
|
|
93
|
+
@facilities ||= Resources::Facilities.new(self)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def messages
|
|
97
|
+
@messages ||= Resources::Messages.new(self)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def message_threads
|
|
101
|
+
@message_threads ||= Resources::MessageThreads.new(self)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def reviews
|
|
105
|
+
@reviews ||= Resources::Reviews.new(self)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def attachments
|
|
109
|
+
@attachments ||= Resources::Attachments.new(self)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def hotel_policies
|
|
113
|
+
@hotel_policies ||= Resources::HotelPolicies.new(self)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def availability_rules
|
|
117
|
+
@availability_rules ||= Resources::AvailabilityRules.new(self)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# HTTP methods
|
|
121
|
+
|
|
122
|
+
def get(path, params: {})
|
|
123
|
+
request(:get, path, params: params)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def post(path, body: {})
|
|
127
|
+
request(:post, path, body: body)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def put(path, body: {})
|
|
131
|
+
request(:put, path, body: body)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def delete(path)
|
|
135
|
+
request(:delete, path)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
private
|
|
139
|
+
|
|
140
|
+
def validate_configuration!
|
|
141
|
+
if @api_key.nil? || @api_key.empty?
|
|
142
|
+
raise Errors::ConfigurationError, "API key is required. Set via Channex.configure or pass to Client.new"
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def request(method, path, params: {}, body: nil)
|
|
147
|
+
uri = build_uri(path, params)
|
|
148
|
+
http = build_http(uri)
|
|
149
|
+
request = build_request(method, uri, body)
|
|
150
|
+
|
|
151
|
+
log_request(method, uri, body)
|
|
152
|
+
|
|
153
|
+
response = execute_request(http, request)
|
|
154
|
+
channex_response = Response.new(response)
|
|
155
|
+
|
|
156
|
+
log_response(channex_response)
|
|
157
|
+
handle_errors(channex_response)
|
|
158
|
+
|
|
159
|
+
channex_response
|
|
160
|
+
rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH, SocketError => e
|
|
161
|
+
raise Errors::ConnectionError.new("Connection failed: #{e.message}")
|
|
162
|
+
rescue Net::OpenTimeout
|
|
163
|
+
raise Errors::TimeoutError.new("Connection timed out")
|
|
164
|
+
rescue Net::ReadTimeout
|
|
165
|
+
raise Errors::TimeoutError.new("Request timed out")
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def build_uri(path, params)
|
|
169
|
+
url = "#{configuration.api_url}#{path}"
|
|
170
|
+
url += "/" unless url.end_with?("/")
|
|
171
|
+
|
|
172
|
+
uri = URI(url)
|
|
173
|
+
uri.query = URI.encode_www_form(flatten_params(params)) unless params.empty?
|
|
174
|
+
uri
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def build_http(uri)
|
|
178
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
179
|
+
http.use_ssl = uri.scheme == "https"
|
|
180
|
+
http.read_timeout = configuration.timeout
|
|
181
|
+
http.open_timeout = configuration.open_timeout
|
|
182
|
+
http
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def build_request(method, uri, body)
|
|
186
|
+
request_class = {
|
|
187
|
+
get: Net::HTTP::Get,
|
|
188
|
+
post: Net::HTTP::Post,
|
|
189
|
+
put: Net::HTTP::Put,
|
|
190
|
+
delete: Net::HTTP::Delete
|
|
191
|
+
}.fetch(method)
|
|
192
|
+
|
|
193
|
+
request = request_class.new(uri)
|
|
194
|
+
request["Content-Type"] = "application/json"
|
|
195
|
+
request["Accept"] = "application/json"
|
|
196
|
+
request["user-api-key"] = api_key
|
|
197
|
+
request.body = body.to_json if body.present?
|
|
198
|
+
|
|
199
|
+
request
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def execute_request(http, request)
|
|
203
|
+
http.request(request)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def flatten_params(params, prefix = nil)
|
|
207
|
+
params.each_with_object([]) do |(key, value), result|
|
|
208
|
+
prefixed_key = prefix ? "#{prefix}[#{key}]" : key.to_s
|
|
209
|
+
|
|
210
|
+
case value
|
|
211
|
+
when Hash
|
|
212
|
+
result.concat(flatten_params(value, prefixed_key))
|
|
213
|
+
when Array
|
|
214
|
+
value.each { |v| result << ["#{prefixed_key}[]", v.to_s] }
|
|
215
|
+
else
|
|
216
|
+
result << [prefixed_key, value.to_s]
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def handle_errors(response)
|
|
222
|
+
return if response.success?
|
|
223
|
+
|
|
224
|
+
error_message = extract_error_message(response)
|
|
225
|
+
|
|
226
|
+
case response.status
|
|
227
|
+
when 401
|
|
228
|
+
raise Errors::AuthenticationError.new(error_message, response: response, code: 401)
|
|
229
|
+
when 403
|
|
230
|
+
raise Errors::ForbiddenError.new(error_message, response: response, code: 403)
|
|
231
|
+
when 404
|
|
232
|
+
raise Errors::NotFoundError.new(error_message, response: response, code: 404)
|
|
233
|
+
when 422
|
|
234
|
+
raise Errors::ValidationError.new(
|
|
235
|
+
error_message,
|
|
236
|
+
errors: (response.error["details"] if response.error.is_a?(Hash)) || {},
|
|
237
|
+
response: response,
|
|
238
|
+
code: 422
|
|
239
|
+
)
|
|
240
|
+
when 429
|
|
241
|
+
retry_after = response.headers["retry-after"]&.first&.to_i
|
|
242
|
+
raise Errors::RateLimitError.new(
|
|
243
|
+
error_message,
|
|
244
|
+
retry_after: retry_after,
|
|
245
|
+
response: response,
|
|
246
|
+
code: 429
|
|
247
|
+
)
|
|
248
|
+
when 500..599
|
|
249
|
+
raise Errors::ServerError.new(error_message, response: response, code: response.status)
|
|
250
|
+
else
|
|
251
|
+
raise Errors::Error.new(error_message, response: response, code: response.status)
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def extract_error_message(response)
|
|
256
|
+
error = response.error
|
|
257
|
+
if error.is_a?(Hash)
|
|
258
|
+
# Handle both error formats:
|
|
259
|
+
# - { "message": "..." } or { "error": "..." }
|
|
260
|
+
# - { "code": "...", "title": "..." }
|
|
261
|
+
error["message"] || error["title"] || error["error"] || error.to_s
|
|
262
|
+
elsif error.is_a?(String)
|
|
263
|
+
error
|
|
264
|
+
else
|
|
265
|
+
"HTTP #{response.status}"
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def log_request(method, uri, body)
|
|
270
|
+
return unless configuration.logger
|
|
271
|
+
|
|
272
|
+
configuration.logger.debug "[Channex] #{method.upcase} #{uri}"
|
|
273
|
+
configuration.logger.debug "[Channex] Body: #{body.to_json}" if body
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def log_response(response)
|
|
277
|
+
return unless configuration.logger
|
|
278
|
+
|
|
279
|
+
configuration.logger.debug "[Channex] Response: #{response.status}"
|
|
280
|
+
configuration.logger.debug "[Channex] Body: #{response.raw_body}" if response.raw_body
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
module Channex
|
|
2
|
+
# Configuration for the Channex client
|
|
3
|
+
#
|
|
4
|
+
# @example
|
|
5
|
+
# Channex.configure do |config|
|
|
6
|
+
# config.api_key = "your-api-key"
|
|
7
|
+
# config.environment = :production
|
|
8
|
+
# config.timeout = 30
|
|
9
|
+
# end
|
|
10
|
+
#
|
|
11
|
+
class Configuration
|
|
12
|
+
ENVIRONMENTS = {
|
|
13
|
+
staging: "https://staging.channex.io",
|
|
14
|
+
production: "https://app.channex.io",
|
|
15
|
+
secure_staging: "https://secure-staging.channex.io",
|
|
16
|
+
secure_production: "https://secure.channex.io"
|
|
17
|
+
}.freeze
|
|
18
|
+
|
|
19
|
+
attr_accessor :api_key, :timeout, :open_timeout, :logger
|
|
20
|
+
attr_reader :environment
|
|
21
|
+
|
|
22
|
+
def initialize
|
|
23
|
+
@api_key = nil
|
|
24
|
+
@environment = :staging
|
|
25
|
+
@timeout = 30
|
|
26
|
+
@open_timeout = 10
|
|
27
|
+
@logger = nil
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def environment=(env)
|
|
31
|
+
env = env.to_sym
|
|
32
|
+
unless ENVIRONMENTS.key?(env)
|
|
33
|
+
raise ArgumentError, "Invalid environment: #{env}. Valid options: #{ENVIRONMENTS.keys.join(", ")}"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
@environment = env
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def base_url
|
|
40
|
+
ENVIRONMENTS.fetch(@environment)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def api_url
|
|
44
|
+
"#{base_url}/api/v1"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def secure_api_url
|
|
48
|
+
secure_env = (@environment == :production) ? :secure_production : :secure_staging
|
|
49
|
+
"#{ENVIRONMENTS.fetch(secure_env)}/api/v1"
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|