nahook 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/LICENSE +21 -0
- data/README.md +189 -0
- data/lib/nahook/client.rb +140 -0
- data/lib/nahook/errors.rb +97 -0
- data/lib/nahook/http_client.rb +164 -0
- data/lib/nahook/management.rb +63 -0
- data/lib/nahook/resources/applications.rb +148 -0
- data/lib/nahook/resources/endpoints.rb +114 -0
- data/lib/nahook/resources/environments.rb +121 -0
- data/lib/nahook/resources/event_types.rb +97 -0
- data/lib/nahook/resources/portal_sessions.rb +47 -0
- data/lib/nahook/resources/subscriptions.rb +70 -0
- data/lib/nahook/version.rb +5 -0
- data/lib/nahook.rb +29 -0
- metadata +76 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: dff420cf119a0ff62244803d4fb0897bc403a42172a27f187efecbd2b79d9eb6
|
|
4
|
+
data.tar.gz: 2eb2ee2daa42e790da927002db9adb91b2034804fd8fd71b64bf9123766fc97e
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: a30168ab2246994559832746f45bb714b3f7d120ee0a00d4c646983a19a0a92237bf4db7307c53504ef81e76977e83fb8dab615414e83218757ae861ec445005
|
|
7
|
+
data.tar.gz: b9a956794a9710d356e09efb57f0fc2d12ed22f0c95e515c1f992983be72c58be3816bb6fefdee21d0a183a4d56a809d701a039910a69c3ff069e0421d6f9dc0
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Nahook
|
|
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,189 @@
|
|
|
1
|
+
# Nahook Ruby SDK
|
|
2
|
+
|
|
3
|
+
Official Ruby SDK for the [Nahook](https://nahook.com) webhook platform. Send webhooks, fan-out by event type, and manage resources programmatically.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
Add to your Gemfile:
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
gem "nahook"
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Or install directly:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
gem install nahook
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
**Requirements:** Ruby 3.0+
|
|
20
|
+
|
|
21
|
+
## Quick Start
|
|
22
|
+
|
|
23
|
+
### Sending Webhooks (Client)
|
|
24
|
+
|
|
25
|
+
```ruby
|
|
26
|
+
require "nahook"
|
|
27
|
+
|
|
28
|
+
client = Nahook::Client.new("nhk_us_your_api_key")
|
|
29
|
+
|
|
30
|
+
# Send to a specific endpoint
|
|
31
|
+
result = client.send("ep_abc123", payload: { order_id: "12345", status: "paid" })
|
|
32
|
+
puts result["deliveryId"] # => "del_..."
|
|
33
|
+
|
|
34
|
+
# Fan-out by event type (delivers to all subscribed endpoints)
|
|
35
|
+
result = client.trigger("order.paid", payload: { order_id: "12345" })
|
|
36
|
+
puts result["deliveryIds"] # => ["del_1", "del_2"]
|
|
37
|
+
|
|
38
|
+
# With metadata
|
|
39
|
+
client.trigger("order.paid",
|
|
40
|
+
payload: { order_id: "12345" },
|
|
41
|
+
metadata: { "source" => "checkout" }
|
|
42
|
+
)
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### Managing Resources (Management)
|
|
46
|
+
|
|
47
|
+
```ruby
|
|
48
|
+
mgmt = Nahook::Management.new("nhm_your_management_token")
|
|
49
|
+
|
|
50
|
+
# Endpoints
|
|
51
|
+
endpoints = mgmt.endpoints.list("ws_abc123")
|
|
52
|
+
endpoint = mgmt.endpoints.create("ws_abc123",
|
|
53
|
+
url: "https://example.com/webhook",
|
|
54
|
+
type: "webhook",
|
|
55
|
+
description: "Production webhook"
|
|
56
|
+
)
|
|
57
|
+
mgmt.endpoints.update("ws_abc123", endpoint["id"], is_active: false)
|
|
58
|
+
mgmt.endpoints.delete("ws_abc123", endpoint["id"])
|
|
59
|
+
|
|
60
|
+
# Event Types
|
|
61
|
+
mgmt.event_types.create("ws_abc123", name: "order.paid", description: "Fired when an order is paid")
|
|
62
|
+
types = mgmt.event_types.list("ws_abc123")
|
|
63
|
+
|
|
64
|
+
# Applications
|
|
65
|
+
app = mgmt.applications.create("ws_abc123", name: "Acme Corp", external_id: "acme_123")
|
|
66
|
+
mgmt.applications.list("ws_abc123", limit: 10, offset: 0)
|
|
67
|
+
mgmt.applications.list_endpoints("ws_abc123", app["id"])
|
|
68
|
+
mgmt.applications.create_endpoint("ws_abc123", app["id"], url: "https://acme.com/hook")
|
|
69
|
+
|
|
70
|
+
# Subscriptions
|
|
71
|
+
mgmt.subscriptions.create("ws_abc123", "ep_def456", event_type_ids: ["evt_ghi789"])
|
|
72
|
+
mgmt.subscriptions.list("ws_abc123", "ep_def456")
|
|
73
|
+
mgmt.subscriptions.delete("ws_abc123", "ep_def456", "evt_ghi789")
|
|
74
|
+
|
|
75
|
+
# Environments
|
|
76
|
+
env = mgmt.environments.create("ws_abc123", name: "Staging", slug: "staging")
|
|
77
|
+
mgmt.environments.list("ws_abc123")
|
|
78
|
+
mgmt.environments.get("ws_abc123", env["id"])
|
|
79
|
+
mgmt.environments.update("ws_abc123", env["id"], name: "Pre-production")
|
|
80
|
+
mgmt.environments.delete("ws_abc123", env["id"])
|
|
81
|
+
|
|
82
|
+
# Event Type Visibility
|
|
83
|
+
mgmt.environments.list_event_type_visibility("ws_abc123", "env_abc123")
|
|
84
|
+
mgmt.environments.set_event_type_visibility("ws_abc123", "env_abc123", "evt_abc123", published: true)
|
|
85
|
+
|
|
86
|
+
# Portal Sessions
|
|
87
|
+
session = mgmt.portal_sessions.create("ws_abc123", "app_jkl012")
|
|
88
|
+
puts session["url"] # Redirect your customer here
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## Client Options
|
|
92
|
+
|
|
93
|
+
### Nahook::Client
|
|
94
|
+
|
|
95
|
+
```ruby
|
|
96
|
+
client = Nahook::Client.new("nhk_us_...",
|
|
97
|
+
timeout_ms: 30_000, # milliseconds, default
|
|
98
|
+
retries: 3 # retry on 5xx/429/network errors
|
|
99
|
+
)
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
### Configuration
|
|
103
|
+
|
|
104
|
+
The SDK automatically routes requests to the correct regional API based on your API key prefix (`nhk_us_...` -> US, `nhk_eu_...` -> EU, `nhk_ap_...` -> Asia Pacific). No configuration needed.
|
|
105
|
+
|
|
106
|
+
To override the base URL (for testing or local development):
|
|
107
|
+
|
|
108
|
+
```ruby
|
|
109
|
+
client = Nahook::Client.new("nhk_us_...", base_url: "http://localhost:3001")
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
For unit tests, mock the SDK client at the dependency injection boundary. For integration tests, override the base URL to point at a local server.
|
|
113
|
+
|
|
114
|
+
### Nahook::Management
|
|
115
|
+
|
|
116
|
+
```ruby
|
|
117
|
+
mgmt = Nahook::Management.new("nhm_...",
|
|
118
|
+
timeout_ms: 30_000 # milliseconds, default
|
|
119
|
+
)
|
|
120
|
+
# Note: Management does not support retries
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## Batch Operations
|
|
124
|
+
|
|
125
|
+
```ruby
|
|
126
|
+
# Send to multiple endpoints (max 20)
|
|
127
|
+
result = client.send_batch([
|
|
128
|
+
{ endpoint_id: "ep_abc", payload: { order: 1 } },
|
|
129
|
+
{ endpoint_id: "ep_def", payload: { order: 2 }, idempotency_key: "key-2" }
|
|
130
|
+
])
|
|
131
|
+
|
|
132
|
+
# Fan-out multiple event types (max 20)
|
|
133
|
+
result = client.trigger_batch([
|
|
134
|
+
{ event_type: "order.paid", payload: { order_id: "123" } },
|
|
135
|
+
{ event_type: "user.created", payload: { user_id: "456" }, metadata: { "source" => "api" } }
|
|
136
|
+
])
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## Idempotency
|
|
140
|
+
|
|
141
|
+
The `send` method auto-generates a UUID idempotency key if you don't provide one:
|
|
142
|
+
|
|
143
|
+
```ruby
|
|
144
|
+
# Auto-generated idempotency key
|
|
145
|
+
client.send("ep_abc", payload: { order: 1 })
|
|
146
|
+
|
|
147
|
+
# Explicit idempotency key
|
|
148
|
+
client.send("ep_abc", payload: { order: 1 }, idempotency_key: "order-1-v1")
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
## Error Handling
|
|
152
|
+
|
|
153
|
+
```ruby
|
|
154
|
+
begin
|
|
155
|
+
client.send("ep_abc", payload: { test: true })
|
|
156
|
+
rescue Nahook::APIError => e
|
|
157
|
+
puts e.message # Human-readable message
|
|
158
|
+
puts e.status # HTTP status code
|
|
159
|
+
puts e.code # Machine-readable error code
|
|
160
|
+
puts e.retryable? # true for 5xx and 429
|
|
161
|
+
puts e.auth_error? # true for 401, or 403 with token_disabled
|
|
162
|
+
puts e.not_found? # true for 404
|
|
163
|
+
puts e.rate_limited? # true for 429
|
|
164
|
+
puts e.retry_after # Retry-After header value (seconds), if present
|
|
165
|
+
rescue Nahook::NetworkError => e
|
|
166
|
+
puts e.message # "Network error: ..."
|
|
167
|
+
puts e.original_error # Original exception
|
|
168
|
+
rescue Nahook::TimeoutError => e
|
|
169
|
+
puts e.message # "Request timed out after 30000ms"
|
|
170
|
+
puts e.timeout_ms # Timeout in milliseconds
|
|
171
|
+
rescue Nahook::Error => e
|
|
172
|
+
# Catch-all for any SDK error
|
|
173
|
+
end
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
## Retry Logic
|
|
177
|
+
|
|
178
|
+
When `retries` is configured on `Nahook::Client`, the SDK automatically retries on:
|
|
179
|
+
|
|
180
|
+
- HTTP 5xx responses
|
|
181
|
+
- HTTP 429 (rate limited) -- respects `Retry-After` header
|
|
182
|
+
- Network connection failures
|
|
183
|
+
- Request timeouts
|
|
184
|
+
|
|
185
|
+
Retry delay uses exponential backoff with full jitter (base 500ms, max 10s).
|
|
186
|
+
|
|
187
|
+
## License
|
|
188
|
+
|
|
189
|
+
MIT
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module Nahook
|
|
6
|
+
# Client for sending webhook payloads through the Nahook ingestion API.
|
|
7
|
+
#
|
|
8
|
+
# Supports sending to specific endpoints, fan-out by event type,
|
|
9
|
+
# and batch operations. Includes configurable retry with exponential backoff.
|
|
10
|
+
#
|
|
11
|
+
# @example Basic usage
|
|
12
|
+
# client = Nahook::Client.new("nhk_us_your_api_key")
|
|
13
|
+
# client.send("ep_abc123", payload: { order_id: "12345" })
|
|
14
|
+
#
|
|
15
|
+
# @example With options
|
|
16
|
+
# client = Nahook::Client.new("nhk_us_your_api_key",
|
|
17
|
+
# base_url: "https://custom.nahook.com",
|
|
18
|
+
# timeout_ms: 15_000,
|
|
19
|
+
# retries: 3
|
|
20
|
+
# )
|
|
21
|
+
class Client
|
|
22
|
+
# @param api_key [String] API key (must start with "nhk_")
|
|
23
|
+
# @param base_url [String] API base URL
|
|
24
|
+
# @param timeout_ms [Integer] request timeout in milliseconds (default: 30000)
|
|
25
|
+
# @param retries [Integer] number of retry attempts for retryable errors
|
|
26
|
+
# @raise [ArgumentError] if the API key does not start with "nhk_"
|
|
27
|
+
def initialize(api_key, base_url: nil, timeout_ms: HttpClient::DEFAULT_TIMEOUT_MS, retries: 0)
|
|
28
|
+
unless api_key.start_with?("nhk_")
|
|
29
|
+
raise ArgumentError, "Invalid API key: must start with 'nhk_'"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
resolved_url = base_url || HttpClient.resolve_base_url(api_key)
|
|
33
|
+
|
|
34
|
+
@http = HttpClient.new(
|
|
35
|
+
token: api_key,
|
|
36
|
+
base_url: resolved_url,
|
|
37
|
+
timeout_ms: timeout_ms,
|
|
38
|
+
retries: retries
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Send a payload to a specific endpoint.
|
|
43
|
+
#
|
|
44
|
+
# @param endpoint_id [String] the endpoint public ID (e.g. "ep_abc123")
|
|
45
|
+
# @param payload [Hash] the webhook payload
|
|
46
|
+
# @param idempotency_key [String, nil] optional idempotency key (auto-generated if omitted)
|
|
47
|
+
# @return [Hash] response with "deliveryId", "idempotencyKey", and "status" keys
|
|
48
|
+
# @raise [APIError] on API error responses
|
|
49
|
+
# @raise [NetworkError] on connection failures
|
|
50
|
+
# @raise [TimeoutError] on request timeout
|
|
51
|
+
def send(endpoint_id, payload:, idempotency_key: nil)
|
|
52
|
+
key = idempotency_key || SecureRandom.uuid
|
|
53
|
+
|
|
54
|
+
@http.request(
|
|
55
|
+
method: :post,
|
|
56
|
+
path: "/api/ingest/#{CGI.escape(endpoint_id)}",
|
|
57
|
+
body: {
|
|
58
|
+
"payload" => payload,
|
|
59
|
+
"idempotencyKey" => key
|
|
60
|
+
}
|
|
61
|
+
)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Fan-out a payload by event type to all subscribed endpoints.
|
|
65
|
+
#
|
|
66
|
+
# @param event_type [String] the event type name (e.g. "order.paid")
|
|
67
|
+
# @param payload [Hash] the webhook payload
|
|
68
|
+
# @param metadata [Hash, nil] optional metadata key-value pairs
|
|
69
|
+
# @return [Hash] response with "eventTypeId", "deliveryIds", and "status" keys
|
|
70
|
+
# @raise [APIError] on API error responses
|
|
71
|
+
def trigger(event_type, payload:, metadata: nil)
|
|
72
|
+
body = { "payload" => payload }
|
|
73
|
+
body["metadata"] = metadata if metadata
|
|
74
|
+
|
|
75
|
+
@http.request(
|
|
76
|
+
method: :post,
|
|
77
|
+
path: "/api/ingest/event/#{CGI.escape(event_type)}",
|
|
78
|
+
body: body
|
|
79
|
+
)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Batch send to multiple specific endpoints (max 20 items).
|
|
83
|
+
#
|
|
84
|
+
# @param items [Array<Hash>] list of items, each with :endpoint_id, :payload, and optional :idempotency_key
|
|
85
|
+
# @return [Hash] response with "items" key containing per-item results
|
|
86
|
+
# @raise [APIError] on API error responses
|
|
87
|
+
#
|
|
88
|
+
# @example
|
|
89
|
+
# client.send_batch([
|
|
90
|
+
# { endpoint_id: "ep_abc", payload: { order: 1 } },
|
|
91
|
+
# { endpoint_id: "ep_def", payload: { order: 2 }, idempotency_key: "key-2" }
|
|
92
|
+
# ])
|
|
93
|
+
def send_batch(items)
|
|
94
|
+
mapped = items.map do |item|
|
|
95
|
+
entry = {
|
|
96
|
+
"endpointId" => item[:endpoint_id] || item["endpoint_id"],
|
|
97
|
+
"payload" => item[:payload] || item["payload"]
|
|
98
|
+
}
|
|
99
|
+
key = item[:idempotency_key] || item["idempotency_key"]
|
|
100
|
+
entry["idempotencyKey"] = key if key
|
|
101
|
+
entry
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
@http.request(
|
|
105
|
+
method: :post,
|
|
106
|
+
path: "/api/ingest/batch",
|
|
107
|
+
body: { "items" => mapped }
|
|
108
|
+
)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Batch fan-out by event types (max 20 items).
|
|
112
|
+
#
|
|
113
|
+
# @param items [Array<Hash>] list of items, each with :event_type, :payload, and optional :metadata
|
|
114
|
+
# @return [Hash] response with "items" key containing per-item results
|
|
115
|
+
# @raise [APIError] on API error responses
|
|
116
|
+
#
|
|
117
|
+
# @example
|
|
118
|
+
# client.trigger_batch([
|
|
119
|
+
# { event_type: "order.paid", payload: { order_id: "123" } },
|
|
120
|
+
# { event_type: "user.created", payload: { user_id: "456" } }
|
|
121
|
+
# ])
|
|
122
|
+
def trigger_batch(items)
|
|
123
|
+
mapped = items.map do |item|
|
|
124
|
+
entry = {
|
|
125
|
+
"eventType" => item[:event_type] || item["event_type"],
|
|
126
|
+
"payload" => item[:payload] || item["payload"]
|
|
127
|
+
}
|
|
128
|
+
meta = item[:metadata] || item["metadata"]
|
|
129
|
+
entry["metadata"] = meta if meta
|
|
130
|
+
entry
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
@http.request(
|
|
134
|
+
method: :post,
|
|
135
|
+
path: "/api/ingest/event/batch",
|
|
136
|
+
body: { "items" => mapped }
|
|
137
|
+
)
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nahook
|
|
4
|
+
# Base error for all Nahook SDK errors.
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# Raised when the Nahook API returns an error response (4xx/5xx).
|
|
8
|
+
#
|
|
9
|
+
# @example Handling an API error
|
|
10
|
+
# begin
|
|
11
|
+
# client.send("ep_123", payload: { order: 1 })
|
|
12
|
+
# rescue Nahook::APIError => e
|
|
13
|
+
# puts e.status # => 404
|
|
14
|
+
# puts e.code # => "not_found"
|
|
15
|
+
# puts e.retryable? # => false
|
|
16
|
+
# end
|
|
17
|
+
class APIError < Error
|
|
18
|
+
# @return [Integer] HTTP status code
|
|
19
|
+
attr_reader :status
|
|
20
|
+
|
|
21
|
+
# @return [String] machine-readable error code from the API
|
|
22
|
+
attr_reader :code
|
|
23
|
+
|
|
24
|
+
# @return [Integer, nil] seconds the client should wait before retrying
|
|
25
|
+
attr_reader :retry_after
|
|
26
|
+
|
|
27
|
+
# @param status [Integer] HTTP status code
|
|
28
|
+
# @param code [String] machine-readable error code
|
|
29
|
+
# @param message [String] human-readable error message
|
|
30
|
+
# @param retry_after [Integer, nil] Retry-After header value in seconds
|
|
31
|
+
def initialize(status, code, message, retry_after = nil)
|
|
32
|
+
@status = status
|
|
33
|
+
@code = code
|
|
34
|
+
@retry_after = retry_after
|
|
35
|
+
super(message)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Whether this error is safe to retry (5xx or 429).
|
|
39
|
+
#
|
|
40
|
+
# @return [Boolean]
|
|
41
|
+
def retryable?
|
|
42
|
+
status >= 500 || status == 429
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Whether this is an authentication or authorization error.
|
|
46
|
+
#
|
|
47
|
+
# @return [Boolean]
|
|
48
|
+
def auth_error?
|
|
49
|
+
status == 401 || (status == 403 && code == "token_disabled")
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Whether the requested resource was not found.
|
|
53
|
+
#
|
|
54
|
+
# @return [Boolean]
|
|
55
|
+
def not_found?
|
|
56
|
+
status == 404
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Whether the request was rate limited.
|
|
60
|
+
#
|
|
61
|
+
# @return [Boolean]
|
|
62
|
+
def rate_limited?
|
|
63
|
+
status == 429
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Whether the request failed validation.
|
|
67
|
+
#
|
|
68
|
+
# @return [Boolean]
|
|
69
|
+
def validation_error?
|
|
70
|
+
status == 400
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Raised when a network-level failure occurs (no HTTP response received).
|
|
75
|
+
class NetworkError < Error
|
|
76
|
+
# @return [Exception] the underlying error that caused this failure
|
|
77
|
+
attr_reader :original_error
|
|
78
|
+
|
|
79
|
+
# @param original_error [Exception] the original exception
|
|
80
|
+
def initialize(original_error)
|
|
81
|
+
@original_error = original_error
|
|
82
|
+
super("Network error: #{original_error.message}")
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Raised when a request exceeds the configured timeout.
|
|
87
|
+
class TimeoutError < Error
|
|
88
|
+
# @return [Integer] the timeout in milliseconds
|
|
89
|
+
attr_reader :timeout_ms
|
|
90
|
+
|
|
91
|
+
# @param timeout_ms [Integer] the configured timeout in milliseconds
|
|
92
|
+
def initialize(timeout_ms)
|
|
93
|
+
@timeout_ms = timeout_ms
|
|
94
|
+
super("Request timed out after #{timeout_ms}ms")
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "faraday"
|
|
4
|
+
require "json"
|
|
5
|
+
require "cgi"
|
|
6
|
+
|
|
7
|
+
module Nahook
|
|
8
|
+
# Low-level HTTP client used by both {Client} and {Management}.
|
|
9
|
+
#
|
|
10
|
+
# Handles request execution, retry logic with exponential backoff,
|
|
11
|
+
# and error parsing. Not intended for direct use.
|
|
12
|
+
#
|
|
13
|
+
# @api private
|
|
14
|
+
class HttpClient
|
|
15
|
+
DEFAULT_BASE_URL = "https://api.nahook.com"
|
|
16
|
+
DEFAULT_TIMEOUT_MS = 30_000
|
|
17
|
+
BASE_DELAY_MS = 500
|
|
18
|
+
MAX_DELAY_MS = 10_000
|
|
19
|
+
|
|
20
|
+
REGION_BASE_URLS = {
|
|
21
|
+
"us" => "https://us.api.nahook.com",
|
|
22
|
+
"eu" => "https://eu.api.nahook.com",
|
|
23
|
+
"ap" => "https://ap.api.nahook.com",
|
|
24
|
+
}.freeze
|
|
25
|
+
|
|
26
|
+
# @api private
|
|
27
|
+
def self.resolve_base_url(token)
|
|
28
|
+
if (m = token.match(/\Anhk_([a-z]{2})_/))
|
|
29
|
+
REGION_BASE_URLS[m[1]] || DEFAULT_BASE_URL
|
|
30
|
+
else
|
|
31
|
+
DEFAULT_BASE_URL
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# @param token [String] bearer token for authentication
|
|
36
|
+
# @param base_url [String] API base URL
|
|
37
|
+
# @param timeout_ms [Integer] request timeout in milliseconds (default: 30000)
|
|
38
|
+
# @param retries [Integer] number of retry attempts for retryable errors
|
|
39
|
+
def initialize(token:, base_url: DEFAULT_BASE_URL, timeout_ms: DEFAULT_TIMEOUT_MS, retries: 0)
|
|
40
|
+
@token = token
|
|
41
|
+
@retries = retries
|
|
42
|
+
@timeout_ms = timeout_ms
|
|
43
|
+
|
|
44
|
+
timeout_secs = timeout_ms / 1000.0
|
|
45
|
+
@conn = Faraday.new(url: base_url.chomp("/")) do |f|
|
|
46
|
+
f.options.timeout = timeout_secs
|
|
47
|
+
f.options.open_timeout = timeout_secs
|
|
48
|
+
f.adapter Faraday.default_adapter
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Execute an HTTP request with optional retry logic.
|
|
53
|
+
#
|
|
54
|
+
# @param method [Symbol] HTTP method (:get, :post, :patch, :delete)
|
|
55
|
+
# @param path [String] request path (will be appended to base URL)
|
|
56
|
+
# @param body [Hash, nil] request body (will be JSON-encoded)
|
|
57
|
+
# @param query [Hash, nil] query parameters
|
|
58
|
+
# @return [Hash, nil] parsed JSON response, or nil for 204
|
|
59
|
+
# @raise [APIError] on 4xx/5xx responses
|
|
60
|
+
# @raise [NetworkError] on connection failures
|
|
61
|
+
# @raise [TimeoutError] on request timeout
|
|
62
|
+
def request(method:, path:, body: nil, query: nil)
|
|
63
|
+
execute_with_retry(method, path, body, query)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def execute_with_retry(method, path, body, query)
|
|
69
|
+
last_error = nil
|
|
70
|
+
|
|
71
|
+
(0..@retries).each do |attempt|
|
|
72
|
+
if attempt > 0
|
|
73
|
+
retry_after_ms = last_error.is_a?(APIError) ? (last_error.retry_after || 0) * 1000 : nil
|
|
74
|
+
delay = calculate_delay(attempt - 1, retry_after_ms)
|
|
75
|
+
sleep(delay / 1000.0)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
begin
|
|
79
|
+
response = perform_request(method, path, body, query)
|
|
80
|
+
|
|
81
|
+
unless response.success?
|
|
82
|
+
error = parse_error(response)
|
|
83
|
+
if attempt < @retries && retryable?(error)
|
|
84
|
+
last_error = error
|
|
85
|
+
next
|
|
86
|
+
end
|
|
87
|
+
raise error
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
return nil if response.status == 204
|
|
91
|
+
return JSON.parse(response.body)
|
|
92
|
+
|
|
93
|
+
rescue APIError
|
|
94
|
+
raise
|
|
95
|
+
|
|
96
|
+
rescue Faraday::TimeoutError => e
|
|
97
|
+
last_error = TimeoutError.new(@timeout_ms)
|
|
98
|
+
raise last_error unless attempt < @retries && retryable?(last_error)
|
|
99
|
+
|
|
100
|
+
rescue Faraday::ConnectionFailed => e
|
|
101
|
+
last_error = NetworkError.new(e)
|
|
102
|
+
raise last_error unless attempt < @retries && retryable?(last_error)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
raise last_error
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def perform_request(method, path, body, query)
|
|
110
|
+
@conn.run_request(method, path, nil, request_headers(body)) do |req|
|
|
111
|
+
req.body = JSON.generate(body) if body
|
|
112
|
+
if query
|
|
113
|
+
query.each do |key, value|
|
|
114
|
+
req.params[key.to_s] = value.to_s unless value.nil?
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def request_headers(body)
|
|
121
|
+
headers = {
|
|
122
|
+
"Authorization" => "Bearer #{@token}",
|
|
123
|
+
"Accept" => "application/json",
|
|
124
|
+
"User-Agent" => "nahook-ruby/#{Nahook::VERSION}"
|
|
125
|
+
}
|
|
126
|
+
headers["Content-Type"] = "application/json" if body
|
|
127
|
+
headers
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def parse_error(response)
|
|
131
|
+
retry_after = response.headers["retry-after"]
|
|
132
|
+
retry_after_secs = retry_after ? retry_after.to_i : nil
|
|
133
|
+
|
|
134
|
+
begin
|
|
135
|
+
body = JSON.parse(response.body)
|
|
136
|
+
code = body.dig("error", "code") || "unknown"
|
|
137
|
+
message = body.dig("error", "message") || response.reason_phrase || "Unknown error"
|
|
138
|
+
rescue JSON::ParserError
|
|
139
|
+
code = "unknown"
|
|
140
|
+
message = response.reason_phrase || "Unknown error"
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
APIError.new(response.status, code, message, retry_after_secs)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def calculate_delay(attempt, retry_after_ms = nil)
|
|
147
|
+
if retry_after_ms && retry_after_ms > 0
|
|
148
|
+
return retry_after_ms
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
exponential = [MAX_DELAY_MS, BASE_DELAY_MS * (2**attempt)].min
|
|
152
|
+
exponential * rand
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def retryable?(error)
|
|
156
|
+
case error
|
|
157
|
+
when APIError then error.retryable?
|
|
158
|
+
when NetworkError then true
|
|
159
|
+
when TimeoutError then true
|
|
160
|
+
else false
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nahook
|
|
4
|
+
# Client for the Nahook Management API.
|
|
5
|
+
#
|
|
6
|
+
# Provides programmatic access to manage workspaces, endpoints, event types,
|
|
7
|
+
# applications, subscriptions, and portal sessions. Intended for server-side
|
|
8
|
+
# use with a management token.
|
|
9
|
+
#
|
|
10
|
+
# Unlike {Client}, the Management client does not support retries --
|
|
11
|
+
# management operations are not idempotent by default.
|
|
12
|
+
#
|
|
13
|
+
# @example
|
|
14
|
+
# mgmt = Nahook::Management.new("nhm_your_token")
|
|
15
|
+
#
|
|
16
|
+
# # List endpoints
|
|
17
|
+
# result = mgmt.endpoints.list("ws_abc123")
|
|
18
|
+
# result["data"].each { |ep| puts ep["url"] }
|
|
19
|
+
#
|
|
20
|
+
# # Create an endpoint
|
|
21
|
+
# endpoint = mgmt.endpoints.create("ws_abc123",
|
|
22
|
+
# url: "https://example.com/webhook",
|
|
23
|
+
# description: "Production webhook"
|
|
24
|
+
# )
|
|
25
|
+
class Management
|
|
26
|
+
# @return [Resources::Endpoints]
|
|
27
|
+
attr_reader :endpoints
|
|
28
|
+
|
|
29
|
+
# @return [Resources::EventTypes]
|
|
30
|
+
attr_reader :event_types
|
|
31
|
+
|
|
32
|
+
# @return [Resources::Applications]
|
|
33
|
+
attr_reader :applications
|
|
34
|
+
|
|
35
|
+
# @return [Resources::Subscriptions]
|
|
36
|
+
attr_reader :subscriptions
|
|
37
|
+
|
|
38
|
+
# @return [Resources::PortalSessions]
|
|
39
|
+
attr_reader :portal_sessions
|
|
40
|
+
|
|
41
|
+
# @return [Resources::Environments]
|
|
42
|
+
attr_reader :environments
|
|
43
|
+
|
|
44
|
+
# @param token [String] management token (must start with "nhm_")
|
|
45
|
+
# @param base_url [String] API base URL
|
|
46
|
+
# @param timeout_ms [Integer] request timeout in milliseconds (default: 30000)
|
|
47
|
+
# @raise [ArgumentError] if the token does not start with "nhm_"
|
|
48
|
+
def initialize(token, base_url: HttpClient::DEFAULT_BASE_URL, timeout_ms: HttpClient::DEFAULT_TIMEOUT_MS)
|
|
49
|
+
unless token.start_with?("nhm_")
|
|
50
|
+
raise ArgumentError, "Invalid management token: must start with 'nhm_'"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
http = HttpClient.new(token: token, base_url: base_url, timeout_ms: timeout_ms)
|
|
54
|
+
|
|
55
|
+
@endpoints = Resources::Endpoints.new(http)
|
|
56
|
+
@event_types = Resources::EventTypes.new(http)
|
|
57
|
+
@applications = Resources::Applications.new(http)
|
|
58
|
+
@subscriptions = Resources::Subscriptions.new(http)
|
|
59
|
+
@portal_sessions = Resources::PortalSessions.new(http)
|
|
60
|
+
@environments = Resources::Environments.new(http)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|