hungrytable 0.0.8 → 1.0.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/README.md +299 -22
- data/RELEASE_NOTES.md +16 -0
- data/lib/hungrytable/api_health_monitor.rb +106 -0
- data/lib/hungrytable/circuit_breaker.rb +177 -0
- data/lib/hungrytable/config.rb +38 -16
- data/lib/hungrytable/enhanced_errors.rb +127 -0
- data/lib/hungrytable/errors.rb +36 -0
- data/lib/hungrytable/get_request.rb +17 -6
- data/lib/hungrytable/post_request.rb +17 -6
- data/lib/hungrytable/request.rb +42 -5
- data/lib/hungrytable/request_extensions.rb +17 -4
- data/lib/hungrytable/request_header.rb +25 -25
- data/lib/hungrytable/reservation_cancel.rb +19 -5
- data/lib/hungrytable/reservation_make.rb +30 -9
- data/lib/hungrytable/reservation_status.rb +56 -0
- data/lib/hungrytable/restaurant.rb +43 -31
- data/lib/hungrytable/restaurant_search.rb +63 -34
- data/lib/hungrytable/restaurant_slotlock.rb +32 -10
- data/lib/hungrytable/version.rb +3 -1
- data/lib/hungrytable.rb +117 -65
- metadata +43 -179
- data/.gitignore +0 -6
- data/.rvmrc +0 -1
- data/Gemfile +0 -5
- data/Guardfile +0 -16
- data/Rakefile +0 -8
- data/hungrytable.gemspec +0 -37
- data/test/restaurant_get_details_result.json +0 -6
- data/test/restaurant_search_result.json +0 -7
- data/test/test_helper.rb +0 -18
- data/test/unit/config_test.rb +0 -43
- data/test/unit/get_request_test.rb +0 -0
- data/test/unit/hungrytable/user_test.rb +0 -28
- data/test/unit/post_request_test.rb +0 -0
- data/test/unit/request_test.rb +0 -0
- data/test/unit/reservation_cancel_test.rb +0 -0
- data/test/unit/reservation_make_test.rb +0 -0
- data/test/unit/restaurant_search_test.rb +0 -0
- data/test/unit/restaurant_slotlock_test.rb +0 -0
- data/test/unit/restaurant_test.rb +0 -39
- data/test/user_login_result.json +0 -6
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 03c04cacdda93b1d21d8a26fff6cc52759d2a69e3f8c37e15718c9c356ca5cc1
|
|
4
|
+
data.tar.gz: 5d90da3b15d1c81049375ee5d105cf0c50b58eab5d67562a1db50400d042e87a
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 8bf655f9a26eab925b1e703a5b48d6cc43de861446106c63728a3000ed29a4665d2c118e542e1511c4f90fec47a7a5e14c54913a0be87d4eefaa12a15bebe56e
|
|
7
|
+
data.tar.gz: ebaa8f601e1959e7ae933e0d8cbf8d4ee559af9c94321990da1b9c17bdfb8b2a8f075203dc2efb7e8ce4272792fc9c4b75f19eed534e6febffc34cc072406517
|
data/README.md
CHANGED
|
@@ -1,54 +1,331 @@
|
|
|
1
1
|
# Hungrytable
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://github.com/dchapman1988/hungrytable/actions/workflows/ci.yml)
|
|
4
|
+
[](https://codecov.io/gh/dchapman1988/hungrytable)
|
|
5
|
+
[](https://www.ruby-lang.org/)
|
|
6
|
+
[](https://badge.fury.io/rb/hungrytable)
|
|
7
|
+
[](https://opensource.org/licenses/MIT)
|
|
8
|
+
|
|
9
|
+
Ruby client for the OpenTable REST API. Supports restaurant search, availability lookup, reservations, and booking management.
|
|
10
|
+
|
|
11
|
+
## Features
|
|
12
|
+
|
|
13
|
+
- Fetch restaurant details
|
|
14
|
+
- Search available reservation times
|
|
15
|
+
- Lock time slots before booking
|
|
16
|
+
- Create and cancel reservations
|
|
17
|
+
- Check reservation status
|
|
18
|
+
- Requires Ruby 3.0+
|
|
19
|
+
- No `method_missing` - all methods explicitly defined
|
|
20
|
+
- Error classes for different failure types
|
|
4
21
|
|
|
5
22
|
## Installation
|
|
6
23
|
|
|
7
24
|
Add this line to your application's Gemfile:
|
|
8
25
|
|
|
9
|
-
|
|
26
|
+
```ruby
|
|
27
|
+
gem 'hungrytable'
|
|
28
|
+
```
|
|
10
29
|
|
|
11
30
|
And then execute:
|
|
12
31
|
|
|
13
|
-
|
|
32
|
+
```bash
|
|
33
|
+
$ bundle install
|
|
34
|
+
```
|
|
14
35
|
|
|
15
36
|
Or install it yourself as:
|
|
16
37
|
|
|
17
|
-
|
|
38
|
+
```bash
|
|
39
|
+
$ gem install hungrytable
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Configuration
|
|
43
|
+
|
|
44
|
+
### Environment Variables
|
|
45
|
+
|
|
46
|
+
Set the following environment variables (recommended for production):
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
export OT_PARTNER_ID=<YOUR OPENTABLE PARTNER ID>
|
|
50
|
+
export OT_OAUTH_KEY=<YOUR OPENTABLE OAUTH KEY>
|
|
51
|
+
export OT_OAUTH_SECRET=<YOUR OPENTABLE OAUTH SECRET KEY>
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### Programmatic Configuration
|
|
55
|
+
|
|
56
|
+
Alternatively, configure programmatically in your code:
|
|
57
|
+
|
|
58
|
+
```ruby
|
|
59
|
+
Hungrytable.configure do |config|
|
|
60
|
+
config.partner_id = 'your_partner_id'
|
|
61
|
+
config.oauth_key = 'your_oauth_key'
|
|
62
|
+
config.oauth_secret = 'your_oauth_secret'
|
|
63
|
+
end
|
|
64
|
+
```
|
|
18
65
|
|
|
19
66
|
## Usage
|
|
20
67
|
|
|
21
|
-
|
|
68
|
+
### 1. Get Restaurant Details
|
|
69
|
+
|
|
70
|
+
```ruby
|
|
71
|
+
restaurant = Hungrytable::Restaurant.new(82591)
|
|
72
|
+
|
|
73
|
+
if restaurant.valid?
|
|
74
|
+
puts restaurant.restaurant_name
|
|
75
|
+
puts restaurant.address
|
|
76
|
+
puts restaurant.phone
|
|
77
|
+
puts restaurant.primary_food_type
|
|
78
|
+
puts restaurant.price_range
|
|
79
|
+
else
|
|
80
|
+
puts "Error: #{restaurant.error_message}"
|
|
81
|
+
end
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Available restaurant attributes:
|
|
85
|
+
- `restaurant_name`, `restaurant_ID`
|
|
86
|
+
- `address`, `city`, `state`, `postal_code`
|
|
87
|
+
- `phone`, `url`
|
|
88
|
+
- `latitude`, `longitude`
|
|
89
|
+
- `neighborhood_name`, `metro_name`
|
|
90
|
+
- `primary_food_type`, `price_range`
|
|
91
|
+
- `restaurant_description`
|
|
92
|
+
- `parking`, `parking_details`
|
|
93
|
+
- `image_link`
|
|
94
|
+
|
|
95
|
+
### 2. Search for Availability
|
|
96
|
+
|
|
97
|
+
```ruby
|
|
98
|
+
restaurant = Hungrytable::Restaurant.new(82591)
|
|
99
|
+
|
|
100
|
+
# Search for a table for 4 people, 5 days from now at 7pm
|
|
101
|
+
search_time = 5.days.from_now.change(hour: 19, min: 0)
|
|
102
|
+
search = Hungrytable::RestaurantSearch.new(
|
|
103
|
+
restaurant,
|
|
104
|
+
date_time: search_time,
|
|
105
|
+
party_size: 4
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
if search.valid?
|
|
109
|
+
puts "Exact time: #{search.exact_time}" if search.exact_time
|
|
110
|
+
puts "Early time: #{search.early_time}" if search.early_time
|
|
111
|
+
puts "Later time: #{search.later_time}" if search.later_time
|
|
112
|
+
puts "Best time: #{search.ideal_time}"
|
|
113
|
+
else
|
|
114
|
+
puts "No availability: #{search.error_message}"
|
|
115
|
+
end
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### 3. Lock a Time Slot
|
|
119
|
+
|
|
120
|
+
```ruby
|
|
121
|
+
slotlock = Hungrytable::RestaurantSlotlock.new(search)
|
|
122
|
+
|
|
123
|
+
if slotlock.successful?
|
|
124
|
+
puts "Slot locked! ID: #{slotlock.slotlock_id}"
|
|
125
|
+
else
|
|
126
|
+
puts "Failed to lock slot: #{slotlock.errors}"
|
|
127
|
+
end
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
### 4. Make a Reservation
|
|
131
|
+
|
|
132
|
+
```ruby
|
|
133
|
+
reservation = Hungrytable::ReservationMake.new(
|
|
134
|
+
slotlock,
|
|
135
|
+
email_address: 'john.doe@example.com',
|
|
136
|
+
firstname: 'John',
|
|
137
|
+
lastname: 'Doe',
|
|
138
|
+
phone: '5551234567',
|
|
139
|
+
specialinstructions: 'Window seat please'
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
if reservation.successful?
|
|
143
|
+
puts "Reservation confirmed! Number: #{reservation.confirmation_number}"
|
|
144
|
+
else
|
|
145
|
+
puts "Reservation failed: #{reservation.error_message}"
|
|
146
|
+
end
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
### 5. Check Reservation Status
|
|
150
|
+
|
|
151
|
+
```ruby
|
|
152
|
+
status = Hungrytable::ReservationStatus.new(
|
|
153
|
+
restaurant_id: 82591,
|
|
154
|
+
confirmation_number: 'ABC123XYZ'
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
if status.successful?
|
|
158
|
+
puts "Status: #{status.status}"
|
|
159
|
+
details = status.reservation_details
|
|
160
|
+
puts "Restaurant: #{details[:restaurant_name]}"
|
|
161
|
+
puts "Date/Time: #{details[:date_time]}"
|
|
162
|
+
puts "Party Size: #{details[:party_size]}"
|
|
163
|
+
end
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
### 6. Cancel a Reservation
|
|
167
|
+
|
|
168
|
+
```ruby
|
|
169
|
+
cancel = Hungrytable::ReservationCancel.new(
|
|
170
|
+
email_address: 'john.doe@example.com',
|
|
171
|
+
confirmation_number: 'ABC123XYZ',
|
|
172
|
+
restaurant_id: 82591
|
|
173
|
+
)
|
|
22
174
|
|
|
23
|
-
|
|
175
|
+
if cancel.successful?
|
|
176
|
+
puts "Reservation cancelled successfully"
|
|
177
|
+
else
|
|
178
|
+
puts "Cancellation failed: #{cancel.error_message}"
|
|
179
|
+
end
|
|
180
|
+
```
|
|
24
181
|
|
|
25
|
-
|
|
26
|
-
export OT_PARTNER_ID=<YOUR OPENTABLE PARTNER ID>
|
|
27
|
-
export OT_PARTNER_AUTH=<YOUR OPENTABLE PARTNER AUTH> # For XML feed only...
|
|
28
|
-
export OT_OAUTH_KEY=<YOUR OPENTABLE OAUTH KEY>
|
|
29
|
-
export OT_OAUTH_SECRET=<YOUR OPENTABLE OAUTH SECRET KEY>
|
|
182
|
+
### Complete Example: Full Reservation Flow
|
|
30
183
|
|
|
31
|
-
|
|
184
|
+
```ruby
|
|
185
|
+
require 'hungrytable'
|
|
32
186
|
|
|
33
|
-
|
|
187
|
+
# Configure
|
|
188
|
+
Hungrytable.configure do |config|
|
|
189
|
+
config.partner_id = ENV['OT_PARTNER_ID']
|
|
190
|
+
config.oauth_key = ENV['OT_OAUTH_KEY']
|
|
191
|
+
config.oauth_secret = ENV['OT_OAUTH_SECRET']
|
|
192
|
+
end
|
|
34
193
|
|
|
35
|
-
|
|
194
|
+
# 1. Get restaurant details
|
|
195
|
+
restaurant = Hungrytable::Restaurant.new(82591)
|
|
196
|
+
raise "Restaurant not found" unless restaurant.valid?
|
|
36
197
|
|
|
37
|
-
|
|
198
|
+
puts "Booking at: #{restaurant.restaurant_name}"
|
|
38
199
|
|
|
39
|
-
|
|
200
|
+
# 2. Search for availability
|
|
201
|
+
search_time = 3.days.from_now.change(hour: 19, min: 0)
|
|
202
|
+
search = Hungrytable::RestaurantSearch.new(
|
|
203
|
+
restaurant,
|
|
204
|
+
date_time: search_time,
|
|
205
|
+
party_size: 2
|
|
206
|
+
)
|
|
40
207
|
|
|
41
|
-
|
|
208
|
+
raise "No availability" unless search.valid?
|
|
42
209
|
|
|
43
|
-
|
|
210
|
+
puts "Found time slot: #{search.ideal_time}"
|
|
44
211
|
|
|
45
|
-
|
|
212
|
+
# 3. Lock the slot
|
|
213
|
+
slotlock = Hungrytable::RestaurantSlotlock.new(search)
|
|
214
|
+
raise "Could not lock slot: #{slotlock.errors}" unless slotlock.successful?
|
|
215
|
+
|
|
216
|
+
# 4. Make the reservation
|
|
217
|
+
reservation = Hungrytable::ReservationMake.new(
|
|
218
|
+
slotlock,
|
|
219
|
+
email_address: 'diner@example.com',
|
|
220
|
+
firstname: 'Jane',
|
|
221
|
+
lastname: 'Smith',
|
|
222
|
+
phone: '5551234567'
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
if reservation.successful?
|
|
226
|
+
puts "Success! Confirmation: #{reservation.confirmation_number}"
|
|
227
|
+
else
|
|
228
|
+
puts "Failed: #{reservation.error_message}"
|
|
229
|
+
end
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
## Error Handling
|
|
233
|
+
|
|
234
|
+
Available error classes:
|
|
235
|
+
|
|
236
|
+
```ruby
|
|
237
|
+
begin
|
|
238
|
+
restaurant = Hungrytable::Restaurant.new(invalid_id)
|
|
239
|
+
rescue Hungrytable::ConfigurationError => e
|
|
240
|
+
puts "Configuration error: #{e.message}"
|
|
241
|
+
rescue Hungrytable::HTTPError => e
|
|
242
|
+
puts "HTTP error: #{e.message}"
|
|
243
|
+
rescue Hungrytable::APIError => e
|
|
244
|
+
puts "API error #{e.error_code}: #{e.error_message}"
|
|
245
|
+
rescue Hungrytable::MissingRequiredFieldError => e
|
|
246
|
+
puts "Missing required field: #{e.message}"
|
|
247
|
+
end
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
Error hierarchy:
|
|
251
|
+
- `Hungrytable::Error` (base class)
|
|
252
|
+
- `ConfigurationError` - Missing or invalid configuration
|
|
253
|
+
- `HTTPError` - HTTP-level errors
|
|
254
|
+
- `NotFoundError` - 404 errors
|
|
255
|
+
- `UnauthorizedError` - 401 authentication errors
|
|
256
|
+
- `ServerError` - 5xx server errors
|
|
257
|
+
- `APIError` - OpenTable API errors
|
|
258
|
+
- `ValidationError` - Input validation errors
|
|
259
|
+
- `MissingRequiredFieldError` - Missing required parameters
|
|
260
|
+
|
|
261
|
+
## Development
|
|
262
|
+
|
|
263
|
+
After checking out the repo, run `bundle install` to install dependencies. Then, run `rake test` to run the tests.
|
|
264
|
+
|
|
265
|
+
### Running Tests
|
|
266
|
+
|
|
267
|
+
```bash
|
|
268
|
+
bundle exec rake test
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
### Running RuboCop
|
|
272
|
+
|
|
273
|
+
```bash
|
|
274
|
+
bundle exec rubocop
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
### Auto-fixing RuboCop Issues
|
|
278
|
+
|
|
279
|
+
```bash
|
|
280
|
+
bundle exec rubocop -A
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
## Requirements
|
|
284
|
+
|
|
285
|
+
- Ruby >= 3.0.0
|
|
286
|
+
- OpenTable Partner API credentials
|
|
287
|
+
|
|
288
|
+
## Dependencies
|
|
289
|
+
|
|
290
|
+
Runtime:
|
|
291
|
+
- `activesupport` (>= 6.0)
|
|
292
|
+
- `http` (~> 5.0)
|
|
293
|
+
- `oauth` (~> 1.1)
|
|
294
|
+
|
|
295
|
+
Development:
|
|
296
|
+
- `minitest` (~> 5.20) - Testing framework
|
|
297
|
+
- `webmock` (~> 3.20) - HTTP mocking for tests
|
|
298
|
+
- `rubocop` (~> 1.60) - Code quality and style
|
|
299
|
+
- `mocha` (~> 2.1) - Mocking and stubbing
|
|
46
300
|
|
|
47
301
|
|
|
48
302
|
## Contributing
|
|
49
303
|
|
|
50
|
-
1. Fork it
|
|
304
|
+
1. Fork it (https://github.com/dchapman1988/hungrytable/fork)
|
|
51
305
|
2. Create your feature branch (`git checkout -b my-new-feature`)
|
|
52
|
-
3. Commit your changes (`git commit -am '
|
|
306
|
+
3. Commit your changes (`git commit -am 'Add some feature'`)
|
|
53
307
|
4. Push to the branch (`git push origin my-new-feature`)
|
|
54
|
-
5. Create new Pull Request
|
|
308
|
+
5. Create a new Pull Request
|
|
309
|
+
|
|
310
|
+
Please ensure:
|
|
311
|
+
- All tests pass (`bundle exec rake test`)
|
|
312
|
+
- RuboCop is clean (`bundle exec rubocop`)
|
|
313
|
+
- New features have tests
|
|
314
|
+
- Code follows existing style
|
|
315
|
+
|
|
316
|
+
## License
|
|
317
|
+
|
|
318
|
+
The gem is available as open source under the terms of the [MIT License](LICENSE).
|
|
319
|
+
|
|
320
|
+
## Maintainers
|
|
321
|
+
|
|
322
|
+
**Active:**
|
|
323
|
+
- David Chapman ([@dchapman1988](https://github.com/dchapman1988))
|
|
324
|
+
|
|
325
|
+
**Inactive:**
|
|
326
|
+
- Nicholas Fine ([@yrgoldteeth](https://github.com/yrgoldteeth))
|
|
327
|
+
- Ryan T. Hosford ([@rthbound](https://github.com/rthbound))
|
|
328
|
+
|
|
329
|
+
## Changelog
|
|
330
|
+
|
|
331
|
+
See [RELEASE_NOTES.md](RELEASE_NOTES.md) for version history.
|
data/RELEASE_NOTES.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# Hungrytable - Ruby API Client for the OpenTable REST API
|
|
2
2
|
|
|
3
|
+
## v1.0.0 (2025-01-XX)
|
|
4
|
+
|
|
5
|
+
### Breaking Changes
|
|
6
|
+
- Requires Ruby 3.0+
|
|
7
|
+
- Replaced `curb` with `http` gem
|
|
8
|
+
- Replaced `method_missing` with explicit method definitions
|
|
9
|
+
|
|
10
|
+
### Changes
|
|
11
|
+
- Fixed deprecated `URI.encode`/`URI.decode` → `CGI.escape`/`CGI.unescape`
|
|
12
|
+
- Added `oauth` gem for OAuth 1.0
|
|
13
|
+
- RuboCop clean, Ruby 3.0+ code style
|
|
14
|
+
- Added 152 tests
|
|
15
|
+
- Added GitHub Actions CI
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
3
19
|
## v0.0.1 (05/11/2012)
|
|
4
20
|
|
|
5
21
|
* Get relevant details about an individual restaurant.
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hungrytable
|
|
4
|
+
# Monitors API health and detects potential deprecation or degradation
|
|
5
|
+
class ApiHealthMonitor
|
|
6
|
+
class << self
|
|
7
|
+
# Check if API is healthy
|
|
8
|
+
# @return [Hash] health status
|
|
9
|
+
def health_check
|
|
10
|
+
{
|
|
11
|
+
timestamp: Time.now.utc.iso8601,
|
|
12
|
+
status: check_status,
|
|
13
|
+
circuit_breaker: CircuitBreaker.stats,
|
|
14
|
+
endpoint: Config.base_url,
|
|
15
|
+
warnings: warnings
|
|
16
|
+
}
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Perform a lightweight health check
|
|
20
|
+
# @return [Symbol] :healthy, :degraded, or :down
|
|
21
|
+
def check_status
|
|
22
|
+
return :down if CircuitBreaker.open?
|
|
23
|
+
|
|
24
|
+
begin
|
|
25
|
+
# Try a simple request
|
|
26
|
+
restaurant = Restaurant.new(82591)
|
|
27
|
+
restaurant.valid? ? :healthy : :degraded
|
|
28
|
+
rescue UnauthorizedError
|
|
29
|
+
:unauthorized
|
|
30
|
+
rescue ServerError, HTTPError
|
|
31
|
+
:down
|
|
32
|
+
rescue StandardError
|
|
33
|
+
:unknown
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Get warnings about potential issues
|
|
38
|
+
# @return [Array<Hash>] warnings
|
|
39
|
+
def warnings
|
|
40
|
+
warnings = []
|
|
41
|
+
|
|
42
|
+
if CircuitBreaker.open?
|
|
43
|
+
warnings << {
|
|
44
|
+
level: :critical,
|
|
45
|
+
message: 'Circuit breaker is OPEN - API appears to be down',
|
|
46
|
+
action: 'Wait for circuit breaker timeout or contact OpenTable support'
|
|
47
|
+
}
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
if check_oauth_version_deprecated?
|
|
51
|
+
warnings << {
|
|
52
|
+
level: :warning,
|
|
53
|
+
message: 'Using OAuth 1.0 which may be deprecated',
|
|
54
|
+
action: 'Consider migrating to modern OpenTable API (OAuth 2.0)'
|
|
55
|
+
}
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
if check_legacy_endpoint?
|
|
59
|
+
warnings << {
|
|
60
|
+
level: :warning,
|
|
61
|
+
message: 'Using legacy otapi_v3.ashx endpoint (2012 vintage)',
|
|
62
|
+
action: 'Verify endpoint is still supported with OpenTable'
|
|
63
|
+
}
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
warnings
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Log API health to stdout or logger
|
|
70
|
+
# @param logger [Logger] optional logger
|
|
71
|
+
def log_health(logger = nil)
|
|
72
|
+
health = health_check
|
|
73
|
+
output = logger || $stdout
|
|
74
|
+
|
|
75
|
+
case health[:status]
|
|
76
|
+
when :healthy
|
|
77
|
+
output.puts "[Hungrytable] API Status: HEALTHY"
|
|
78
|
+
when :degraded
|
|
79
|
+
output.puts "[Hungrytable] API Status: DEGRADED - Some endpoints may be failing"
|
|
80
|
+
when :down
|
|
81
|
+
output.puts "[Hungrytable] API Status: DOWN - API is not responding"
|
|
82
|
+
when :unauthorized
|
|
83
|
+
output.puts "[Hungrytable] API Status: UNAUTHORIZED - Check credentials"
|
|
84
|
+
else
|
|
85
|
+
output.puts "[Hungrytable] API Status: UNKNOWN"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
health[:warnings].each do |warning|
|
|
89
|
+
output.puts "[Hungrytable] [#{warning[:level].to_s.upcase}] #{warning[:message]}"
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
def check_oauth_version_deprecated?
|
|
96
|
+
# OAuth 1.0 is considered legacy
|
|
97
|
+
true
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def check_legacy_endpoint?
|
|
101
|
+
# otapi_v3.ashx is from 2012
|
|
102
|
+
Config.base_url.include?('otapi_v3.ashx')
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hungrytable
|
|
4
|
+
# Circuit Breaker pattern to prevent cascading failures when API is down
|
|
5
|
+
#
|
|
6
|
+
# States:
|
|
7
|
+
# - CLOSED: Normal operation, requests pass through
|
|
8
|
+
# - OPEN: Too many failures, requests fail immediately
|
|
9
|
+
# - HALF_OPEN: Testing if service recovered
|
|
10
|
+
#
|
|
11
|
+
# Usage:
|
|
12
|
+
# Hungrytable::CircuitBreaker.call do
|
|
13
|
+
# # Make API request
|
|
14
|
+
# end
|
|
15
|
+
class CircuitBreaker
|
|
16
|
+
class CircuitOpenError < Error
|
|
17
|
+
def initialize(message = nil)
|
|
18
|
+
super(message || 'Circuit breaker is OPEN. API appears to be down. Try again later.')
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
class << self
|
|
23
|
+
# Execute a block with circuit breaker protection
|
|
24
|
+
# @yield Block to execute
|
|
25
|
+
# @raise [CircuitOpenError] if circuit is open
|
|
26
|
+
# @return Result of the block
|
|
27
|
+
def call(&block)
|
|
28
|
+
instance.call(&block)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Check if circuit is open
|
|
32
|
+
# @return [Boolean]
|
|
33
|
+
def open?
|
|
34
|
+
instance.open?
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Get current state
|
|
38
|
+
# @return [Symbol] :closed, :open, or :half_open
|
|
39
|
+
def state
|
|
40
|
+
instance.state
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Get circuit breaker statistics
|
|
44
|
+
# @return [Hash] statistics
|
|
45
|
+
def stats
|
|
46
|
+
instance.stats
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Reset the circuit breaker
|
|
50
|
+
def reset!
|
|
51
|
+
instance.reset!
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Configure circuit breaker
|
|
55
|
+
# @param failure_threshold [Integer] Number of failures before opening
|
|
56
|
+
# @param timeout [Integer] Seconds to wait before entering half-open state
|
|
57
|
+
# @param success_threshold [Integer] Successes needed in half-open to close
|
|
58
|
+
def configure(failure_threshold: 5, timeout: 60, success_threshold: 2)
|
|
59
|
+
instance.configure(
|
|
60
|
+
failure_threshold: failure_threshold,
|
|
61
|
+
timeout: timeout,
|
|
62
|
+
success_threshold: success_threshold
|
|
63
|
+
)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def instance
|
|
69
|
+
@instance ||= new
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
attr_reader :state, :failure_count, :success_count, :last_failure_time
|
|
74
|
+
|
|
75
|
+
def initialize
|
|
76
|
+
@state = :closed
|
|
77
|
+
@failure_count = 0
|
|
78
|
+
@success_count = 0
|
|
79
|
+
@last_failure_time = nil
|
|
80
|
+
@mutex = Mutex.new
|
|
81
|
+
|
|
82
|
+
# Default configuration
|
|
83
|
+
@config = {
|
|
84
|
+
failure_threshold: 5, # Open after 5 failures
|
|
85
|
+
timeout: 60, # Try again after 60 seconds
|
|
86
|
+
success_threshold: 2 # Close after 2 successes in half-open
|
|
87
|
+
}
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def configure(failure_threshold: nil, timeout: nil, success_threshold: nil)
|
|
91
|
+
@mutex.synchronize do
|
|
92
|
+
@config[:failure_threshold] = failure_threshold if failure_threshold
|
|
93
|
+
@config[:timeout] = timeout if timeout
|
|
94
|
+
@config[:success_threshold] = success_threshold if success_threshold
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def call
|
|
99
|
+
@mutex.synchronize do
|
|
100
|
+
check_state_transition
|
|
101
|
+
|
|
102
|
+
if @state == :open
|
|
103
|
+
raise CircuitOpenError
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
begin
|
|
108
|
+
result = yield
|
|
109
|
+
record_success
|
|
110
|
+
result
|
|
111
|
+
rescue UnauthorizedError, ServerError, HTTPError => e
|
|
112
|
+
record_failure
|
|
113
|
+
raise e
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def open?
|
|
118
|
+
@state == :open
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def stats
|
|
122
|
+
{
|
|
123
|
+
state: @state,
|
|
124
|
+
failure_count: @failure_count,
|
|
125
|
+
success_count: @success_count,
|
|
126
|
+
last_failure_time: @last_failure_time,
|
|
127
|
+
config: @config
|
|
128
|
+
}
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def reset!
|
|
132
|
+
@mutex.synchronize do
|
|
133
|
+
@state = :closed
|
|
134
|
+
@failure_count = 0
|
|
135
|
+
@success_count = 0
|
|
136
|
+
@last_failure_time = nil
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
private
|
|
141
|
+
|
|
142
|
+
def check_state_transition
|
|
143
|
+
return unless @state == :open
|
|
144
|
+
|
|
145
|
+
if Time.now - @last_failure_time >= @config[:timeout]
|
|
146
|
+
@state = :half_open
|
|
147
|
+
@success_count = 0
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def record_success
|
|
152
|
+
@mutex.synchronize do
|
|
153
|
+
@failure_count = 0
|
|
154
|
+
|
|
155
|
+
if @state == :half_open
|
|
156
|
+
@success_count += 1
|
|
157
|
+
if @success_count >= @config[:success_threshold]
|
|
158
|
+
@state = :closed
|
|
159
|
+
@success_count = 0
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def record_failure
|
|
166
|
+
@mutex.synchronize do
|
|
167
|
+
@failure_count += 1
|
|
168
|
+
@last_failure_time = Time.now
|
|
169
|
+
@success_count = 0
|
|
170
|
+
|
|
171
|
+
if @failure_count >= @config[:failure_threshold]
|
|
172
|
+
@state = :open
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|