@haddowg/json-api-client 1.3.0 → 1.4.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/dist/index.mjs CHANGED
@@ -539,18 +539,37 @@ const ATOMIC_MEDIA_TYPE = mediaType({ ext: [ATOMIC_EXT] });
539
539
  //#region src/request.ts
540
540
  const isAbsolute = (path) => /^https?:\/\//i.test(path);
541
541
  const RELATIVE_ORIGIN = "http://relative.invalid";
542
- /** Resolve a server link against `baseUrl` per RFC 3986. An absolute link is kept verbatim. */
542
+ /**
543
+ * Resolve a server link against `baseUrl` per RFC 3986 and check where it lands. A link on the
544
+ * base's origin is followed; a relative base (e.g. `/api`) takes the page's origin in a browser and
545
+ * otherwise only accepts a link that keeps the host. Anything that isn't plain http(s) is refused.
546
+ */
543
547
  function resolveLink(link, baseUrl) {
544
- if (isAbsolute(link)) return link;
545
- if (isAbsolute(baseUrl)) return new URL(link, baseUrl).href;
546
- const resolved = new URL(link, new URL(baseUrl, RELATIVE_ORIGIN));
547
- return resolved.origin === RELATIVE_ORIGIN ? resolved.pathname + resolved.search + resolved.hash : resolved.href;
548
+ let normalised = link.replace(/[\t\n\r]/g, "");
549
+ let start = 0;
550
+ while (start < normalised.length && normalised.charCodeAt(start) <= 32) start++;
551
+ normalised = normalised.slice(start);
552
+ const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(normalised)?.[1]?.toLowerCase();
553
+ if (scheme !== void 0 && scheme !== "http" && scheme !== "https") throw new Error(`Refusing to follow link "${link}": only http(s) links are followed`);
554
+ const relativeBase = !isAbsolute(baseUrl);
555
+ const page = globalThis.location?.href;
556
+ const base = new URL(baseUrl, relativeBase ? typeof page === "string" ? page : RELATIVE_ORIGIN : void 0);
557
+ const resolved = new URL(link, base);
558
+ const sameOrigin = resolved.origin === base.origin && (scheme === void 0 || /^https?:\/\//i.test(normalised));
559
+ if (!sameOrigin) return {
560
+ url: resolved.href,
561
+ sameOrigin
562
+ };
563
+ return {
564
+ url: relativeBase ? resolved.pathname + resolved.search + resolved.hash : resolved.href,
565
+ sameOrigin
566
+ };
548
567
  }
549
568
  const append = (parts, key, value) => {
550
569
  if (value === void 0 || value === null || value === "") return;
551
570
  parts.push(`${key}=${encodeURIComponent(String(value))}`);
552
571
  };
553
- const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
572
+ const isPlainObject$1 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
554
573
  /**
555
574
  * Append a filter value under its (already-bracketed) key. A plain object is a STRUCTURED filter
556
575
  * value — a `Range`/`DateRange` deepObject (`{ min, max }`) — and recurses into nested bracketed
@@ -559,7 +578,7 @@ const isPlainObject = (value) => typeof value === "object" && value !== null &&
559
578
  * wire as `filter[k]=[object Object]` and be silently ignored by the server (D23).
560
579
  */
561
580
  const appendFilterValue = (parts, key, value) => {
562
- if (isPlainObject(value)) {
581
+ if (isPlainObject$1(value)) {
563
582
  for (const [subKey, subValue] of Object.entries(value)) appendFilterValue(parts, `${key}[${subKey}]`, subValue);
564
583
  return;
565
584
  }
@@ -582,14 +601,37 @@ function serializeQuery(query) {
582
601
  return parts.join("&");
583
602
  }
584
603
  /**
585
- * Execute a request: build the URL (a server link resolved against `baseUrl`, an absolute path
586
- * verbatim, else `baseUrl + path`), apply content negotiation and any async per-request headers,
604
+ * Execute a request: build the URL (a server link resolved against `baseUrl` and held to its
605
+ * origin, an absolute path verbatim, else `baseUrl + path`), apply content negotiation and any async per-request headers,
587
606
  * then drive the transport. A 2xx with a body parses to a {@link Document}; an empty /
588
607
  * 204 body resolves to `undefined`. A non-2xx throws a {@link JsonApiError} carrying the
589
608
  * parsed error objects (tolerating a non-JSON error body with an empty list).
590
609
  */
591
610
  async function execute(ctx, req) {
592
- let url = req.link ? resolveLink(req.path, ctx.baseUrl) : isAbsolute(req.path) ? req.path : ctx.baseUrl + req.path;
611
+ return parseDocument((await send(ctx, req)).body);
612
+ }
613
+ async function executeRaw(ctx, req, as) {
614
+ const res = await send(ctx, req, as === "text" ? void 0 : as);
615
+ if (as === "text") return res.body === "" ? void 0 : res.body;
616
+ if (res.status === 204) return;
617
+ if (res.binary === void 0) throw new Error("The transport returned no binary body for a request that asked for one; a custom transport must read the response as binary when the request sets responseType.");
618
+ return res.binary;
619
+ }
620
+ /**
621
+ * Build the URL (a server link resolved against `baseUrl` and held to its origin, an absolute
622
+ * path verbatim, else `baseUrl + path`), apply content negotiation and any async per-request
623
+ * headers, then drive the transport. A non-2xx throws a {@link JsonApiError} carrying the parsed
624
+ * error objects (tolerating a non-JSON error body with an empty list).
625
+ */
626
+ async function send(ctx, req, responseType) {
627
+ let url;
628
+ let sendHeaders = true;
629
+ if (req.link) {
630
+ const link = resolveLink(req.path, ctx.baseUrl);
631
+ if (!link.sameOrigin && ctx.allowCrossOriginLinks !== true) throw new Error(`Refusing to follow link "${req.path}": it leaves the base URL's origin (set allowCrossOriginLinks to follow it without the configured headers)`);
632
+ url = link.url;
633
+ sendHeaders = link.sameOrigin;
634
+ } else url = isAbsolute(req.path) ? req.path : ctx.baseUrl + req.path;
593
635
  if (req.query) {
594
636
  const qs = serializeQuery(req.query);
595
637
  if (qs) url += (url.includes("?") ? "&" : "?") + qs;
@@ -605,15 +647,23 @@ async function execute(ctx, req) {
605
647
  headers
606
648
  };
607
649
  if (req.signal !== void 0) transportReq.signal = req.signal;
608
- if (req.body !== void 0) {
609
- headers["Content-Type"] = req.contentType ?? negotiated;
610
- transportReq.body = req.raw && typeof req.body === "string" ? req.body : JSON.stringify(req.body);
650
+ if (responseType !== void 0) transportReq.responseType = responseType;
651
+ if (req.body !== void 0 || req.method.toUpperCase() === "POST") {
652
+ const contentType = req.contentType === void 0 ? negotiated : req.contentType;
653
+ if (contentType !== null) headers["Content-Type"] = contentType;
611
654
  }
612
- if (ctx.headers) mergeHeaders(headers, await ctx.headers());
655
+ if (typeof req.body === "string" && req.raw) transportReq.body = req.body;
656
+ else if (req.raw && isRawBody(req.body)) transportReq.rawBody = req.body;
657
+ else if (req.body !== void 0) transportReq.body = JSON.stringify(req.body);
658
+ if (ctx.headers && sendHeaders) mergeHeaders(headers, await ctx.headers());
613
659
  const res = await ctx.transport(transportReq);
614
- if (res.status >= 200 && res.status < 300) return parseDocument(res.body);
660
+ if (res.status >= 200 && res.status < 300) return res;
615
661
  throw new JsonApiError(res.status, parseErrors(res.body));
616
662
  }
663
+ /** Whether a raw body goes to the transport as is rather than through `JSON.stringify`. */
664
+ function isRawBody(body) {
665
+ return typeof Blob !== "undefined" && body instanceof Blob || body instanceof ArrayBuffer || ArrayBuffer.isView(body) || typeof FormData !== "undefined" && body instanceof FormData || typeof URLSearchParams !== "undefined" && body instanceof URLSearchParams;
666
+ }
617
667
  /**
618
668
  * Merge a `HeadersInit` (object / tuple array / `Headers`) into the outgoing map,
619
669
  * preserving the caller's casing (unlike the WHATWG `Headers`, which lowercases). The
@@ -1001,13 +1051,23 @@ const fetchTransport = async (req) => {
1001
1051
  method: req.method,
1002
1052
  headers: req.headers
1003
1053
  };
1004
- if (req.body !== void 0) init.body = req.body;
1054
+ if (req.rawBody !== void 0) init.body = req.rawBody;
1055
+ else if (req.body !== void 0) init.body = req.body;
1005
1056
  if (req.signal !== void 0) init.signal = req.signal;
1006
1057
  const res = await fetch(req.url, init);
1007
1058
  const headers = {};
1008
1059
  res.headers.forEach((value, key) => {
1009
1060
  headers[key] = value;
1010
1061
  });
1062
+ if (req.responseType !== void 0 && res.ok) {
1063
+ const binary = req.responseType === "blob" ? await res.blob() : await res.arrayBuffer();
1064
+ return {
1065
+ status: res.status,
1066
+ headers,
1067
+ body: "",
1068
+ binary
1069
+ };
1070
+ }
1011
1071
  return {
1012
1072
  status: res.status,
1013
1073
  headers,
@@ -1118,36 +1178,145 @@ async function runWrite(ctx, spec) {
1118
1178
  * - `raw` — the caller's payload, sent with the action's declared media type (e.g.
1119
1179
  * `application/octet-stream`), falling back to a wildcard when the spec declared none.
1120
1180
  *
1181
+ * An action listing several request media types (`inputMedia`) sends the one the caller picks
1182
+ * with `options.contentType`, or JSON:API when listed, else the first.
1183
+ *
1121
1184
  * The response is shaped by the action's `output`: `document` materialises into the resource /
1122
1185
  * collection view, `meta` returns the document's top-level `meta`, `none` resolves `undefined` (a
1123
- * `204` / no body).
1186
+ * `204` / no body). An action declaring `rawOutput` answers in the media type the caller picks
1187
+ * with `options.accept`: JSON:API as above, or a `string`, a `Blob` or `ArrayBuffer`, or parsed
1188
+ * JSON by the media type's kind.
1124
1189
  */
1125
- async function runAction(ctx, action, vars, input) {
1190
+ async function runAction(ctx, action, vars, input, options = {}) {
1126
1191
  const req = {
1127
1192
  method: action.method ?? "POST",
1128
1193
  path: fill(action.path, vars)
1129
1194
  };
1195
+ if (options.signal !== void 0) req.signal = options.signal;
1130
1196
  let remap;
1131
- if (action.input === "document") if (action.inputType !== void 0) {
1197
+ const media = requestMedia(action, options.contentType);
1198
+ if (media !== void 0 && media.kind !== "document") {
1199
+ req.body = encodeFields(media.mediaType, media.kind, input);
1200
+ req.raw = true;
1201
+ req.contentType = sentContentType(media.mediaType, media.kind, req.body);
1202
+ } else if (action.input === "document") if (action.inputType !== void 0) {
1132
1203
  const inputType = action.inputType;
1133
1204
  req.body = toDocument(ctx.descriptor, inputType, input ?? {});
1134
1205
  remap = (error) => withRemappedPaths(error, ctx.descriptor, inputType);
1135
1206
  } else req.body = input;
1136
1207
  else if (action.input === "raw") {
1137
- req.body = input;
1138
- req.contentType = action.contentType ?? "*/*";
1208
+ req.body = encodeFields(action.contentType ?? "*/*", void 0, input);
1209
+ req.contentType = sentContentType(action.contentType ?? "*/*", void 0, req.body);
1139
1210
  req.raw = true;
1140
1211
  }
1141
- let doc;
1212
+ const raw = responseMedia(action, options.accept);
1142
1213
  try {
1143
- doc = await execute(ctx.request, req);
1214
+ if (raw !== void 0) {
1215
+ req.accept = raw.mediaType;
1216
+ if (raw.kind === "binary") return await executeRaw(ctx.request, req, options.responseType === "arrayBuffer" ? "arraybuffer" : "blob");
1217
+ const text = await executeRaw(ctx.request, req, "text");
1218
+ return raw.kind === "json" && text !== void 0 ? JSON.parse(text) : text;
1219
+ }
1220
+ const doc = await execute(ctx.request, req);
1221
+ if (action.output === "none" || doc === void 0) return;
1222
+ if (action.output === "meta") return doc.meta;
1223
+ return materialise(doc, ctx.materialise);
1144
1224
  } catch (error) {
1145
1225
  if (remap !== void 0 && error instanceof JsonApiError) throw remap(error);
1146
1226
  throw error;
1147
1227
  }
1148
- if (action.output === "none" || doc === void 0) return;
1149
- if (action.output === "meta") return doc.meta;
1150
- return materialise(doc, ctx.materialise);
1228
+ }
1229
+ /**
1230
+ * The request media type an action with several (`inputMedia`) sends: the caller's pick, matched
1231
+ * exactly or through a declared wildcard such as `image/*`, else JSON:API when listed, else the
1232
+ * first. `undefined` for an action with a single request media type.
1233
+ */
1234
+ function requestMedia(action, picked) {
1235
+ const listed = action.inputMedia;
1236
+ if (listed === void 0 || listed.length === 0) {
1237
+ if (picked !== void 0) throw new Error(`This action declares no request media types to choose "${picked}" from.`);
1238
+ return;
1239
+ }
1240
+ if (picked === void 0) {
1241
+ const media = listed.find((entry) => entry.mediaType === "application/vnd.api+json") ?? listed[0];
1242
+ return {
1243
+ mediaType: media.mediaType,
1244
+ kind: media.kind
1245
+ };
1246
+ }
1247
+ const essence = picked.split(";")[0].trim().toLowerCase();
1248
+ const match = listed.find((entry) => entry.mediaType === essence) ?? listed.find((entry) => entry.mediaType === `${essence.split("/")[0]}/*`) ?? listed.find((entry) => entry.mediaType === "*/*");
1249
+ if (match === void 0) throw new Error(`This action does not accept "${picked}"; it accepts ${listed.map((entry) => entry.mediaType).join(", ")}.`);
1250
+ return {
1251
+ mediaType: picked,
1252
+ kind: match.kind
1253
+ };
1254
+ }
1255
+ /**
1256
+ * A multipart or URL-encoded body given as a plain object of fields, encoded into a `FormData` or
1257
+ * `URLSearchParams`. Any other body, a ready `FormData` included, is sent as it is.
1258
+ */
1259
+ function encodeFields(mediaType, kind, body) {
1260
+ const multipart = kind === "multipart" || mediaType.startsWith("multipart/");
1261
+ if (!multipart && !(kind === "form" || mediaType === "application/x-www-form-urlencoded") || !isPlainObject(body)) return body;
1262
+ return multipart ? toFormData(body) : toSearchParams(body);
1263
+ }
1264
+ /** Whether a body is a plain object of fields rather than a ready payload. */
1265
+ function isPlainObject(value) {
1266
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
1267
+ const proto = Object.getPrototypeOf(value);
1268
+ return proto === Object.prototype || proto === null;
1269
+ }
1270
+ /**
1271
+ * The entries a field contributes: nothing for `null` or `undefined`, one `name[]` entry per item
1272
+ * for an array (the bracket suffix is how PHP servers read a repeated field as a list), else one
1273
+ * `name` entry.
1274
+ */
1275
+ function fieldEntries(name, value) {
1276
+ if (value === null || value === void 0) return [];
1277
+ if (Array.isArray(value)) return value.filter((item) => item !== null && item !== void 0).map((item) => [`${name}[]`, item]);
1278
+ return [[name, value]];
1279
+ }
1280
+ /** A field's text value: booleans as `true` or `false`, everything else through `String`. */
1281
+ function fieldText(value) {
1282
+ return typeof value === "boolean" ? value ? "true" : "false" : String(value);
1283
+ }
1284
+ /** A multipart body from fields. A `File` keeps its name; a bare `Blob` is named after its field. */
1285
+ function toFormData(fields) {
1286
+ const data = new FormData();
1287
+ for (const [field, value] of Object.entries(fields)) for (const [name, item] of fieldEntries(field, value)) if (typeof Blob !== "undefined" && item instanceof Blob) {
1288
+ const fileName = typeof File !== "undefined" && item instanceof File && item.name !== "" ? item.name : field;
1289
+ data.append(name, item, fileName);
1290
+ } else data.append(name, fieldText(item));
1291
+ return data;
1292
+ }
1293
+ /** A URL-encoded body from fields. */
1294
+ function toSearchParams(fields) {
1295
+ const params = new URLSearchParams();
1296
+ for (const [field, value] of Object.entries(fields)) for (const [name, item] of fieldEntries(field, value)) params.append(name, fieldText(item));
1297
+ return params;
1298
+ }
1299
+ /**
1300
+ * The `Content-Type` for a non-JSON:API body. A multipart `FormData` body gets none, so the
1301
+ * transport writes the boundary. A wildcard media type takes a `Blob` body's own type.
1302
+ */
1303
+ function sentContentType(mediaType, kind, body) {
1304
+ if ((kind === "multipart" || mediaType.startsWith("multipart/")) && typeof FormData !== "undefined" && body instanceof FormData) return null;
1305
+ if (mediaType.includes("*") && typeof Blob !== "undefined" && body instanceof Blob) return body.type === "" ? mediaType : body.type;
1306
+ return mediaType;
1307
+ }
1308
+ /**
1309
+ * The non-JSON:API media type an action is asked to answer in: the caller's `accept` when it isn't
1310
+ * JSON:API, else, with no `accept`, the first raw media type of an action that answers in no
1311
+ * JSON:API document. `undefined` means a JSON:API response.
1312
+ */
1313
+ function responseMedia(action, accept) {
1314
+ const raw = action.rawOutput ?? [];
1315
+ if (accept === void 0) return action.output === "none" ? raw[0] : void 0;
1316
+ if (accept === "application/vnd.api+json") return;
1317
+ const media = raw.find((entry) => entry.mediaType === accept);
1318
+ if (media === void 0) throw new Error(`This action does not answer in "${accept}"; it answers in ${[...action.output === "none" ? [] : [JSON_API_MEDIA_TYPE], ...raw.map((entry) => entry.mediaType)].join(", ")}.`);
1319
+ return media;
1151
1320
  }
1152
1321
  /**
1153
1322
  * Build the typed `.actions` accessor at one scope: a plain object exposing exactly the
@@ -1159,7 +1328,7 @@ async function runAction(ctx, action, vars, input) {
1159
1328
  function actionsAccessor(ctx, type, scope, vars) {
1160
1329
  const actions = ctx.descriptor[type]?.actions ?? {};
1161
1330
  const out = {};
1162
- for (const [name, action] of Object.entries(actions)) if (action.scope === scope) out[name] = (input) => runAction(ctx, action, vars, input);
1331
+ for (const [name, action] of Object.entries(actions)) if (action.scope === scope) out[name] = (...args) => action.input === "none" ? runAction(ctx, action, vars, void 0, args[0]) : runAction(ctx, action, vars, args[0], args[1]);
1163
1332
  return out;
1164
1333
  }
1165
1334
  /**
@@ -1279,7 +1448,8 @@ function createClient(descriptor, options) {
1279
1448
  const request = {
1280
1449
  baseUrl: options.baseUrl,
1281
1450
  transport: options.transport ?? fetchTransport,
1282
- ...options.headers !== void 0 ? { headers: options.headers } : {}
1451
+ ...options.headers !== void 0 ? { headers: options.headers } : {},
1452
+ ...options.allowCrossOriginLinks !== void 0 ? { allowCrossOriginLinks: options.allowCrossOriginLinks } : {}
1283
1453
  };
1284
1454
  const validator = resolveValidator(options.validate);
1285
1455
  const ctx = {
@@ -1322,6 +1492,6 @@ function createClient(descriptor, options) {
1322
1492
  });
1323
1493
  }
1324
1494
  //#endregion
1325
- export { ATOMIC_EXT, ATOMIC_MEDIA_TYPE, CANONICAL_PAGE_KEYS, HANDLE_RESERVED, JSON_API_MEDIA_TYPE, JsonApiError, StructuralGuardError, assertJsonApiDocument, createClient, execute, fetchTransport, hasErrorCode, materialise, mediaType, relatedPaginator, relatedPaginatorKind, remapPointer, remapRelationshipPointer, resolvePaginator, resolveValidator, runAtomic, serializeQuery, toDocument, toRelationshipDocument, validateDocument, withRemappedAtomicPaths, withRemappedPaths, withRemappedRelationshipPaths };
1495
+ export { ATOMIC_EXT, ATOMIC_MEDIA_TYPE, CANONICAL_PAGE_KEYS, HANDLE_RESERVED, JSON_API_MEDIA_TYPE, JsonApiError, StructuralGuardError, assertJsonApiDocument, createClient, execute, executeRaw, fetchTransport, hasErrorCode, materialise, mediaType, relatedPaginator, relatedPaginatorKind, remapPointer, remapRelationshipPointer, resolvePaginator, resolveValidator, runAtomic, serializeQuery, toDocument, toRelationshipDocument, validateDocument, withRemappedAtomicPaths, withRemappedPaths, withRemappedRelationshipPaths };
1326
1496
 
1327
1497
  //# sourceMappingURL=index.mjs.map