@izak0s/spacebring-api 1.0.0 → 1.2.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 CHANGED
@@ -46,26 +46,38 @@ const sb = new Spacebring({
46
46
  // fetch: customFetch, // inject your own fetch (tests, proxies)
47
47
  });
48
48
 
49
- // Single-property envelopes are unwrapped: entities and plain arrays come back directly
50
- const invoice = await sb.billing.invoices.get(invoiceId);
51
- const locations = await sb.locations.list();
52
- await sb.visitors.visits.checkIn({ locationRef, visitRef });
53
-
54
- // Paginated lists return the page envelope, so nextPageToken stays available
55
- const { benefits, nextPageToken } = await sb.benefits.list({ locationRef });
49
+ async function main() {
50
+ // Single-property envelopes are unwrapped: entities and plain arrays come back directly
51
+ const invoice = await sb.billing.invoices.get(invoiceId);
52
+ const locations = await sb.locations.list();
53
+ await sb.visitors.visits.checkIn({ locationRef, visitRef });
54
+
55
+ // Paginated lists return the page envelope, so nextPageToken stays available
56
+ const { benefits, nextPageToken } = await sb.benefits.list({ locationRef });
57
+
58
+ // ...or let iterate() walk nextPageToken for you; breaking early stops fetching
59
+ for await (const booking of sb.resources.bookings.iterate({ locationRef })) {
60
+ console.log(booking.id);
61
+ }
56
62
 
57
- // ...or let iterate() walk nextPageToken for you; breaking early stops fetching
58
- for await (const booking of sb.resources.bookings.iterate({ locationRef })) {
59
- console.log(booking.id);
63
+ // Endpoints returning multiple payloads keep the envelope
64
+ const { invoice: paid, payment } = await sb.billing.invoices.pay(invoiceId, {
65
+ paymentMethod: { type: "stripe" },
66
+ });
60
67
  }
61
68
 
62
- // Endpoints returning multiple payloads keep the envelope
63
- const { invoice: paid, payment } = await sb.billing.invoices.pay(invoiceId, {
64
- paymentMethod: { type: "stripe" },
65
- });
69
+ main().catch(console.error);
66
70
  ```
67
71
 
68
- TypeScript helpers are exported too: `SpacebringConfig`, `SpacebringResources`, and the raw spec types `paths` / `components` / `operations` (e.g. `components["schemas"]["invoice"]`).
72
+ Entity types are exported by name — `import type { Booking, Invoice, Membership } from "@izak0s/spacebring-api"` — matching what the methods return (`get`/`create`/`update` resolve to the entity, `iterate()` yields it). Lower-level helpers too: `SpacebringConfig`, `SpacebringResources`, and the raw spec types `paths` / `components` / `operations`.
73
+
74
+ ### Data formats
75
+
76
+ Values are passed through exactly as the API sends them — no runtime conversion:
77
+
78
+ - **Dates** (`createDate`, `startDate`, …) are ISO 8601 strings — wrap in `new Date(booking.startDate)` when you need a `Date`.
79
+ - **Money** (`amount`, `price`, …) arrives as decimal floats. Fine for display; for accounting arithmetic convert to integer cents first to avoid floating-point drift.
80
+ - **IDs** (`id`, `*Ref`) are UUID strings.
69
81
 
70
82
  ---
71
83
 
@@ -73,6 +85,8 @@ TypeScript helpers are exported too: `SpacebringConfig`, `SpacebringResources`,
73
85
 
74
86
  HTTP Basic with your **Client ID** and **Client Secret** from **Spacebring → [Network] → Network Settings → Developers**. The client builds the `Authorization: Basic …` header for you. The API's OAuth2 flow is not currently supported.
75
87
 
88
+ For development without touching live data, Spacebring offers a [test environment](https://www.spacebring.com/docs/administration/test-environment) (Network settings → Billing add-on) with free sandbox API credentials that work with this client unchanged.
89
+
76
90
  ---
77
91
 
78
92
  ## Error handling
@@ -93,6 +107,10 @@ try {
93
107
 
94
108
  Malformed successes are covered too: a 2xx with an empty or incomplete body throws a `SpacebringError` (never a bare `TypeError`), and `iterate()` throws instead of looping forever if the API repeats a page token.
95
109
 
110
+ ### Rate limits
111
+
112
+ The API allows **10 requests per second**. Rate-limited requests (429) are retried automatically — up to 3 times, honoring `Retry-After` or backing off exponentially — so `iterate()` survives the limit out of the box. Tune or disable via `maxRetries` in the config (`maxRetries: 0` turns it off); a 429 that persists past the retries is thrown as a normal `SpacebringError`.
113
+
96
114
  ---
97
115
 
98
116
  ## Escape hatch
package/dist/index.cjs CHANGED
@@ -40,24 +40,27 @@ function createClient(clientOptions) {
40
40
  fetch: baseFetch = globalThis.fetch,
41
41
  querySerializer: globalQuerySerializer,
42
42
  bodySerializer: globalBodySerializer,
43
+ pathSerializer: globalPathSerializer,
43
44
  headers: baseHeaders,
44
45
  requestInitExt = void 0,
45
46
  ...baseOptions
46
47
  } = { ...clientOptions };
47
48
  requestInitExt = supportsRequestInitExt() ? requestInitExt : void 0;
48
49
  baseUrl = removeTrailingSlash(baseUrl);
49
- const middlewares = [];
50
+ const globalMiddlewares = [];
50
51
  async function coreFetch(schemaPath, fetchOptions) {
51
52
  const {
52
53
  baseUrl: localBaseUrl,
53
54
  fetch = baseFetch,
54
- Request = CustomRequest,
55
+ Request: Request2 = CustomRequest,
55
56
  headers,
56
57
  params = {},
57
58
  parseAs = "json",
58
59
  querySerializer: requestQuerySerializer,
59
60
  bodySerializer = globalBodySerializer ?? defaultBodySerializer,
61
+ pathSerializer: requestPathSerializer,
60
62
  body,
63
+ middleware: requestMiddlewares = [],
61
64
  ...init
62
65
  } = fetchOptions || {};
63
66
  let finalBaseUrl = baseUrl;
@@ -71,6 +74,7 @@ function createClient(clientOptions) {
71
74
  ...requestQuerySerializer
72
75
  });
73
76
  }
77
+ const pathSerializer = requestPathSerializer || globalPathSerializer || defaultPathSerializer;
74
78
  const serializedBody = body === void 0 ? void 0 : bodySerializer(
75
79
  body,
76
80
  // Note: we declare mergeHeaders() both here and below because it’s a bit of a chicken-or-egg situation:
@@ -90,6 +94,7 @@ function createClient(clientOptions) {
90
94
  headers,
91
95
  params.header
92
96
  );
97
+ const finalMiddlewares = [...globalMiddlewares, ...requestMiddlewares];
93
98
  const requestInit = {
94
99
  redirect: "follow",
95
100
  ...baseOptions,
@@ -99,8 +104,8 @@ function createClient(clientOptions) {
99
104
  };
100
105
  let id;
101
106
  let options;
102
- let request = new Request(
103
- createFinalURL(schemaPath, { baseUrl: finalBaseUrl, params, querySerializer }),
107
+ let request = new Request2(
108
+ createFinalURL(schemaPath, { baseUrl: finalBaseUrl, params, querySerializer, pathSerializer }),
104
109
  requestInit
105
110
  );
106
111
  let response;
@@ -109,16 +114,17 @@ function createClient(clientOptions) {
109
114
  request[key] = init[key];
110
115
  }
111
116
  }
112
- if (middlewares.length) {
117
+ if (finalMiddlewares.length) {
113
118
  id = randomID();
114
119
  options = Object.freeze({
115
120
  baseUrl: finalBaseUrl,
116
121
  fetch,
117
122
  parseAs,
118
123
  querySerializer,
119
- bodySerializer
124
+ bodySerializer,
125
+ pathSerializer
120
126
  });
121
- for (const m of middlewares) {
127
+ for (const m of finalMiddlewares) {
122
128
  if (m && typeof m === "object" && typeof m.onRequest === "function") {
123
129
  const result = await m.onRequest({
124
130
  request,
@@ -128,7 +134,7 @@ function createClient(clientOptions) {
128
134
  id
129
135
  });
130
136
  if (result) {
131
- if (result instanceof Request) {
137
+ if (result instanceof Request2) {
132
138
  request = result;
133
139
  } else if (result instanceof Response) {
134
140
  response = result;
@@ -145,9 +151,9 @@ function createClient(clientOptions) {
145
151
  response = await fetch(request, requestInitExt);
146
152
  } catch (error2) {
147
153
  let errorAfterMiddleware = error2;
148
- if (middlewares.length) {
149
- for (let i = middlewares.length - 1; i >= 0; i--) {
150
- const m = middlewares[i];
154
+ if (finalMiddlewares.length) {
155
+ for (let i = finalMiddlewares.length - 1; i >= 0; i--) {
156
+ const m = finalMiddlewares[i];
151
157
  if (m && typeof m === "object" && typeof m.onError === "function") {
152
158
  const result = await m.onError({
153
159
  request,
@@ -176,9 +182,9 @@ function createClient(clientOptions) {
176
182
  throw errorAfterMiddleware;
177
183
  }
178
184
  }
179
- if (middlewares.length) {
180
- for (let i = middlewares.length - 1; i >= 0; i--) {
181
- const m = middlewares[i];
185
+ if (finalMiddlewares.length) {
186
+ for (let i = finalMiddlewares.length - 1; i >= 0; i--) {
187
+ const m = finalMiddlewares[i];
182
188
  if (m && typeof m === "object" && typeof m.onResponse === "function") {
183
189
  const result = await m.onResponse({
184
190
  request,
@@ -198,14 +204,22 @@ function createClient(clientOptions) {
198
204
  }
199
205
  }
200
206
  }
201
- if (response.status === 204 || request.method === "HEAD" || response.headers.get("Content-Length") === "0") {
207
+ const contentLength = response.headers.get("Content-Length");
208
+ if (response.status === 204 || request.method === "HEAD" || contentLength === "0" && !response.headers.get("Transfer-Encoding")?.includes("chunked")) {
202
209
  return response.ok ? { data: void 0, response } : { error: void 0, response };
203
210
  }
204
211
  if (response.ok) {
205
- if (parseAs === "stream") {
206
- return { data: response.body, response };
207
- }
208
- return { data: await response[parseAs](), response };
212
+ const getResponseData = async () => {
213
+ if (parseAs === "stream") {
214
+ return response.body;
215
+ }
216
+ if (parseAs === "json" && !contentLength) {
217
+ const raw = await response.text();
218
+ return raw ? JSON.parse(raw) : void 0;
219
+ }
220
+ return await response[parseAs]();
221
+ };
222
+ return { data: await getResponseData(), response };
209
223
  }
210
224
  let error = await response.text();
211
225
  try {
@@ -259,15 +273,15 @@ function createClient(clientOptions) {
259
273
  if (typeof m !== "object" || !("onRequest" in m || "onResponse" in m || "onError" in m)) {
260
274
  throw new Error("Middleware must be an object with one of `onRequest()`, `onResponse() or `onError()`");
261
275
  }
262
- middlewares.push(m);
276
+ globalMiddlewares.push(m);
263
277
  }
264
278
  },
265
279
  /** Unregister middleware */
266
280
  eject(...middleware) {
267
281
  for (const m of middleware) {
268
- const i = middlewares.indexOf(m);
282
+ const i = globalMiddlewares.indexOf(m);
269
283
  if (i !== -1) {
270
- middlewares.splice(i, 1);
284
+ globalMiddlewares.splice(i, 1);
271
285
  }
272
286
  }
273
287
  }
@@ -448,7 +462,7 @@ function defaultBodySerializer(body, headers) {
448
462
  function createFinalURL(pathname, options) {
449
463
  let finalURL = `${options.baseUrl}${pathname}`;
450
464
  if (options.params?.path) {
451
- finalURL = defaultPathSerializer(finalURL, options.params.path);
465
+ finalURL = options.pathSerializer(finalURL, options.params.path);
452
466
  }
453
467
  let search = options.querySerializer(options.params.query ?? {});
454
468
  if (search.startsWith("?")) {
@@ -1708,8 +1722,8 @@ function createSubscriptions(client, defaults) {
1708
1722
  *
1709
1723
  * Retrieve a certain subscription.
1710
1724
  */
1711
- async get(id) {
1712
- return unwrapProp(await client.GET("/subscriptions/v1/{id}", { params: { path: { id } } }), "subscription");
1725
+ async get(subscriptionId) {
1726
+ return unwrapProp(await client.GET("/subscriptions/v1/{subscriptionId}", { params: { path: { subscriptionId } } }), "subscription");
1713
1727
  },
1714
1728
  /**
1715
1729
  * Create a subscription
@@ -1724,28 +1738,28 @@ function createSubscriptions(client, defaults) {
1724
1738
  *
1725
1739
  * Update a certain subscription.
1726
1740
  */
1727
- async update(id, body) {
1728
- return unwrap(await client.PATCH("/subscriptions/v1/{id}", { params: { path: { id } }, body }));
1741
+ async update(subscriptionId, body) {
1742
+ return unwrap(await client.PATCH("/subscriptions/v1/{subscriptionId}", { params: { path: { subscriptionId } }, body }));
1729
1743
  },
1730
1744
  /**
1731
1745
  * Delete a subscription
1732
1746
  *
1733
1747
  * Delete a certain subscription.
1734
1748
  */
1735
- async delete(id) {
1736
- return unwrap(await client.DELETE("/subscriptions/v1/{id}", { params: { path: { id } } }));
1749
+ async delete(subscriptionId) {
1750
+ return unwrap(await client.DELETE("/subscriptions/v1/{subscriptionId}", { params: { path: { subscriptionId } } }));
1737
1751
  },
1738
1752
  /** Create a subscription item */
1739
- async createItem(id, body) {
1740
- return unwrapProp(await client.POST("/subscriptions/v1/{id}/items", { params: { path: { id } }, body }), "subscription");
1753
+ async createItem(subscriptionId, body) {
1754
+ return unwrapProp(await client.POST("/subscriptions/v1/{subscriptionId}/items", { params: { path: { subscriptionId } }, body }), "subscription");
1741
1755
  },
1742
1756
  /** Delete a subscription item */
1743
- async deleteItem(id, itemId) {
1744
- return unwrap(await client.DELETE("/subscriptions/v1/{id}/items/{itemId}", { params: { path: { id, itemId } } }));
1757
+ async deleteItem(subscriptionId, itemId) {
1758
+ return unwrap(await client.DELETE("/subscriptions/v1/{subscriptionId}/items/{itemId}", { params: { path: { subscriptionId, itemId } } }));
1745
1759
  },
1746
1760
  /** Update a subscription item */
1747
- async updateItem(id, itemId, body) {
1748
- return unwrap(await client.PATCH("/subscriptions/v1/{id}/items/{itemId}", { params: { path: { id, itemId } }, body }));
1761
+ async updateItem(subscriptionId, itemId, body) {
1762
+ return unwrap(await client.PATCH("/subscriptions/v1/{subscriptionId}/items/{itemId}", { params: { path: { subscriptionId, itemId } }, body }));
1749
1763
  }
1750
1764
  };
1751
1765
  }
@@ -2075,11 +2089,25 @@ var Spacebring = class {
2075
2089
  this.raw = createClient({
2076
2090
  baseUrl: config.baseUrl ?? "https://api.spacebring.com",
2077
2091
  headers,
2078
- fetch: config.fetch
2092
+ fetch: withRetry(config.fetch ?? globalThis.fetch, config.maxRetries ?? 3)
2079
2093
  });
2080
2094
  Object.assign(this, createResources2(this.raw, { networkId: config.networkId }));
2081
2095
  }
2082
2096
  };
2097
+ function withRetry(fetchImpl, maxRetries) {
2098
+ if (maxRetries <= 0) return fetchImpl;
2099
+ return async (input, init) => {
2100
+ const request = input instanceof Request && init === void 0 ? input : new Request(input, init);
2101
+ for (let attempt = 0; ; attempt += 1) {
2102
+ const response = await fetchImpl(request.clone());
2103
+ if (response.status !== 429 || attempt >= maxRetries) return response;
2104
+ const retryAfterHeader = response.headers.get("retry-after");
2105
+ const retryAfter = retryAfterHeader === null ? Number.NaN : Number(retryAfterHeader);
2106
+ const delayMs = Number.isFinite(retryAfter) && retryAfter >= 0 ? Math.min(retryAfter * 1e3, 6e4) : Math.min(250 * 2 ** attempt + Math.random() * 100, 5e3);
2107
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
2108
+ }
2109
+ };
2110
+ }
2083
2111
  function toBase64(value) {
2084
2112
  if (typeof Buffer !== "undefined") {
2085
2113
  return Buffer.from(value, "utf8").toString("base64");