oilpriceapi 0.7.0 → 0.9.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.
- package/README.md +244 -30
- package/dist/cjs/client.js +610 -0
- package/dist/cjs/errors.js +80 -0
- package/dist/cjs/index.js +96 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/resources/alerts.js +387 -0
- package/dist/cjs/resources/analytics.js +188 -0
- package/dist/cjs/resources/bunker-fuels.js +210 -0
- package/dist/cjs/resources/commodities.js +115 -0
- package/dist/cjs/resources/data-quality.js +144 -0
- package/dist/cjs/resources/data-sources.js +298 -0
- package/dist/cjs/resources/diesel.js +119 -0
- package/dist/cjs/resources/drilling.js +269 -0
- package/dist/cjs/resources/ei/drilling-productivity.js +108 -0
- package/dist/cjs/resources/ei/forecasts.js +106 -0
- package/dist/cjs/resources/ei/frac-focus.js +165 -0
- package/dist/cjs/resources/ei/index.js +98 -0
- package/dist/cjs/resources/ei/oil-inventories.js +97 -0
- package/dist/cjs/resources/ei/opec-production.js +97 -0
- package/dist/cjs/resources/ei/rig-counts.js +93 -0
- package/dist/cjs/resources/ei/well-permits.js +136 -0
- package/dist/cjs/resources/forecasts.js +168 -0
- package/dist/cjs/resources/futures.js +424 -0
- package/dist/cjs/resources/indicators.js +79 -0
- package/dist/cjs/resources/raw.js +128 -0
- package/dist/cjs/resources/rig-counts.js +164 -0
- package/dist/cjs/resources/spreads.js +105 -0
- package/dist/cjs/resources/storage.js +166 -0
- package/dist/cjs/resources/streaming.js +350 -0
- package/dist/cjs/resources/webhooks.js +283 -0
- package/dist/cjs/types.js +2 -0
- package/dist/cjs/version.js +24 -0
- package/dist/client.d.ts +130 -3
- package/dist/client.js +206 -30
- package/dist/errors.d.ts +6 -0
- package/dist/errors.js +25 -16
- package/dist/index.d.ts +28 -5
- package/dist/index.js +29 -1
- package/dist/resources/alerts.js +31 -77
- package/dist/resources/analytics.d.ts +147 -214
- package/dist/resources/analytics.js +104 -141
- package/dist/resources/bunker-fuels.d.ts +35 -12
- package/dist/resources/bunker-fuels.js +41 -26
- package/dist/resources/commodities.js +2 -1
- package/dist/resources/data-quality.js +2 -1
- package/dist/resources/data-sources.d.ts +31 -31
- package/dist/resources/data-sources.js +30 -85
- package/dist/resources/diesel.d.ts +1 -1
- package/dist/resources/diesel.js +9 -38
- package/dist/resources/drilling.js +2 -1
- package/dist/resources/ei/drilling-productivity.js +2 -1
- package/dist/resources/ei/forecasts.js +2 -1
- package/dist/resources/ei/frac-focus.d.ts +23 -9
- package/dist/resources/ei/frac-focus.js +20 -9
- package/dist/resources/ei/index.js +2 -1
- package/dist/resources/ei/oil-inventories.js +2 -1
- package/dist/resources/ei/opec-production.js +2 -1
- package/dist/resources/ei/rig-counts.js +2 -1
- package/dist/resources/ei/well-permits.d.ts +25 -9
- package/dist/resources/ei/well-permits.js +20 -7
- package/dist/resources/forecasts.d.ts +4 -1
- package/dist/resources/forecasts.js +13 -6
- package/dist/resources/futures.d.ts +178 -1
- package/dist/resources/futures.js +199 -8
- package/dist/resources/indicators.d.ts +170 -0
- package/dist/resources/indicators.js +75 -0
- package/dist/resources/raw.d.ts +94 -0
- package/dist/resources/raw.js +124 -0
- package/dist/resources/rig-counts.js +5 -2
- package/dist/resources/spreads.d.ts +121 -0
- package/dist/resources/spreads.js +101 -0
- package/dist/resources/storage.d.ts +5 -4
- package/dist/resources/storage.js +7 -6
- package/dist/resources/streaming.d.ts +272 -0
- package/dist/resources/streaming.js +342 -0
- package/dist/resources/webhooks.d.ts +73 -23
- package/dist/resources/webhooks.js +59 -77
- package/dist/types.d.ts +43 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +2 -2
- package/package.json +21 -6
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebSocket Streaming Resource
|
|
3
|
+
*
|
|
4
|
+
* Real-time price streaming via the OilPriceAPI ActionCable endpoint
|
|
5
|
+
* (`wss://api.oilpriceapi.com/cable`).
|
|
6
|
+
*
|
|
7
|
+
* Streaming is a **Reservoir Mastery (Professional+)** feature. Connections
|
|
8
|
+
* authenticate with your API key and subscribe to the `EnergyPricesChannel`,
|
|
9
|
+
* which pushes an initial `welcome` snapshot followed by live `price_update`
|
|
10
|
+
* and (for drilling-tier accounts) `rig_count_update` messages.
|
|
11
|
+
*
|
|
12
|
+
* The implementation speaks the raw ActionCable JSON subprotocol over the
|
|
13
|
+
* `ws` package: it performs the `welcome` -> `subscribe` ->
|
|
14
|
+
* `confirm_subscription` handshake, answers server `ping` frames, and
|
|
15
|
+
* surfaces decoded channel messages as typed events. Auto-reconnect with
|
|
16
|
+
* exponential backoff keeps the stream alive across transient network drops.
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* ```typescript
|
|
20
|
+
* const sub = client.stream.prices({}, (update) => {
|
|
21
|
+
* console.log(update.prices.oil.wti?.original_price);
|
|
22
|
+
* });
|
|
23
|
+
*
|
|
24
|
+
* sub.on("rig_count_update", (m) => console.log(m.rig_count.region, m.rig_count.count));
|
|
25
|
+
* sub.on("error", (err) => console.error(err));
|
|
26
|
+
*
|
|
27
|
+
* // later
|
|
28
|
+
* sub.close();
|
|
29
|
+
* ```
|
|
30
|
+
*/
|
|
31
|
+
import { EventEmitter } from "node:events";
|
|
32
|
+
import WebSocket from "ws";
|
|
33
|
+
/**
|
|
34
|
+
* The ActionCable channel exposed by the OilPriceAPI server.
|
|
35
|
+
*
|
|
36
|
+
* Confirmed from `app/channels/energy_prices_channel.rb`
|
|
37
|
+
* (`class EnergyPricesChannel`).
|
|
38
|
+
*/
|
|
39
|
+
export const ENERGY_PRICES_CHANNEL = "EnergyPricesChannel";
|
|
40
|
+
/** Maps streamed slug <-> upstream code so `commodities` filtering accepts either. */
|
|
41
|
+
const COMMODITY_CODE_TO_SLUG = {
|
|
42
|
+
BRENT_CRUDE_USD: "brent",
|
|
43
|
+
WTI_USD: "wti",
|
|
44
|
+
NATURAL_GAS_GBP: "uk",
|
|
45
|
+
NATURAL_GAS_USD: "us",
|
|
46
|
+
DUTCH_TTF_EUR: "eu",
|
|
47
|
+
};
|
|
48
|
+
/**
|
|
49
|
+
* Handle for an active price stream.
|
|
50
|
+
*
|
|
51
|
+
* Extends `EventEmitter`. Emitted events:
|
|
52
|
+
* - `"connected"` — transport connected and subscription confirmed
|
|
53
|
+
* - `"welcome"` — initial snapshot ({@link WelcomeMessage})
|
|
54
|
+
* - `"price_update"` — live price broadcast ({@link PriceUpdateMessage})
|
|
55
|
+
* - `"rig_count_update"` — drilling broadcast ({@link RigCountUpdateMessage})
|
|
56
|
+
* - `"message"` — every decoded channel message ({@link StreamMessage})
|
|
57
|
+
* - `"reconnecting"` — a reconnect attempt is scheduled (`{ attempt, delay }`)
|
|
58
|
+
* - `"disconnected"` — transport closed (`{ code, reason }`)
|
|
59
|
+
* - `"error"` — an `Error` (transport error, unauthorized, or retries exhausted)
|
|
60
|
+
* - `"close"` — the subscription was closed via {@link PriceStreamSubscription.close}
|
|
61
|
+
*/
|
|
62
|
+
export class PriceStreamSubscription extends EventEmitter {
|
|
63
|
+
/**
|
|
64
|
+
* @param url - The `wss://.../cable` endpoint.
|
|
65
|
+
* @param apiKey - API key sent as the ActionCable `Authorization: Token <key>` header.
|
|
66
|
+
* @param options - Reconnect + filter options.
|
|
67
|
+
* @param wsImpl - Injectable WebSocket constructor (used by tests to mock).
|
|
68
|
+
* @internal Construct via {@link StreamingResource.prices}.
|
|
69
|
+
*/
|
|
70
|
+
constructor(url, apiKey, options, wsImpl = WebSocket) {
|
|
71
|
+
super();
|
|
72
|
+
this.wsImpl = wsImpl;
|
|
73
|
+
this.ws = null;
|
|
74
|
+
this.closed = false;
|
|
75
|
+
this.subscribed = false;
|
|
76
|
+
this.reconnectAttempts = 0;
|
|
77
|
+
this.reconnectTimer = null;
|
|
78
|
+
this.url = url;
|
|
79
|
+
this.apiKey = apiKey;
|
|
80
|
+
this.options = {
|
|
81
|
+
autoReconnect: options.autoReconnect ?? true,
|
|
82
|
+
reconnectDelay: options.reconnectDelay ?? 1000,
|
|
83
|
+
maxReconnectDelay: options.maxReconnectDelay ?? 30000,
|
|
84
|
+
maxReconnectAttempts: options.maxReconnectAttempts ?? 10,
|
|
85
|
+
commodities: options.commodities,
|
|
86
|
+
};
|
|
87
|
+
// The ActionCable subscription identifier. The channel takes no params;
|
|
88
|
+
// we additionally pass `api_key` in the identifier for parity with the
|
|
89
|
+
// documented native-WebSocket clients, while the server's
|
|
90
|
+
// ApplicationCable::Connection authenticates from the Authorization header.
|
|
91
|
+
this.identifier = JSON.stringify({
|
|
92
|
+
channel: ENERGY_PRICES_CHANNEL,
|
|
93
|
+
api_key: apiKey,
|
|
94
|
+
});
|
|
95
|
+
if (options.commodities && options.commodities.length > 0) {
|
|
96
|
+
this.commodityFilter = new Set(options.commodities.map((c) => COMMODITY_CODE_TO_SLUG[c] ?? c.toLowerCase()));
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
this.commodityFilter = null;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Open the transport and begin the ActionCable handshake.
|
|
104
|
+
* @internal Called once by {@link StreamingResource.prices}.
|
|
105
|
+
*/
|
|
106
|
+
connect() {
|
|
107
|
+
if (this.closed)
|
|
108
|
+
return;
|
|
109
|
+
const ws = new this.wsImpl(this.url, {
|
|
110
|
+
headers: {
|
|
111
|
+
Authorization: `Token ${this.apiKey}`,
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
this.ws = ws;
|
|
115
|
+
ws.on("open", () => {
|
|
116
|
+
// ActionCable sends a transport `welcome` frame first; we subscribe on
|
|
117
|
+
// open (the server tolerates an early subscribe and replies with
|
|
118
|
+
// confirm_subscription once the connection is established).
|
|
119
|
+
this.send({ command: "subscribe", identifier: this.identifier });
|
|
120
|
+
});
|
|
121
|
+
ws.on("message", (raw) => this.handleRaw(raw));
|
|
122
|
+
ws.on("error", (err) => {
|
|
123
|
+
this.emit("error", err);
|
|
124
|
+
});
|
|
125
|
+
ws.on("close", (code, reason) => {
|
|
126
|
+
this.ws = null;
|
|
127
|
+
this.subscribed = false;
|
|
128
|
+
this.emit("disconnected", { code, reason: reason?.toString() ?? "" });
|
|
129
|
+
if (!this.closed) {
|
|
130
|
+
this.scheduleReconnect();
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
handleRaw(raw) {
|
|
135
|
+
let frame;
|
|
136
|
+
try {
|
|
137
|
+
frame = JSON.parse(raw.toString());
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
// Ignore malformed frames rather than crash the stream.
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
// ActionCable transport-level frames carry a top-level `type`.
|
|
144
|
+
const transportType = frame["type"];
|
|
145
|
+
if (transportType === "ping") {
|
|
146
|
+
// Heartbeat — nothing to do; presence of frames keeps `ws` alive.
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
if (transportType === "welcome") {
|
|
150
|
+
// Transport handshake complete. (subscribe already sent on open.)
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
if (transportType === "confirm_subscription") {
|
|
154
|
+
this.subscribed = true;
|
|
155
|
+
this.reconnectAttempts = 0;
|
|
156
|
+
this.emit("connected");
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (transportType === "reject_subscription") {
|
|
160
|
+
this.emit("error", new Error("WebSocket subscription rejected. Streaming requires a Reservoir Mastery " +
|
|
161
|
+
"(Professional+) plan and a valid API key."));
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
if (transportType === "disconnect") {
|
|
165
|
+
// Server-initiated disconnect (e.g. auth failure). Let `close` drive reconnect.
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
// Channel message: payload lives under `message`.
|
|
169
|
+
const payload = frame["message"];
|
|
170
|
+
if (payload && typeof payload === "object") {
|
|
171
|
+
this.dispatch(payload);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
dispatch(message) {
|
|
175
|
+
this.emit("message", message);
|
|
176
|
+
switch (message.type) {
|
|
177
|
+
case "welcome":
|
|
178
|
+
this.emit("welcome", message);
|
|
179
|
+
break;
|
|
180
|
+
case "price_update": {
|
|
181
|
+
const update = message;
|
|
182
|
+
if (this.matchesFilter(update)) {
|
|
183
|
+
this.emit("price_update", update);
|
|
184
|
+
}
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
case "rig_count_update":
|
|
188
|
+
this.emit("rig_count_update", message);
|
|
189
|
+
break;
|
|
190
|
+
default:
|
|
191
|
+
// Unknown message types are still emitted via `message` above.
|
|
192
|
+
break;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
matchesFilter(update) {
|
|
196
|
+
if (!this.commodityFilter)
|
|
197
|
+
return true;
|
|
198
|
+
const { oil, natural_gas } = update.prices ?? {};
|
|
199
|
+
const present = {
|
|
200
|
+
brent: oil?.brent,
|
|
201
|
+
wti: oil?.wti,
|
|
202
|
+
uk: natural_gas?.uk,
|
|
203
|
+
us: natural_gas?.us,
|
|
204
|
+
eu: natural_gas?.eu,
|
|
205
|
+
};
|
|
206
|
+
for (const slug of this.commodityFilter) {
|
|
207
|
+
if (present[slug])
|
|
208
|
+
return true;
|
|
209
|
+
}
|
|
210
|
+
return false;
|
|
211
|
+
}
|
|
212
|
+
scheduleReconnect() {
|
|
213
|
+
if (this.closed || !this.options.autoReconnect)
|
|
214
|
+
return;
|
|
215
|
+
if (this.reconnectAttempts >= this.options.maxReconnectAttempts) {
|
|
216
|
+
this.emit("error", new Error(`WebSocket reconnect failed after ${this.reconnectAttempts} attempt(s); giving up.`));
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
const attempt = this.reconnectAttempts++;
|
|
220
|
+
const delay = Math.min(this.options.reconnectDelay * Math.pow(2, attempt), this.options.maxReconnectDelay);
|
|
221
|
+
this.emit("reconnecting", { attempt: attempt + 1, delay });
|
|
222
|
+
this.reconnectTimer = setTimeout(() => {
|
|
223
|
+
this.reconnectTimer = null;
|
|
224
|
+
this.connect();
|
|
225
|
+
}, delay);
|
|
226
|
+
}
|
|
227
|
+
send(payload) {
|
|
228
|
+
if (this.ws && this.ws.readyState === this.wsImpl.OPEN) {
|
|
229
|
+
this.ws.send(JSON.stringify(payload));
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
/** Whether the channel subscription has been confirmed by the server. */
|
|
233
|
+
get isSubscribed() {
|
|
234
|
+
return this.subscribed;
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Cleanly tear down the stream: cancels any pending reconnect, unsubscribes
|
|
238
|
+
* from the channel, and closes the socket. Safe to call multiple times.
|
|
239
|
+
* Emits `"close"` once.
|
|
240
|
+
*/
|
|
241
|
+
close() {
|
|
242
|
+
if (this.closed)
|
|
243
|
+
return;
|
|
244
|
+
this.closed = true;
|
|
245
|
+
if (this.reconnectTimer) {
|
|
246
|
+
clearTimeout(this.reconnectTimer);
|
|
247
|
+
this.reconnectTimer = null;
|
|
248
|
+
}
|
|
249
|
+
if (this.ws) {
|
|
250
|
+
const ws = this.ws;
|
|
251
|
+
try {
|
|
252
|
+
if (ws.readyState === this.wsImpl.OPEN) {
|
|
253
|
+
ws.send(JSON.stringify({ command: "unsubscribe", identifier: this.identifier }));
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
catch {
|
|
257
|
+
// ignore — we're closing anyway
|
|
258
|
+
}
|
|
259
|
+
try {
|
|
260
|
+
ws.close();
|
|
261
|
+
}
|
|
262
|
+
catch {
|
|
263
|
+
// ignore
|
|
264
|
+
}
|
|
265
|
+
this.ws = null;
|
|
266
|
+
}
|
|
267
|
+
this.subscribed = false;
|
|
268
|
+
this.emit("close");
|
|
269
|
+
this.removeAllListeners();
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Streaming resource — entry point for real-time price subscriptions.
|
|
274
|
+
*
|
|
275
|
+
* Accessed via `client.stream`.
|
|
276
|
+
*/
|
|
277
|
+
export class StreamingResource {
|
|
278
|
+
constructor(client,
|
|
279
|
+
/**
|
|
280
|
+
* Injectable WebSocket implementation. Defaults to the `ws` package;
|
|
281
|
+
* tests pass a mock constructor.
|
|
282
|
+
* @internal
|
|
283
|
+
*/
|
|
284
|
+
wsImpl = WebSocket) {
|
|
285
|
+
this.client = client;
|
|
286
|
+
this.wsImpl = wsImpl;
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* Derive the `wss://.../cable` endpoint from the client's REST base URL.
|
|
290
|
+
*
|
|
291
|
+
* `https://api.oilpriceapi.com` -> `wss://api.oilpriceapi.com/cable`
|
|
292
|
+
* `http://localhost:5000` -> `ws://localhost:5000/cable`
|
|
293
|
+
*/
|
|
294
|
+
cableUrl() {
|
|
295
|
+
const base = this.client["baseUrl"];
|
|
296
|
+
const wsBase = base
|
|
297
|
+
.replace(/^http:/, "ws:")
|
|
298
|
+
.replace(/^https:/, "wss:")
|
|
299
|
+
.replace(/\/$/, "");
|
|
300
|
+
return `${wsBase}/cable`;
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* Open a real-time price stream over the `EnergyPricesChannel`.
|
|
304
|
+
*
|
|
305
|
+
* Returns a {@link PriceStreamSubscription} handle (an `EventEmitter`) you
|
|
306
|
+
* can attach further listeners to and `.close()` when done. The optional
|
|
307
|
+
* `onUpdate` callback is a convenience wired to the `price_update` event.
|
|
308
|
+
*
|
|
309
|
+
* @param options - Filtering and reconnect options.
|
|
310
|
+
* @param onUpdate - Optional callback for each `price_update` message.
|
|
311
|
+
* @returns The subscription handle.
|
|
312
|
+
*
|
|
313
|
+
* @throws {OilPriceAPIError} If no API key is configured on the client.
|
|
314
|
+
*
|
|
315
|
+
* @example
|
|
316
|
+
* ```typescript
|
|
317
|
+
* const client = new OilPriceAPI({ apiKey: process.env.OILPRICEAPI_KEY });
|
|
318
|
+
*
|
|
319
|
+
* const sub = client.stream.prices(
|
|
320
|
+
* { commodities: ["WTI_USD", "BRENT_CRUDE_USD"] },
|
|
321
|
+
* (update) => {
|
|
322
|
+
* const wti = update.prices.oil.wti;
|
|
323
|
+
* if (wti) console.log(`WTI ${wti.original_price} @ ${update.timestamp}`);
|
|
324
|
+
* },
|
|
325
|
+
* );
|
|
326
|
+
*
|
|
327
|
+
* sub.on("connected", () => console.log("streaming live"));
|
|
328
|
+
* sub.on("error", (err) => console.error("stream error:", err));
|
|
329
|
+
*
|
|
330
|
+
* process.on("SIGINT", () => sub.close());
|
|
331
|
+
* ```
|
|
332
|
+
*/
|
|
333
|
+
prices(options = {}, onUpdate) {
|
|
334
|
+
const apiKey = this.client["apiKey"];
|
|
335
|
+
const sub = new PriceStreamSubscription(this.cableUrl(), apiKey, options, this.wsImpl);
|
|
336
|
+
if (onUpdate) {
|
|
337
|
+
sub.on("price_update", onUpdate);
|
|
338
|
+
}
|
|
339
|
+
sub.connect();
|
|
340
|
+
return sub;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
@@ -10,18 +10,16 @@ import type { OilPriceAPI } from "../client.js";
|
|
|
10
10
|
export interface WebhookEndpoint {
|
|
11
11
|
/** Unique webhook identifier */
|
|
12
12
|
id: string;
|
|
13
|
-
/** User-friendly webhook
|
|
14
|
-
|
|
13
|
+
/** User-friendly description of the webhook */
|
|
14
|
+
description?: string;
|
|
15
15
|
/** Webhook URL (must be HTTPS) */
|
|
16
16
|
url: string;
|
|
17
17
|
/** Event types to subscribe to */
|
|
18
18
|
events: string[];
|
|
19
|
-
/**
|
|
20
|
-
|
|
21
|
-
/** Optional secret for signature verification */
|
|
19
|
+
/** Lifecycle status (e.g. 'active', 'disabled') */
|
|
20
|
+
status?: string;
|
|
21
|
+
/** Optional secret for signature verification (generated server-side) */
|
|
22
22
|
secret?: string;
|
|
23
|
-
/** Optional metadata */
|
|
24
|
-
metadata?: Record<string, unknown>;
|
|
25
23
|
/** Number of successful deliveries */
|
|
26
24
|
successful_deliveries: number;
|
|
27
25
|
/** Number of failed deliveries */
|
|
@@ -37,37 +35,53 @@ export interface WebhookEndpoint {
|
|
|
37
35
|
}
|
|
38
36
|
/**
|
|
39
37
|
* Parameters for creating a webhook
|
|
38
|
+
*
|
|
39
|
+
* NOTE: The API permits a flat (un-nested) body with the fields below. Earlier
|
|
40
|
+
* SDK versions nested these under a `webhook` key and used `name`/`enabled`,
|
|
41
|
+
* which the controller dropped — the controller reads `description` and `status`.
|
|
40
42
|
*/
|
|
41
43
|
export interface CreateWebhookParams {
|
|
42
|
-
/** User-friendly webhook name */
|
|
43
|
-
name: string;
|
|
44
44
|
/** Webhook URL (must be HTTPS) */
|
|
45
45
|
url: string;
|
|
46
46
|
/** Event types to subscribe to */
|
|
47
47
|
events: string[];
|
|
48
|
-
/**
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
|
|
48
|
+
/** User-friendly description */
|
|
49
|
+
description?: string;
|
|
50
|
+
/** Lifecycle status (e.g. 'active', 'disabled') */
|
|
51
|
+
status?: string;
|
|
52
|
+
/** Commodity codes to filter events to */
|
|
53
|
+
commodity_filters?: string[];
|
|
54
|
+
/** US state codes to filter events to */
|
|
55
|
+
state_filters?: string[];
|
|
56
|
+
/** Per-second delivery rate limit */
|
|
57
|
+
rate_limit_per_second?: number;
|
|
58
|
+
/** Delivery timeout in seconds */
|
|
59
|
+
timeout_seconds?: number;
|
|
60
|
+
/** Max delivery retry attempts */
|
|
61
|
+
max_retries?: number;
|
|
54
62
|
}
|
|
55
63
|
/**
|
|
56
64
|
* Parameters for updating a webhook
|
|
57
65
|
*/
|
|
58
66
|
export interface UpdateWebhookParams {
|
|
59
|
-
/** User-friendly webhook name */
|
|
60
|
-
name?: string;
|
|
61
67
|
/** Webhook URL */
|
|
62
68
|
url?: string;
|
|
63
69
|
/** Event types to subscribe to */
|
|
64
70
|
events?: string[];
|
|
65
|
-
/**
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
|
|
71
|
+
/** User-friendly description */
|
|
72
|
+
description?: string;
|
|
73
|
+
/** Lifecycle status (e.g. 'active', 'disabled') */
|
|
74
|
+
status?: string;
|
|
75
|
+
/** Commodity codes to filter events to */
|
|
76
|
+
commodity_filters?: string[];
|
|
77
|
+
/** US state codes to filter events to */
|
|
78
|
+
state_filters?: string[];
|
|
79
|
+
/** Per-second delivery rate limit */
|
|
80
|
+
rate_limit_per_second?: number;
|
|
81
|
+
/** Delivery timeout in seconds */
|
|
82
|
+
timeout_seconds?: number;
|
|
83
|
+
/** Max delivery retry attempts */
|
|
84
|
+
max_retries?: number;
|
|
71
85
|
}
|
|
72
86
|
/**
|
|
73
87
|
* Webhook test response
|
|
@@ -287,4 +301,40 @@ export declare class WebhooksResource {
|
|
|
287
301
|
* ```
|
|
288
302
|
*/
|
|
289
303
|
events(id: string): Promise<WebhookEvent[]>;
|
|
304
|
+
/**
|
|
305
|
+
* Verify a webhook signature.
|
|
306
|
+
*
|
|
307
|
+
* Validates that a webhook payload was sent by OilPriceAPI by checking
|
|
308
|
+
* the HMAC-SHA256 signature. Uses constant-time comparison to prevent
|
|
309
|
+
* timing attacks.
|
|
310
|
+
*
|
|
311
|
+
* @param payload - Raw request body (string or Buffer)
|
|
312
|
+
* @param signature - Value of the X-OilPriceAPI-Signature header (e.g., "sha256=abc123...")
|
|
313
|
+
* @param secret - Your webhook signing secret
|
|
314
|
+
* @returns true if signature is valid
|
|
315
|
+
*
|
|
316
|
+
* @example
|
|
317
|
+
* ```typescript
|
|
318
|
+
* import express from 'express';
|
|
319
|
+
* import { OilPriceAPI } from 'oilpriceapi';
|
|
320
|
+
*
|
|
321
|
+
* const app = express();
|
|
322
|
+
* const client = new OilPriceAPI({ apiKey: 'your_key' });
|
|
323
|
+
*
|
|
324
|
+
* // Use raw body parser for webhook routes
|
|
325
|
+
* app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
|
|
326
|
+
* const signature = req.headers['x-oilpriceapi-signature'] as string;
|
|
327
|
+
* const isValid = client.webhooks.verifySignature(req.body, signature, 'your_secret');
|
|
328
|
+
*
|
|
329
|
+
* if (!isValid) {
|
|
330
|
+
* return res.status(401).send('Invalid signature');
|
|
331
|
+
* }
|
|
332
|
+
*
|
|
333
|
+
* const event = JSON.parse(req.body.toString());
|
|
334
|
+
* console.log('Verified webhook:', event.type);
|
|
335
|
+
* res.sendStatus(200);
|
|
336
|
+
* });
|
|
337
|
+
* ```
|
|
338
|
+
*/
|
|
339
|
+
verifySignature(payload: string | Buffer, signature: string, secret: string): boolean;
|
|
290
340
|
}
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Manage webhook endpoints for real-time event notifications.
|
|
5
5
|
*/
|
|
6
|
+
import { ValidationError } from "../errors.js";
|
|
7
|
+
import { verifyWebhookSignature } from "../index.js";
|
|
6
8
|
/**
|
|
7
9
|
* Webhooks Resource
|
|
8
10
|
*
|
|
@@ -85,7 +87,7 @@ export class WebhooksResource {
|
|
|
85
87
|
*/
|
|
86
88
|
async get(id) {
|
|
87
89
|
if (!id || typeof id !== "string") {
|
|
88
|
-
throw new
|
|
90
|
+
throw new ValidationError("Webhook ID must be a non-empty string");
|
|
89
91
|
}
|
|
90
92
|
const response = await this.client["request"](`/v1/webhooks/${id}`, {});
|
|
91
93
|
return "webhook" in response ? response.webhook : response;
|
|
@@ -112,41 +114,18 @@ export class WebhooksResource {
|
|
|
112
114
|
* ```
|
|
113
115
|
*/
|
|
114
116
|
async create(params) {
|
|
115
|
-
if (!params.name || typeof params.name !== "string") {
|
|
116
|
-
throw new Error("Webhook name is required");
|
|
117
|
-
}
|
|
118
117
|
if (!params.url || !params.url.startsWith("https://")) {
|
|
119
|
-
throw new
|
|
118
|
+
throw new ValidationError("Webhook URL must use HTTPS protocol");
|
|
120
119
|
}
|
|
121
|
-
if (!params.events ||
|
|
122
|
-
|
|
123
|
-
params.events.length === 0) {
|
|
124
|
-
throw new Error("At least one event type is required");
|
|
120
|
+
if (!params.events || !Array.isArray(params.events) || params.events.length === 0) {
|
|
121
|
+
throw new ValidationError("At least one event type is required");
|
|
125
122
|
}
|
|
126
|
-
|
|
127
|
-
const response = await
|
|
123
|
+
// The controller reads a flat (un-nested) body via params.permit(...).
|
|
124
|
+
const response = await this.client["request"]("/v1/webhooks", {}, {
|
|
128
125
|
method: "POST",
|
|
129
|
-
|
|
130
|
-
Authorization: `Bearer ${this.client["apiKey"]}`,
|
|
131
|
-
"Content-Type": "application/json",
|
|
132
|
-
},
|
|
133
|
-
body: JSON.stringify({
|
|
134
|
-
webhook: {
|
|
135
|
-
name: params.name,
|
|
136
|
-
url: params.url,
|
|
137
|
-
events: params.events,
|
|
138
|
-
enabled: params.enabled ?? true,
|
|
139
|
-
secret: params.secret,
|
|
140
|
-
metadata: params.metadata,
|
|
141
|
-
},
|
|
142
|
-
}),
|
|
126
|
+
body: { ...params },
|
|
143
127
|
});
|
|
144
|
-
|
|
145
|
-
const errorText = await response.text();
|
|
146
|
-
throw new Error(`Failed to create webhook: ${response.status} ${errorText}`);
|
|
147
|
-
}
|
|
148
|
-
const data = (await response.json());
|
|
149
|
-
return "webhook" in data ? data.webhook : data;
|
|
128
|
+
return "webhook" in response ? response.webhook : response;
|
|
150
129
|
}
|
|
151
130
|
/**
|
|
152
131
|
* Update a webhook endpoint
|
|
@@ -171,32 +150,20 @@ export class WebhooksResource {
|
|
|
171
150
|
*/
|
|
172
151
|
async update(id, params) {
|
|
173
152
|
if (!id || typeof id !== "string") {
|
|
174
|
-
throw new
|
|
153
|
+
throw new ValidationError("Webhook ID must be a non-empty string");
|
|
175
154
|
}
|
|
176
155
|
if (params.url !== undefined && !params.url.startsWith("https://")) {
|
|
177
|
-
throw new
|
|
156
|
+
throw new ValidationError("Webhook URL must use HTTPS protocol");
|
|
178
157
|
}
|
|
179
158
|
if (params.events !== undefined &&
|
|
180
159
|
(!Array.isArray(params.events) || params.events.length === 0)) {
|
|
181
|
-
throw new
|
|
160
|
+
throw new ValidationError("Events must be a non-empty array");
|
|
182
161
|
}
|
|
183
|
-
const
|
|
184
|
-
const response = await fetch(url, {
|
|
162
|
+
const response = await this.client["request"](`/v1/webhooks/${id}`, {}, {
|
|
185
163
|
method: "PATCH",
|
|
186
|
-
|
|
187
|
-
Authorization: `Bearer ${this.client["apiKey"]}`,
|
|
188
|
-
"Content-Type": "application/json",
|
|
189
|
-
},
|
|
190
|
-
body: JSON.stringify({
|
|
191
|
-
webhook: params,
|
|
192
|
-
}),
|
|
164
|
+
body: { ...params },
|
|
193
165
|
});
|
|
194
|
-
|
|
195
|
-
const errorText = await response.text();
|
|
196
|
-
throw new Error(`Failed to update webhook: ${response.status} ${errorText}`);
|
|
197
|
-
}
|
|
198
|
-
const data = (await response.json());
|
|
199
|
-
return "webhook" in data ? data.webhook : data;
|
|
166
|
+
return "webhook" in response ? response.webhook : response;
|
|
200
167
|
}
|
|
201
168
|
/**
|
|
202
169
|
* Delete a webhook endpoint
|
|
@@ -214,20 +181,9 @@ export class WebhooksResource {
|
|
|
214
181
|
*/
|
|
215
182
|
async delete(id) {
|
|
216
183
|
if (!id || typeof id !== "string") {
|
|
217
|
-
throw new
|
|
218
|
-
}
|
|
219
|
-
const url = `${this.client["baseUrl"]}/v1/webhooks/${id}`;
|
|
220
|
-
const response = await fetch(url, {
|
|
221
|
-
method: "DELETE",
|
|
222
|
-
headers: {
|
|
223
|
-
Authorization: `Bearer ${this.client["apiKey"]}`,
|
|
224
|
-
"Content-Type": "application/json",
|
|
225
|
-
},
|
|
226
|
-
});
|
|
227
|
-
if (!response.ok) {
|
|
228
|
-
const errorText = await response.text();
|
|
229
|
-
throw new Error(`Failed to delete webhook: ${response.status} ${errorText}`);
|
|
184
|
+
throw new ValidationError("Webhook ID must be a non-empty string");
|
|
230
185
|
}
|
|
186
|
+
await this.client["request"](`/v1/webhooks/${id}`, {}, { method: "DELETE" });
|
|
231
187
|
}
|
|
232
188
|
/**
|
|
233
189
|
* Test a webhook endpoint
|
|
@@ -252,21 +208,9 @@ export class WebhooksResource {
|
|
|
252
208
|
*/
|
|
253
209
|
async test(id) {
|
|
254
210
|
if (!id || typeof id !== "string") {
|
|
255
|
-
throw new
|
|
256
|
-
}
|
|
257
|
-
const url = `${this.client["baseUrl"]}/v1/webhooks/${id}/test`;
|
|
258
|
-
const response = await fetch(url, {
|
|
259
|
-
method: "POST",
|
|
260
|
-
headers: {
|
|
261
|
-
Authorization: `Bearer ${this.client["apiKey"]}`,
|
|
262
|
-
"Content-Type": "application/json",
|
|
263
|
-
},
|
|
264
|
-
});
|
|
265
|
-
if (!response.ok) {
|
|
266
|
-
const errorText = await response.text();
|
|
267
|
-
throw new Error(`Failed to test webhook: ${response.status} ${errorText}`);
|
|
211
|
+
throw new ValidationError("Webhook ID must be a non-empty string");
|
|
268
212
|
}
|
|
269
|
-
return
|
|
213
|
+
return this.client["request"](`/v1/webhooks/${id}/test`, {}, { method: "POST" });
|
|
270
214
|
}
|
|
271
215
|
/**
|
|
272
216
|
* Get webhook event history
|
|
@@ -289,9 +233,47 @@ export class WebhooksResource {
|
|
|
289
233
|
*/
|
|
290
234
|
async events(id) {
|
|
291
235
|
if (!id || typeof id !== "string") {
|
|
292
|
-
throw new
|
|
236
|
+
throw new ValidationError("Webhook ID must be a non-empty string");
|
|
293
237
|
}
|
|
294
238
|
const response = await this.client["request"](`/v1/webhooks/${id}/events`, {});
|
|
295
239
|
return Array.isArray(response) ? response : response.events;
|
|
296
240
|
}
|
|
241
|
+
/**
|
|
242
|
+
* Verify a webhook signature.
|
|
243
|
+
*
|
|
244
|
+
* Validates that a webhook payload was sent by OilPriceAPI by checking
|
|
245
|
+
* the HMAC-SHA256 signature. Uses constant-time comparison to prevent
|
|
246
|
+
* timing attacks.
|
|
247
|
+
*
|
|
248
|
+
* @param payload - Raw request body (string or Buffer)
|
|
249
|
+
* @param signature - Value of the X-OilPriceAPI-Signature header (e.g., "sha256=abc123...")
|
|
250
|
+
* @param secret - Your webhook signing secret
|
|
251
|
+
* @returns true if signature is valid
|
|
252
|
+
*
|
|
253
|
+
* @example
|
|
254
|
+
* ```typescript
|
|
255
|
+
* import express from 'express';
|
|
256
|
+
* import { OilPriceAPI } from 'oilpriceapi';
|
|
257
|
+
*
|
|
258
|
+
* const app = express();
|
|
259
|
+
* const client = new OilPriceAPI({ apiKey: 'your_key' });
|
|
260
|
+
*
|
|
261
|
+
* // Use raw body parser for webhook routes
|
|
262
|
+
* app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
|
|
263
|
+
* const signature = req.headers['x-oilpriceapi-signature'] as string;
|
|
264
|
+
* const isValid = client.webhooks.verifySignature(req.body, signature, 'your_secret');
|
|
265
|
+
*
|
|
266
|
+
* if (!isValid) {
|
|
267
|
+
* return res.status(401).send('Invalid signature');
|
|
268
|
+
* }
|
|
269
|
+
*
|
|
270
|
+
* const event = JSON.parse(req.body.toString());
|
|
271
|
+
* console.log('Verified webhook:', event.type);
|
|
272
|
+
* res.sendStatus(200);
|
|
273
|
+
* });
|
|
274
|
+
* ```
|
|
275
|
+
*/
|
|
276
|
+
verifySignature(payload, signature, secret) {
|
|
277
|
+
return verifyWebhookSignature(payload, signature, secret);
|
|
278
|
+
}
|
|
297
279
|
}
|