@jskit-ai/http-runtime 0.1.55 → 0.1.57

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.
@@ -1,7 +1,7 @@
1
1
  export default Object.freeze({
2
2
  "packageVersion": 1,
3
3
  "packageId": "@jskit-ai/http-runtime",
4
- "version": "0.1.55",
4
+ "version": "0.1.57",
5
5
  "kind": "runtime",
6
6
  "dependsOn": [],
7
7
  "capabilities": {
@@ -67,7 +67,7 @@ export default Object.freeze({
67
67
  "mutations": {
68
68
  "dependencies": {
69
69
  "runtime": {
70
- "@jskit-ai/kernel": "0.1.56"
70
+ "@jskit-ai/kernel": "0.1.58"
71
71
  },
72
72
  "dev": {}
73
73
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/http-runtime",
3
- "version": "0.1.55",
3
+ "version": "0.1.57",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test"
@@ -18,7 +18,7 @@
18
18
  "./shared/validators/operationValidation": "./src/shared/validators/operationValidation.js"
19
19
  },
20
20
  "dependencies": {
21
- "@jskit-ai/kernel": "0.1.56",
21
+ "@jskit-ai/kernel": "0.1.58",
22
22
  "json-rest-schema": "1.x.x"
23
23
  }
24
24
  }
@@ -36,7 +36,21 @@ function normalizeJsonApiClientTransport(transport = null) {
36
36
  responseKind: normalizeText(transport?.responseKind, {
37
37
  fallback: "record"
38
38
  }).toLowerCase(),
39
- includeBodyId: transport?.includeBodyId === true
39
+ includeBodyId: transport?.includeBodyId === true,
40
+ lookupContainerKey: normalizeText(transport?.lookupContainerKey, {
41
+ fallback: "lookups"
42
+ }),
43
+ lookupFieldMap: Object.freeze(
44
+ Object.fromEntries(
45
+ Object.entries(normalizeObject(transport?.lookupFieldMap))
46
+ .map(([relationshipName, fieldKey]) => [
47
+ normalizeText(relationshipName),
48
+ normalizeText(fieldKey)
49
+ ])
50
+ .filter(([relationshipName, fieldKey]) => relationshipName && fieldKey)
51
+ .sort(([left], [right]) => left.localeCompare(right))
52
+ )
53
+ )
40
54
  });
41
55
  }
42
56
 
@@ -84,12 +98,119 @@ function encodeJsonApiResourceQuery(query, transport = null) {
84
98
  });
85
99
  }
86
100
 
87
- function simplifyResourceObject(resource = {}) {
101
+ function resolveRelationshipFieldKey(relationshipName = "", lookupFieldMap = null) {
102
+ const normalizedRelationshipName = normalizeText(relationshipName);
103
+ const explicitFieldKey = normalizeText(lookupFieldMap?.[normalizedRelationshipName]);
104
+ if (explicitFieldKey) {
105
+ return explicitFieldKey;
106
+ }
107
+
108
+ return normalizedRelationshipName ? `${normalizedRelationshipName}Id` : "";
109
+ }
110
+
111
+ function createIncludedResourceIndex(document = {}) {
112
+ const index = new Map();
113
+
114
+ for (const resource of normalizeArray(document?.included)) {
115
+ const type = normalizeText(resource?.type);
116
+ const id = normalizeText(resource?.id);
117
+ if (!type || !id) {
118
+ continue;
119
+ }
120
+
121
+ index.set(`${type}:${id}`, resource);
122
+ }
123
+
124
+ return index;
125
+ }
126
+
127
+ function simplifyRelationshipResource(linkage = {}, options = {}) {
128
+ const normalizedLinkage = isRecord(linkage) ? normalizeObject(linkage) : {};
129
+ const type = normalizeText(normalizedLinkage.type);
130
+ const id = normalizeText(normalizedLinkage.id);
131
+ if (!type || !id) {
132
+ return null;
133
+ }
134
+
135
+ const resourceKey = `${type}:${id}`;
136
+ const includedResource = options.includedResourceIndex?.get(resourceKey);
137
+ if (!includedResource) {
138
+ return {
139
+ id
140
+ };
141
+ }
142
+
143
+ return simplifyResourceObject(includedResource, options);
144
+ }
145
+
146
+ function simplifyResourceObject(resource = {}, options = {}) {
88
147
  const normalizedResource = isRecord(resource) ? normalizeObject(resource) : {};
89
- return {
148
+ const simplified = {
90
149
  id: normalizedResource.id == null ? "" : String(normalizedResource.id),
91
150
  ...(normalizeObject(normalizedResource.attributes))
92
151
  };
152
+ const lookupContainerKey = normalizeText(options.lookupContainerKey, {
153
+ fallback: "lookups"
154
+ });
155
+ const lookupFieldMap = options.lookupFieldMap || null;
156
+ const resourceKey = `${normalizeText(normalizedResource.type)}:${normalizeText(normalizedResource.id)}`;
157
+ if (resourceKey && options.inFlightKeys?.has(resourceKey)) {
158
+ return simplified;
159
+ }
160
+ if (resourceKey && options.resourceCache?.has(resourceKey)) {
161
+ return options.resourceCache.get(resourceKey);
162
+ }
163
+
164
+ if (resourceKey) {
165
+ options.inFlightKeys?.add(resourceKey);
166
+ }
167
+
168
+ const lookups = {};
169
+ for (const [relationshipName, relationshipValue] of Object.entries(normalizeObject(normalizedResource.relationships))) {
170
+ const relationshipData = relationshipValue?.data;
171
+ if (Array.isArray(relationshipData)) {
172
+ const items = relationshipData
173
+ .map((entry) =>
174
+ simplifyRelationshipResource(entry, {
175
+ ...options,
176
+ lookupFieldMap: null
177
+ })
178
+ )
179
+ .filter(Boolean);
180
+ if (items.length > 0) {
181
+ lookups[relationshipName] = items;
182
+ }
183
+ continue;
184
+ }
185
+
186
+ const fieldKey = resolveRelationshipFieldKey(relationshipName, lookupFieldMap);
187
+ if (!Object.hasOwn(simplified, fieldKey)) {
188
+ simplified[fieldKey] = relationshipData?.id == null ? null : String(relationshipData.id);
189
+ }
190
+
191
+ const lookupRecord = relationshipData == null
192
+ ? null
193
+ : simplifyRelationshipResource(relationshipData, {
194
+ ...options,
195
+ lookupFieldMap: null
196
+ });
197
+ if (lookupRecord) {
198
+ lookups[fieldKey] = lookupRecord;
199
+ if (relationshipName !== fieldKey) {
200
+ lookups[relationshipName] = lookupRecord;
201
+ }
202
+ }
203
+ }
204
+
205
+ if (Object.keys(lookups).length > 0) {
206
+ simplified[lookupContainerKey] = lookups;
207
+ }
208
+ if (resourceKey) {
209
+ options.inFlightKeys?.delete(resourceKey);
210
+ options.resourceCache?.set(resourceKey, simplified);
211
+ }
212
+
213
+ return simplified;
93
214
  }
94
215
 
95
216
  function assertPrimaryDataType(resource = {}, expectedType = "") {
@@ -122,6 +243,13 @@ function decodeJsonApiResourceResponse(payload, transport = null) {
122
243
  }
123
244
 
124
245
  const document = normalizeJsonApiDocument(payload);
246
+ const simplifyOptions = {
247
+ lookupContainerKey: normalizedTransport.lookupContainerKey,
248
+ lookupFieldMap: normalizedTransport.lookupFieldMap,
249
+ includedResourceIndex: createIncludedResourceIndex(document),
250
+ resourceCache: new Map(),
251
+ inFlightKeys: new Set()
252
+ };
125
253
  if (document.kind === "unknown") {
126
254
  throw new Error("Expected JSON:API response document.");
127
255
  }
@@ -136,7 +264,7 @@ function decodeJsonApiResourceResponse(payload, transport = null) {
136
264
  }
137
265
 
138
266
  return {
139
- items: document.data.map((entry) => simplifyResourceObject(entry)),
267
+ items: document.data.map((entry) => simplifyResourceObject(entry, simplifyOptions)),
140
268
  ...resolveCollectionPageMeta(document)
141
269
  };
142
270
  }
@@ -158,7 +286,7 @@ function decodeJsonApiResourceResponse(payload, transport = null) {
158
286
  assertPrimaryDataType(document.data, normalizedTransport.responseType);
159
287
  }
160
288
 
161
- return document.data == null ? null : simplifyResourceObject(document.data);
289
+ return document.data == null ? null : simplifyResourceObject(document.data, simplifyOptions);
162
290
  }
163
291
 
164
292
  if (document.kind !== "resource") {
@@ -169,7 +297,7 @@ function decodeJsonApiResourceResponse(payload, transport = null) {
169
297
  assertPrimaryDataType(document.data, normalizedTransport.responseType);
170
298
  }
171
299
 
172
- return document.data == null ? null : simplifyResourceObject(document.data);
300
+ return document.data == null ? null : simplifyResourceObject(document.data, simplifyOptions);
173
301
  }
174
302
 
175
303
  function decodeJsonApiErrorFieldErrors(payload = {}) {
@@ -54,6 +54,54 @@ const JSON_API_META_SCHEMA = Object.freeze({
54
54
  additionalProperties: true
55
55
  });
56
56
 
57
+ const JSON_API_RESOURCE_IDENTIFIER_SCHEMA = Object.freeze({
58
+ type: "object",
59
+ additionalProperties: false,
60
+ required: ["type", "id"],
61
+ properties: {
62
+ type: { type: "string", minLength: 1 },
63
+ id: JSON_API_ID_SCHEMA
64
+ }
65
+ });
66
+
67
+ const JSON_API_RELATIONSHIP_OBJECT_SCHEMA = Object.freeze({
68
+ type: "object",
69
+ additionalProperties: false,
70
+ required: ["data"],
71
+ properties: {
72
+ data: {
73
+ anyOf: [
74
+ JSON_API_RESOURCE_IDENTIFIER_SCHEMA,
75
+ {
76
+ type: "array",
77
+ items: JSON_API_RESOURCE_IDENTIFIER_SCHEMA
78
+ },
79
+ { type: "null" }
80
+ ]
81
+ }
82
+ }
83
+ });
84
+
85
+ const JSON_API_INCLUDED_RESOURCE_SCHEMA = Object.freeze({
86
+ type: "object",
87
+ additionalProperties: false,
88
+ required: ["type", "id"],
89
+ properties: {
90
+ type: { type: "string", minLength: 1 },
91
+ id: JSON_API_ID_SCHEMA,
92
+ attributes: {
93
+ type: "object",
94
+ additionalProperties: true
95
+ },
96
+ relationships: {
97
+ type: "object",
98
+ additionalProperties: JSON_API_RELATIONSHIP_OBJECT_SCHEMA
99
+ },
100
+ links: JSON_API_LINKS_SCHEMA,
101
+ meta: JSON_API_META_SCHEMA
102
+ }
103
+ });
104
+
57
105
  const JSON_API_ERROR_OBJECT_SCHEMA = Object.freeze({
58
106
  type: "object",
59
107
  additionalProperties: false,
@@ -120,7 +168,8 @@ function resolveRouteTypes(value = {}) {
120
168
  function resolveEmbeddedAttributesTransportSchema(definition, {
121
169
  context = "JSON:API resource",
122
170
  defaultMode = "replace",
123
- removeId = false
171
+ removeId = false,
172
+ removeKeys = []
124
173
  } = {}) {
125
174
  const transportSchema = resolveSchemaTransportSchemaDefinition(definition, {
126
175
  context,
@@ -136,13 +185,35 @@ function resolveEmbeddedAttributesTransportSchema(definition, {
136
185
  const nextProperties = {
137
186
  ...properties
138
187
  };
188
+ const excludedKeys = new Set(
189
+ normalizeArray(removeKeys)
190
+ .map((entry) => String(entry || "").trim())
191
+ .filter(Boolean)
192
+ );
139
193
 
140
194
  if (removeId) {
141
- delete nextProperties.id;
195
+ excludedKeys.add("id");
196
+ }
197
+
198
+ for (const key of excludedKeys) {
199
+ delete nextProperties[key];
142
200
  }
143
201
 
202
+ const schemaFieldDefinitions = (() => {
203
+ const fieldDefinitions = definition?.schema?.getFieldDefinitions?.();
204
+ return isRecord(fieldDefinitions) ? fieldDefinitions : {};
205
+ })();
206
+
144
207
  const required = Array.isArray(sourceSchema.required)
145
- ? sourceSchema.required.filter((entry) => String(entry || "").trim() && (!removeId || entry !== "id"))
208
+ ? sourceSchema.required.filter((entry) => {
209
+ const normalizedEntry = String(entry || "").trim();
210
+ if (!normalizedEntry || excludedKeys.has(normalizedEntry)) {
211
+ return false;
212
+ }
213
+
214
+ const fieldDefinition = normalizeObject(schemaFieldDefinitions[normalizedEntry]);
215
+ return fieldDefinition.nullable !== true;
216
+ })
146
217
  : [];
147
218
 
148
219
  const attributeSchema = {
@@ -160,18 +231,124 @@ function resolveEmbeddedAttributesTransportSchema(definition, {
160
231
  return createEmbeddableTransportSchemaDocument(attributeSchema, `${normalizeText(context, { fallback: "JsonApiAttributes" }).replace(/[^a-z0-9]+/gi, "_")}`);
161
232
  }
162
233
 
234
+ function normalizeRelationshipSchemaEntries(entries = []) {
235
+ const normalizedEntries = [];
236
+
237
+ for (const entry of normalizeArray(entries)) {
238
+ const source = normalizeObject(entry);
239
+ const relationshipName = normalizeText(source.relationshipName || source.name);
240
+ const relationshipType = normalizeText(source.relationshipType || source.type);
241
+ const attributeKey = normalizeText(source.attributeKey || source.fieldKey);
242
+ if (!relationshipName) {
243
+ continue;
244
+ }
245
+
246
+ normalizedEntries.push(Object.freeze({
247
+ relationshipName,
248
+ relationshipType,
249
+ attributeKey,
250
+ required: source.required === true,
251
+ nullable: source.nullable === true
252
+ }));
253
+ }
254
+
255
+ return Object.freeze(normalizedEntries);
256
+ }
257
+
258
+ function createJsonApiRelationshipDataSchema(relationshipType = "", {
259
+ nullable = false
260
+ } = {}) {
261
+ const normalizedRelationshipType = normalizeText(relationshipType);
262
+ const resourceIdentifierSchema = normalizedRelationshipType
263
+ ? {
264
+ type: "object",
265
+ additionalProperties: false,
266
+ required: ["type", "id"],
267
+ properties: {
268
+ type: { const: normalizedRelationshipType },
269
+ id: JSON_API_ID_SCHEMA
270
+ }
271
+ }
272
+ : JSON_API_RESOURCE_IDENTIFIER_SCHEMA;
273
+
274
+ return {
275
+ anyOf: nullable
276
+ ? [
277
+ resourceIdentifierSchema,
278
+ { type: "null" }
279
+ ]
280
+ : [resourceIdentifierSchema]
281
+ };
282
+ }
283
+
284
+ function createJsonApiRelationshipsTransportSchema(entries = [], {
285
+ includeRequired = false
286
+ } = {}) {
287
+ const relationshipEntries = normalizeRelationshipSchemaEntries(entries);
288
+ if (relationshipEntries.length < 1) {
289
+ return null;
290
+ }
291
+
292
+ const properties = {};
293
+ const required = [];
294
+
295
+ for (const entry of relationshipEntries) {
296
+ properties[entry.relationshipName] = {
297
+ type: "object",
298
+ additionalProperties: false,
299
+ required: ["data"],
300
+ properties: {
301
+ data: createJsonApiRelationshipDataSchema(entry.relationshipType, {
302
+ nullable: entry.nullable
303
+ })
304
+ }
305
+ };
306
+
307
+ if (includeRequired && entry.required) {
308
+ required.push(entry.relationshipName);
309
+ }
310
+ }
311
+
312
+ const schema = {
313
+ type: "object",
314
+ additionalProperties: false,
315
+ properties
316
+ };
317
+ if (required.length > 0) {
318
+ schema.required = required;
319
+ }
320
+
321
+ return Object.freeze({
322
+ schema,
323
+ entries: relationshipEntries
324
+ });
325
+ }
326
+
163
327
  function createJsonApiResourceObjectTransportSchema({
164
328
  type = "",
165
329
  attributes,
166
330
  requireId = true,
167
331
  includeLinks = false,
168
- includeMeta = false
332
+ includeMeta = false,
333
+ excludeAttributeKeys = [],
334
+ relationshipEntries = [],
335
+ relationshipMembersRequired = false
169
336
  } = {}) {
170
337
  const normalizedType = resolveRouteType(type);
338
+ const relationshipsTransport = createJsonApiRelationshipsTransportSchema(relationshipEntries, {
339
+ includeRequired: relationshipMembersRequired
340
+ });
341
+ const relationFieldKeys = relationshipsTransport
342
+ ? relationshipsTransport.entries.map((entry) => entry.attributeKey).filter(Boolean)
343
+ : [];
171
344
  const embeddedAttributes = resolveEmbeddedAttributesTransportSchema(attributes, {
172
345
  context: `${normalizedType} resource attributes`,
173
346
  defaultMode: "replace",
174
- removeId: true
347
+ removeId: true,
348
+ removeKeys: [
349
+ ...normalizeArray(excludeAttributeKeys),
350
+ ...relationFieldKeys,
351
+ ]
175
352
  });
176
353
 
177
354
  const properties = {
@@ -187,6 +364,10 @@ function createJsonApiResourceObjectTransportSchema({
187
364
  required.push("id");
188
365
  }
189
366
 
367
+ if (relationshipsTransport) {
368
+ properties.relationships = relationshipsTransport.schema;
369
+ }
370
+
190
371
  if (includeLinks) {
191
372
  properties.links = JSON_API_LINKS_SCHEMA;
192
373
  }
@@ -201,19 +382,25 @@ function createJsonApiResourceObjectTransportSchema({
201
382
  required,
202
383
  properties
203
384
  },
204
- definitions: embeddedAttributes.definitions
385
+ definitions: embeddedAttributes.definitions,
386
+ hasRelationships: Boolean(relationshipsTransport)
205
387
  };
206
388
  }
207
389
 
208
390
  function createJsonApiResourceRequestBodyTransportSchema({
209
391
  type = "",
210
392
  attributes,
211
- requireId = false
393
+ requireId = false,
394
+ excludeAttributeKeys = [],
395
+ relationshipEntries = []
212
396
  } = {}) {
213
397
  const resourceTransport = createJsonApiResourceObjectTransportSchema({
214
398
  type,
215
399
  attributes,
216
- requireId
400
+ requireId,
401
+ excludeAttributeKeys,
402
+ relationshipEntries,
403
+ relationshipMembersRequired: true
217
404
  });
218
405
  const embeddedResource = createEmbeddableTransportSchemaDocument(
219
406
  {
@@ -260,7 +447,9 @@ function createJsonApiResourceSuccessTransportSchema({
260
447
  kind = "record",
261
448
  includeLinks = false,
262
449
  includeMeta = false,
263
- includeIncluded = false
450
+ includeIncluded = false,
451
+ excludeAttributeKeys = [],
452
+ relationshipEntries = []
264
453
  } = {}) {
265
454
  const normalizedKind = String(kind || "record").trim().toLowerCase();
266
455
  if (!["record", "nullable-record", "collection", "meta"].includes(normalizedKind)) {
@@ -276,7 +465,9 @@ function createJsonApiResourceSuccessTransportSchema({
276
465
  const resourceTransport = createJsonApiResourceObjectTransportSchema({
277
466
  type,
278
467
  attributes,
279
- requireId: true
468
+ requireId: true,
469
+ excludeAttributeKeys,
470
+ relationshipEntries
280
471
  });
281
472
  const embeddedResource = createEmbeddableTransportSchemaDocument(
282
473
  {
@@ -308,10 +499,10 @@ function createJsonApiResourceSuccessTransportSchema({
308
499
  documentProperties.data = embeddedResource.schema;
309
500
  }
310
501
 
311
- if (includeIncluded) {
502
+ if (includeIncluded || resourceTransport.hasRelationships) {
312
503
  documentProperties.included = {
313
504
  type: "array",
314
- items: embeddedResource.schema
505
+ items: JSON_API_INCLUDED_RESOURCE_SCHEMA
315
506
  };
316
507
  }
317
508
  if (includeLinks) {
@@ -692,6 +883,10 @@ function createJsonApiResourceRouteContract({
692
883
  includeValidation400 = false,
693
884
  allowBodyId = false,
694
885
  pointerPrefix = "/data/attributes",
886
+ bodyAttributeExcludeKeys = [],
887
+ outputAttributeExcludeKeys = [],
888
+ bodyRelationshipEntries = [],
889
+ outputRelationshipEntries = [],
695
890
  getRecordType = null,
696
891
  getRecordId = null,
697
892
  getRecordAttributes = null,
@@ -747,7 +942,9 @@ function createJsonApiResourceRouteContract({
747
942
  fastifySchema.body = createJsonApiResourceRequestBodyTransportSchema({
748
943
  type: normalizedRequestType,
749
944
  attributes: body,
750
- requireId: allowBodyId
945
+ requireId: allowBodyId,
946
+ excludeAttributeKeys: bodyAttributeExcludeKeys,
947
+ relationshipEntries: bodyRelationshipEntries
751
948
  });
752
949
  }
753
950
  if (query) {
@@ -777,7 +974,9 @@ function createJsonApiResourceRouteContract({
777
974
  kind: normalizedOutputKind,
778
975
  includeLinks: typeof getDocumentLinks === "function",
779
976
  includeMeta: typeof getDocumentMeta === "function" || normalizedOutputKind === "collection",
780
- includeIncluded: typeof getIncluded === "function"
977
+ includeIncluded: typeof getIncluded === "function",
978
+ excludeAttributeKeys: outputAttributeExcludeKeys,
979
+ relationshipEntries: outputRelationshipEntries
781
980
  })
782
981
  );
783
982
  }
@@ -148,6 +148,173 @@ test("request encodes and decodes json:api resource transport for records", asyn
148
148
  );
149
149
  });
150
150
 
151
+ test("request decodes json:api relationship includes into JSKIT lookups and foreign-key fields", async () => {
152
+ const fetchImpl = async () =>
153
+ mockResponse({
154
+ contentType: "application/vnd.api+json",
155
+ data: {
156
+ data: {
157
+ type: "pets",
158
+ id: "729900",
159
+ attributes: {
160
+ name: "Daisy"
161
+ },
162
+ relationships: {
163
+ contact: {
164
+ data: {
165
+ type: "contacts",
166
+ id: "552252"
167
+ }
168
+ },
169
+ breed: {
170
+ data: {
171
+ type: "breeds",
172
+ id: "2"
173
+ }
174
+ }
175
+ }
176
+ },
177
+ included: [
178
+ {
179
+ type: "contacts",
180
+ id: "552252",
181
+ attributes: {
182
+ firstName: "Serena",
183
+ lastName: "Ellison"
184
+ }
185
+ },
186
+ {
187
+ type: "breeds",
188
+ id: "2",
189
+ attributes: {
190
+ name: "Cavoodle"
191
+ }
192
+ }
193
+ ]
194
+ }
195
+ });
196
+
197
+ const client = createHttpClient({ fetchImpl });
198
+ const payload = await client.request("/api/pets/729900", {
199
+ method: "GET",
200
+ transport: {
201
+ kind: "jsonapi-resource",
202
+ responseType: "pets",
203
+ responseKind: "record",
204
+ lookupFieldMap: {
205
+ contact: "contactId",
206
+ breed: "breedId"
207
+ }
208
+ }
209
+ });
210
+
211
+ assert.deepEqual(payload, {
212
+ id: "729900",
213
+ name: "Daisy",
214
+ contactId: "552252",
215
+ breedId: "2",
216
+ lookups: {
217
+ contactId: {
218
+ id: "552252",
219
+ firstName: "Serena",
220
+ lastName: "Ellison"
221
+ },
222
+ contact: {
223
+ id: "552252",
224
+ firstName: "Serena",
225
+ lastName: "Ellison"
226
+ },
227
+ breedId: {
228
+ id: "2",
229
+ name: "Cavoodle"
230
+ },
231
+ breed: {
232
+ id: "2",
233
+ name: "Cavoodle"
234
+ }
235
+ }
236
+ });
237
+ });
238
+
239
+ test("request recursively decodes nested included relationships for collection-style lookups", async () => {
240
+ const fetchImpl = async () =>
241
+ mockResponse({
242
+ contentType: "application/vnd.api+json",
243
+ data: {
244
+ data: {
245
+ type: "contacts",
246
+ id: "552252",
247
+ attributes: {
248
+ firstName: "Serena",
249
+ lastName: "Ellison"
250
+ },
251
+ relationships: {
252
+ pets: {
253
+ data: [
254
+ {
255
+ type: "pets",
256
+ id: "729900"
257
+ }
258
+ ]
259
+ }
260
+ }
261
+ },
262
+ included: [
263
+ {
264
+ type: "pets",
265
+ id: "729900",
266
+ attributes: {
267
+ name: "Daisy"
268
+ },
269
+ relationships: {
270
+ breed: {
271
+ data: {
272
+ type: "breeds",
273
+ id: "2"
274
+ }
275
+ }
276
+ }
277
+ },
278
+ {
279
+ type: "breeds",
280
+ id: "2",
281
+ attributes: {
282
+ name: "Cavoodle"
283
+ }
284
+ }
285
+ ]
286
+ }
287
+ });
288
+
289
+ const client = createHttpClient({ fetchImpl });
290
+ const payload = await client.request("/api/contacts/552252", {
291
+ method: "GET",
292
+ transport: {
293
+ kind: "jsonapi-resource",
294
+ responseType: "contacts",
295
+ responseKind: "record"
296
+ }
297
+ });
298
+
299
+ assert.deepEqual(payload.lookups?.pets, [
300
+ {
301
+ id: "729900",
302
+ name: "Daisy",
303
+ breedId: "2",
304
+ lookups: {
305
+ breedId: {
306
+ id: "2",
307
+ name: "Cavoodle"
308
+ },
309
+ breed: {
310
+ id: "2",
311
+ name: "Cavoodle"
312
+ }
313
+ }
314
+ }
315
+ ]);
316
+ });
317
+
151
318
  test("request decodes json:api collection responses into JSKIT paged-list shape", async () => {
152
319
  const fetchImpl = async () =>
153
320
  mockResponse({
@@ -302,6 +302,76 @@ test("createJsonApiResourceRouteTransport passes through tagged JSON:API documen
302
302
  assert.deepEqual(transport.response(returnJsonApiDocument(document)), document);
303
303
  });
304
304
 
305
+ test("createJsonApiResourceRouteContract models explicit relationship-backed output fields as relationships", () => {
306
+ const contract = createJsonApiResourceRouteContract({
307
+ responseType: "availabilities",
308
+ output: {
309
+ schema: createSchema({
310
+ id: {
311
+ type: "string",
312
+ required: true,
313
+ minLength: 1
314
+ },
315
+ serviceId: {
316
+ type: "string",
317
+ required: true,
318
+ nullable: true,
319
+ belongsTo: "services",
320
+ as: "service"
321
+ },
322
+ name: {
323
+ type: "string",
324
+ required: true,
325
+ minLength: 1
326
+ },
327
+ deletedAt: {
328
+ type: "string",
329
+ required: true,
330
+ nullable: true
331
+ },
332
+ lookups: {
333
+ type: "object",
334
+ required: false
335
+ }
336
+ }),
337
+ mode: "replace"
338
+ },
339
+ outputKind: "record",
340
+ outputAttributeExcludeKeys: ["lookups"],
341
+ outputRelationshipEntries: [
342
+ {
343
+ attributeKey: "serviceId",
344
+ relationshipName: "service",
345
+ relationshipType: "services",
346
+ nullable: true
347
+ }
348
+ ]
349
+ });
350
+
351
+ const resourceSchema = contract.responses["200"].transportSchema.definitions.availabilitiesSuccessResource;
352
+ const attributesSchemaRef = resourceSchema.properties.attributes.allOf[0].$ref;
353
+ const attributesSchemaName = attributesSchemaRef.replace("#/definitions/", "");
354
+ const attributesSchema = contract.responses["200"].transportSchema.definitions[attributesSchemaName];
355
+
356
+ assert.deepEqual(resourceSchema.required, ["type", "attributes", "id"]);
357
+ assert.ok(Object.hasOwn(resourceSchema.properties, "relationships"));
358
+ assert.equal(attributesSchema.required.includes("serviceId"), false);
359
+ assert.equal(attributesSchema.required.includes("deletedAt"), false);
360
+ assert.equal(attributesSchema.required.includes("lookups"), false);
361
+ assert.equal(Object.hasOwn(attributesSchema.properties, "serviceId"), false);
362
+ assert.equal(Object.hasOwn(attributesSchema.properties, "deletedAt"), true);
363
+ assert.equal(Object.hasOwn(attributesSchema.properties, "lookups"), false);
364
+ assert.equal(
365
+ Array.isArray(resourceSchema.properties.relationships.required),
366
+ false
367
+ );
368
+ assert.equal(
369
+ resourceSchema.properties.relationships.properties.service.properties.data.anyOf[1].type,
370
+ "null"
371
+ );
372
+ assert.ok(Object.hasOwn(contract.responses["200"].transportSchema.properties, "included"));
373
+ });
374
+
305
375
  test("createJsonApiResourceRouteTransport wraps tagged meta results for meta routes", () => {
306
376
  const contract = createJsonApiResourceRouteContract({
307
377
  requestType: "password-changes",