@warp-drive-mirror/json-api 5.9.0-alpha.0 → 5.9.0-alpha.1

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.
@@ -0,0 +1,18 @@
1
+ import type { ResourceDocument } from "@warp-drive-mirror/core/types/spec/document";
2
+ import { type Reporter } from "../utils.js";
3
+ /**
4
+ * Validates that all `data` members of relationships have a matching
5
+ * resource object in the `included` or `data` section of the document.
6
+ *
7
+ * Optionally: validates that all resource objects in `included` are reachable
8
+ * from at least one relationship `data` member in the primary data. This is a
9
+ * spec requirement but has allowed caveats such as for sparse-fields wherein
10
+ * related resources may be present with the linkage property omitted.
11
+ * This setting is on by default but can be disabled by setting `strict.enforceReachable`
12
+ * to false.
13
+ *
14
+ * Version: 1.1
15
+ * Section: 7.4
16
+ * Link: https://jsonapi.org/format/#document-compound-documents
17
+ */
18
+ export declare function validateFullLinkage(reporter: Reporter, doc: ResourceDocument): void;
@@ -0,0 +1,12 @@
1
+ import type { ResourceDocument } from "@warp-drive-mirror/core/types/spec/document";
2
+ import type { Reporter } from "../utils.js";
3
+ /**
4
+ * A common mistake, especially during development when mocking responses, is to
5
+ * return the same resource multiple times, sometimes with differing attributes or
6
+ * relationships.
7
+ *
8
+ * Version: 1.1
9
+ * Section: 7.4
10
+ * Link: https://jsonapi.org/format/#document-compound-documents
11
+ */
12
+ export declare function validateNoDuplicateResources(reporter: Reporter, doc: ResourceDocument): void;
@@ -5,6 +5,7 @@ import type { CacheCapabilitiesManager, SchemaService } from "@warp-drive-mirror
5
5
  import type { StructuredDataDocument, StructuredDocument, StructuredErrorDocument } from "@warp-drive-mirror/core/types/request";
6
6
  import type { FieldSchema } from "@warp-drive-mirror/core/types/schema/fields";
7
7
  import type { ResourceDataDocument, ResourceDocument, ResourceErrorDocument, ResourceMetaDocument } from "@warp-drive-mirror/core/types/spec/document";
8
+ import type { ResourceObject } from "@warp-drive-mirror/core/types/spec/json-api-raw";
8
9
  export declare function inspectType(obj: unknown): string;
9
10
  export declare function isSimpleObject(obj: unknown): obj is Record<string, unknown>;
10
11
  export declare const RELATIONSHIP_FIELD_KINDS: string[];
@@ -27,6 +28,19 @@ interface ErrorReport {
27
28
  type: "error" | "warning" | "info";
28
29
  kind: "key" | "value";
29
30
  }
31
+ export interface ResourcePresence {
32
+ data: Map<string, Map<string, ResourceInfo[]>>;
33
+ included: Map<string, Map<string, ResourceInfo[]>>;
34
+ all: Map<string, Map<string, ResourceInfo[]>>;
35
+ }
36
+ export interface ResourceInfo {
37
+ data: ResourceObject;
38
+ /**
39
+ * null if the only primary data member
40
+ */
41
+ index: number | null;
42
+ location: "data" | "included";
43
+ }
30
44
  export declare class Reporter {
31
45
  capabilities: CacheCapabilitiesManager;
32
46
  contextDocument: StructuredDocument<ResourceDocument>;
@@ -38,7 +52,10 @@ export declare class Reporter {
38
52
  unknownType: boolean;
39
53
  unknownAttribute: boolean;
40
54
  unknownRelationship: boolean;
55
+ enforceReachable: boolean;
41
56
  };
57
+ _presence: ResourcePresence | null;
58
+ get presence(): ResourcePresence;
42
59
  constructor(capabilities: CacheCapabilitiesManager, doc: StructuredDocument<ResourceDocument>);
43
60
  _typeFilter: Fuse<string> | undefined;
44
61
  searchTypes(type: string): FuseResult<string>[];
@@ -72,4 +89,12 @@ export declare function isPushedDocument(doc: unknown): doc is {
72
89
  };
73
90
  export declare function logPotentialMatches(matches: FuseResult<string>[], kind: string): string;
74
91
  export declare function getRemoteField(fields: Map<string, FieldSchema>, key: string): FieldSchema | undefined;
92
+ export declare function checkResourceInMap(map: Map<string, Map<string, ResourceInfo[]>>, resource: {
93
+ type: string;
94
+ id: string;
95
+ }): boolean;
96
+ export declare function checkResourcePresent(presence: ResourcePresence, resource: {
97
+ type: string;
98
+ id: string;
99
+ }): boolean;
75
100
  export {};
package/dist/index.js CHANGED
@@ -172,8 +172,44 @@ class Reporter {
172
172
  linkage: true,
173
173
  unknownType: true,
174
174
  unknownAttribute: true,
175
- unknownRelationship: true
175
+ unknownRelationship: true,
176
+ enforceReachable: true
176
177
  };
178
+ _presence = null;
179
+ get presence() {
180
+ if (this._presence) {
181
+ return this._presence;
182
+ }
183
+ const primaryResources = new Map();
184
+ const includedResources = new Map();
185
+ const allResources = new Map();
186
+ const doc = this.contextDocument.content;
187
+ if (doc) {
188
+ if ('data' in doc && doc.data) {
189
+ if (Array.isArray(doc.data)) {
190
+ for (let i = 0; i < doc.data.length; i++) {
191
+ addResourceToMap(primaryResources, doc.data[i], i, 'data');
192
+ addResourceToMap(allResources, doc.data[i], i, 'data');
193
+ }
194
+ } else {
195
+ addResourceToMap(primaryResources, doc.data, null, 'data');
196
+ addResourceToMap(allResources, doc.data, null, 'data');
197
+ }
198
+ }
199
+ }
200
+ if (doc && 'included' in doc && Array.isArray(doc.included)) {
201
+ for (let i = 0; i < doc.included.length; i++) {
202
+ addResourceToMap(includedResources, doc.included[i], i, 'included');
203
+ addResourceToMap(allResources, doc.included[i], i, 'included');
204
+ }
205
+ }
206
+ this._presence = {
207
+ data: primaryResources,
208
+ included: includedResources,
209
+ all: allResources
210
+ };
211
+ return this._presence;
212
+ }
177
213
  constructor(capabilities, doc) {
178
214
  this.capabilities = capabilities;
179
215
  this.contextDocument = doc;
@@ -364,7 +400,7 @@ class Reporter {
364
400
  }
365
401
  }
366
402
  }
367
- const contextStr = `${counts.error} errors and ${counts.warning} warnings found in the {json:api} document returned by ${this.contextDocument.request?.method} ${this.contextDocument.request?.url}`;
403
+ const contextStr = `${counts.error} errors and ${counts.warning} warnings found in the {json:api} document returned by ${this.contextDocument.request?.method ?? 'GET'} ${this.contextDocument.request?.url}`;
368
404
  const errorString = contextStr + `\n\n` + errorLines.join('\n');
369
405
 
370
406
  // eslint-disable-next-line no-console, @typescript-eslint/no-unused-expressions
@@ -429,6 +465,25 @@ function getRemoteField(fields, key) {
429
465
  }
430
466
  return field;
431
467
  }
468
+ function addResourceToMap(map, resource, index, location) {
469
+ if (!map.has(resource.type)) {
470
+ map.set(resource.type, new Map());
471
+ }
472
+ if (!map.get(resource.type).has(resource.id)) {
473
+ map.get(resource.type).set(resource.id, []);
474
+ }
475
+ map.get(resource.type).get(resource.id).push({
476
+ data: resource,
477
+ index,
478
+ location
479
+ });
480
+ }
481
+ function checkResourceInMap(map, resource) {
482
+ return map.has(resource.type) && map.get(resource.type).has(resource.id);
483
+ }
484
+ function checkResourcePresent(presence, resource) {
485
+ return checkResourceInMap(presence.data, resource) || checkResourceInMap(presence.included, resource);
486
+ }
432
487
 
433
488
  const VALID_TOP_LEVEL_MEMBERS = ['data', 'included', 'meta', 'jsonapi', 'links'];
434
489
 
@@ -862,6 +917,183 @@ function validateResourceRelationships(reporter, type, resource, path) {
862
917
  // type instead of the concrete type.
863
918
  }
864
919
 
920
+ /**
921
+ * Validates that all `data` members of relationships have a matching
922
+ * resource object in the `included` or `data` section of the document.
923
+ *
924
+ * Optionally: validates that all resource objects in `included` are reachable
925
+ * from at least one relationship `data` member in the primary data. This is a
926
+ * spec requirement but has allowed caveats such as for sparse-fields wherein
927
+ * related resources may be present with the linkage property omitted.
928
+ * This setting is on by default but can be disabled by setting `strict.enforceReachable`
929
+ * to false.
930
+ *
931
+ * Version: 1.1
932
+ * Section: 7.4
933
+ * Link: https://jsonapi.org/format/#document-compound-documents
934
+ */
935
+ function validateFullLinkage(reporter, doc) {
936
+ const {
937
+ presence
938
+ } = reporter;
939
+
940
+ // validate that all `data` members of relationships have a matching
941
+ // resource object in the `included` or `data` section of the document.
942
+
943
+ // for each type
944
+ for (const type of presence.all.keys()) {
945
+ const typeMap = presence.all.get(type);
946
+ // for each id of the type
947
+ for (const id of typeMap.keys()) {
948
+ const resources = typeMap.get(id);
949
+ // for each occurrence of the resource
950
+ for (const resourceInfo of resources) {
951
+ const relationships = resourceInfo.data.relationships;
952
+ if (relationships) {
953
+ // for each relationship the occurrence has
954
+ for (const relName of Object.keys(relationships)) {
955
+ const rel = relationships[relName];
956
+ if (rel && 'data' in rel && rel.data !== null) {
957
+ // for each linkage in the relationship
958
+ if (Array.isArray(rel.data)) {
959
+ for (const linkage of rel.data) {
960
+ if (!checkResourcePresent(presence, linkage)) {
961
+ // report missing linkage
962
+ const pathLike = resourceInfo.index === null ? [resourceInfo.location, 'relationships', relName, 'data'] : [resourceInfo.location, resourceInfo.index, 'relationships', relName, 'data'];
963
+ const index = rel.data.indexOf(linkage);
964
+ pathLike.push(index);
965
+ reporter.error(pathLike, `No ResourceObject matching the ResourceIdentifier linkage '${linkage.type}:${linkage.id}' was found in this payload. Exclude the relationship data, include the related ResourceObject, or consider using a link to reference this relationship instead.`, 'value');
966
+ }
967
+ }
968
+ } else {
969
+ if (!checkResourcePresent(presence, rel.data)) {
970
+ const pathLike = resourceInfo.index === null ? [resourceInfo.location, 'relationships', relName, 'data'] : [resourceInfo.location, resourceInfo.index, 'relationships', relName, 'data'];
971
+ reporter.error(pathLike, `No ResourceObject matching the ResourceIdentifier linkage '${rel.data.type}:${rel.data.id}' was found in this payload. Exclude the relationship data, include the related ResourceObject, or consider using a link to reference this relationship instead.`, 'value');
972
+ }
973
+ }
974
+ }
975
+ }
976
+ }
977
+ }
978
+ }
979
+ }
980
+
981
+ // Optionally: validate that all resources in `included` are reachable
982
+ // from at least one relationship `data` member in the primary data.
983
+ const seen = walkResourceGraph(presence);
984
+
985
+ // iterate all included resources
986
+ for (const type of presence.included.keys()) {
987
+ const typeMap = presence.included.get(type);
988
+ for (const id of typeMap.keys()) {
989
+ const seenId = `${type}:${id}`;
990
+ if (!seen.has(seenId)) {
991
+ // report unreachable included resource
992
+ const resources = typeMap.get(id);
993
+ for (const resourceInfo of resources) {
994
+ const {
995
+ index,
996
+ location
997
+ } = resourceInfo;
998
+ const pathLike = index !== null ? [location, index] : [location];
999
+ // TODO if fields were present, attempt to use them to determine if this resource was intentionally excluded from relationships
1000
+ // as part of sparseFields.
1001
+ if (reporter.strict.enforceReachable !== false) {
1002
+ reporter.error(pathLike, `The included ResourceObject '${type}:${id} is unreachable by any path originating from a primary ResourceObject. Exclude this ResourceObject or add the appropriate relationship linkages.`, 'value');
1003
+ } else {
1004
+ reporter.warn(pathLike, `The included ResourceObject '${type}:${id} is unreachable by any path originating from a primary ResourceObject. Exclude this ResourceObject or add the appropriate relationship linkages.`, 'value');
1005
+ }
1006
+ }
1007
+ }
1008
+ }
1009
+ }
1010
+ }
1011
+
1012
+ /**
1013
+ * builds a set of all reachable resources by walking the resource graph
1014
+ * starting from the primary data resources.
1015
+ */
1016
+ function walkResourceGraph(presence) {
1017
+ const seen = new Set();
1018
+ for (const resourceType of presence.data.keys()) {
1019
+ for (const resourceId of presence.data.get(resourceType).keys()) {
1020
+ walkResource(presence, seen, presence.data.get(resourceType).get(resourceId));
1021
+ }
1022
+ }
1023
+ return seen;
1024
+ }
1025
+ function walkResource(presence, seen, resource) {
1026
+ const resourceType = resource[0].data.type;
1027
+ const resourceId = resource[0].data.id;
1028
+ const seenId = `${resourceType}:${resourceId}`;
1029
+ if (seen.has(seenId)) {
1030
+ return;
1031
+ }
1032
+ seen.add(`${resourceType}:${resourceId}`);
1033
+ for (const resourceInfo of presence.all.get(resourceType).get(resourceId)) {
1034
+ const relationships = resourceInfo.data.relationships;
1035
+ if (relationships) {
1036
+ for (const relName of Object.keys(relationships)) {
1037
+ const rel = relationships[relName];
1038
+ if (rel && 'data' in rel && rel.data !== null) {
1039
+ // for each linkage in the relationship
1040
+ if (Array.isArray(rel.data)) {
1041
+ for (const linkage of rel.data) {
1042
+ if (checkResourcePresent(presence, linkage)) {
1043
+ walkResource(presence, seen, presence.all.get(linkage.type).get(linkage.id));
1044
+ }
1045
+ }
1046
+ } else {
1047
+ if (checkResourcePresent(presence, rel.data)) {
1048
+ walkResource(presence, seen, presence.all.get(rel.data.type).get(rel.data.id));
1049
+ }
1050
+ }
1051
+ }
1052
+ }
1053
+ }
1054
+ }
1055
+ }
1056
+
1057
+ /**
1058
+ * A common mistake, especially during development when mocking responses, is to
1059
+ * return the same resource multiple times, sometimes with differing attributes or
1060
+ * relationships.
1061
+ *
1062
+ * Version: 1.1
1063
+ * Section: 7.4
1064
+ * Link: https://jsonapi.org/format/#document-compound-documents
1065
+ */
1066
+ function validateNoDuplicateResources(reporter, doc) {
1067
+ const {
1068
+ presence
1069
+ } = reporter;
1070
+
1071
+ // validate that all `data` members of relationships have a matching
1072
+ // resource object in the `included` or `data` section of the document.
1073
+
1074
+ // for each type
1075
+ for (const type of presence.all.keys()) {
1076
+ const typeMap = presence.all.get(type);
1077
+ // for each id of the type
1078
+ for (const id of typeMap.keys()) {
1079
+ const resources = typeMap.get(id);
1080
+ if (resources.length > 1) {
1081
+ for (const resourceInfo of resources) {
1082
+ const {
1083
+ index,
1084
+ location
1085
+ } = resourceInfo;
1086
+ const pathLike = index !== null ? [location, index] : [location];
1087
+ const occurrences = resources.filter(r => r !== resourceInfo).map(r => r.index !== null ? `${r.location}[${r.index}]` : r.location);
1088
+ const ourPath = index !== null ? `${location}[${index}]` : location;
1089
+ const errorMsg = `Duplicate ResourceObject detected for '${type}:${id}'. Each ResourceObject MUST appear only once in a {json:api} Document.\n\nThis Occurrence:\n\t- ${ourPath}\n\nOther Occurrences:\n\t- ${occurrences.join('\n\t- ')}`;
1090
+ reporter.error(pathLike, errorMsg, 'value');
1091
+ }
1092
+ }
1093
+ }
1094
+ }
1095
+ }
1096
+
865
1097
  function validateDocument(capabilities, doc) {
866
1098
  macroCondition(getGlobalConfig().WarpDriveMirror.env.DEBUG) ? (test => {
867
1099
  if (!test) {
@@ -906,6 +1138,8 @@ function validateResourceDocument(reporter, doc) {
906
1138
  validateTopLevelDocumentMembers(reporter, doc.content);
907
1139
  validateLinks(reporter, doc.content, 'data' in doc.content && Array.isArray(doc.content?.data) ? 'collection-document' : 'resource-document');
908
1140
  validateDocumentResources(reporter, doc.content);
1141
+ validateNoDuplicateResources(reporter, doc.content);
1142
+ validateFullLinkage(reporter, doc.content);
909
1143
 
910
1144
  // TODO @runspired - validateMeta on document
911
1145
  // TODO @runspired - validateMeta on resource
@@ -171,8 +171,44 @@ class Reporter {
171
171
  linkage: true,
172
172
  unknownType: true,
173
173
  unknownAttribute: true,
174
- unknownRelationship: true
174
+ unknownRelationship: true,
175
+ enforceReachable: true
175
176
  };
177
+ _presence = null;
178
+ get presence() {
179
+ if (this._presence) {
180
+ return this._presence;
181
+ }
182
+ const primaryResources = new Map();
183
+ const includedResources = new Map();
184
+ const allResources = new Map();
185
+ const doc = this.contextDocument.content;
186
+ if (doc) {
187
+ if ('data' in doc && doc.data) {
188
+ if (Array.isArray(doc.data)) {
189
+ for (let i = 0; i < doc.data.length; i++) {
190
+ addResourceToMap(primaryResources, doc.data[i], i, 'data');
191
+ addResourceToMap(allResources, doc.data[i], i, 'data');
192
+ }
193
+ } else {
194
+ addResourceToMap(primaryResources, doc.data, null, 'data');
195
+ addResourceToMap(allResources, doc.data, null, 'data');
196
+ }
197
+ }
198
+ }
199
+ if (doc && 'included' in doc && Array.isArray(doc.included)) {
200
+ for (let i = 0; i < doc.included.length; i++) {
201
+ addResourceToMap(includedResources, doc.included[i], i, 'included');
202
+ addResourceToMap(allResources, doc.included[i], i, 'included');
203
+ }
204
+ }
205
+ this._presence = {
206
+ data: primaryResources,
207
+ included: includedResources,
208
+ all: allResources
209
+ };
210
+ return this._presence;
211
+ }
176
212
  constructor(capabilities, doc) {
177
213
  this.capabilities = capabilities;
178
214
  this.contextDocument = doc;
@@ -363,7 +399,7 @@ class Reporter {
363
399
  }
364
400
  }
365
401
  }
366
- const contextStr = `${counts.error} errors and ${counts.warning} warnings found in the {json:api} document returned by ${this.contextDocument.request?.method} ${this.contextDocument.request?.url}`;
402
+ const contextStr = `${counts.error} errors and ${counts.warning} warnings found in the {json:api} document returned by ${this.contextDocument.request?.method ?? 'GET'} ${this.contextDocument.request?.url}`;
367
403
  const errorString = contextStr + `\n\n` + errorLines.join('\n');
368
404
 
369
405
  // eslint-disable-next-line no-console, @typescript-eslint/no-unused-expressions
@@ -423,6 +459,25 @@ function getRemoteField(fields, key) {
423
459
  }
424
460
  return field;
425
461
  }
462
+ function addResourceToMap(map, resource, index, location) {
463
+ if (!map.has(resource.type)) {
464
+ map.set(resource.type, new Map());
465
+ }
466
+ if (!map.get(resource.type).has(resource.id)) {
467
+ map.get(resource.type).set(resource.id, []);
468
+ }
469
+ map.get(resource.type).get(resource.id).push({
470
+ data: resource,
471
+ index,
472
+ location
473
+ });
474
+ }
475
+ function checkResourceInMap(map, resource) {
476
+ return map.has(resource.type) && map.get(resource.type).has(resource.id);
477
+ }
478
+ function checkResourcePresent(presence, resource) {
479
+ return checkResourceInMap(presence.data, resource) || checkResourceInMap(presence.included, resource);
480
+ }
426
481
 
427
482
  const VALID_TOP_LEVEL_MEMBERS = ['data', 'included', 'meta', 'jsonapi', 'links'];
428
483
 
@@ -856,6 +911,183 @@ function validateResourceRelationships(reporter, type, resource, path) {
856
911
  // type instead of the concrete type.
857
912
  }
858
913
 
914
+ /**
915
+ * Validates that all `data` members of relationships have a matching
916
+ * resource object in the `included` or `data` section of the document.
917
+ *
918
+ * Optionally: validates that all resource objects in `included` are reachable
919
+ * from at least one relationship `data` member in the primary data. This is a
920
+ * spec requirement but has allowed caveats such as for sparse-fields wherein
921
+ * related resources may be present with the linkage property omitted.
922
+ * This setting is on by default but can be disabled by setting `strict.enforceReachable`
923
+ * to false.
924
+ *
925
+ * Version: 1.1
926
+ * Section: 7.4
927
+ * Link: https://jsonapi.org/format/#document-compound-documents
928
+ */
929
+ function validateFullLinkage(reporter, doc) {
930
+ const {
931
+ presence
932
+ } = reporter;
933
+
934
+ // validate that all `data` members of relationships have a matching
935
+ // resource object in the `included` or `data` section of the document.
936
+
937
+ // for each type
938
+ for (const type of presence.all.keys()) {
939
+ const typeMap = presence.all.get(type);
940
+ // for each id of the type
941
+ for (const id of typeMap.keys()) {
942
+ const resources = typeMap.get(id);
943
+ // for each occurrence of the resource
944
+ for (const resourceInfo of resources) {
945
+ const relationships = resourceInfo.data.relationships;
946
+ if (relationships) {
947
+ // for each relationship the occurrence has
948
+ for (const relName of Object.keys(relationships)) {
949
+ const rel = relationships[relName];
950
+ if (rel && 'data' in rel && rel.data !== null) {
951
+ // for each linkage in the relationship
952
+ if (Array.isArray(rel.data)) {
953
+ for (const linkage of rel.data) {
954
+ if (!checkResourcePresent(presence, linkage)) {
955
+ // report missing linkage
956
+ const pathLike = resourceInfo.index === null ? [resourceInfo.location, 'relationships', relName, 'data'] : [resourceInfo.location, resourceInfo.index, 'relationships', relName, 'data'];
957
+ const index = rel.data.indexOf(linkage);
958
+ pathLike.push(index);
959
+ reporter.error(pathLike, `No ResourceObject matching the ResourceIdentifier linkage '${linkage.type}:${linkage.id}' was found in this payload. Exclude the relationship data, include the related ResourceObject, or consider using a link to reference this relationship instead.`, 'value');
960
+ }
961
+ }
962
+ } else {
963
+ if (!checkResourcePresent(presence, rel.data)) {
964
+ const pathLike = resourceInfo.index === null ? [resourceInfo.location, 'relationships', relName, 'data'] : [resourceInfo.location, resourceInfo.index, 'relationships', relName, 'data'];
965
+ reporter.error(pathLike, `No ResourceObject matching the ResourceIdentifier linkage '${rel.data.type}:${rel.data.id}' was found in this payload. Exclude the relationship data, include the related ResourceObject, or consider using a link to reference this relationship instead.`, 'value');
966
+ }
967
+ }
968
+ }
969
+ }
970
+ }
971
+ }
972
+ }
973
+ }
974
+
975
+ // Optionally: validate that all resources in `included` are reachable
976
+ // from at least one relationship `data` member in the primary data.
977
+ const seen = walkResourceGraph(presence);
978
+
979
+ // iterate all included resources
980
+ for (const type of presence.included.keys()) {
981
+ const typeMap = presence.included.get(type);
982
+ for (const id of typeMap.keys()) {
983
+ const seenId = `${type}:${id}`;
984
+ if (!seen.has(seenId)) {
985
+ // report unreachable included resource
986
+ const resources = typeMap.get(id);
987
+ for (const resourceInfo of resources) {
988
+ const {
989
+ index,
990
+ location
991
+ } = resourceInfo;
992
+ const pathLike = index !== null ? [location, index] : [location];
993
+ // TODO if fields were present, attempt to use them to determine if this resource was intentionally excluded from relationships
994
+ // as part of sparseFields.
995
+ if (reporter.strict.enforceReachable !== false) {
996
+ reporter.error(pathLike, `The included ResourceObject '${type}:${id} is unreachable by any path originating from a primary ResourceObject. Exclude this ResourceObject or add the appropriate relationship linkages.`, 'value');
997
+ } else {
998
+ reporter.warn(pathLike, `The included ResourceObject '${type}:${id} is unreachable by any path originating from a primary ResourceObject. Exclude this ResourceObject or add the appropriate relationship linkages.`, 'value');
999
+ }
1000
+ }
1001
+ }
1002
+ }
1003
+ }
1004
+ }
1005
+
1006
+ /**
1007
+ * builds a set of all reachable resources by walking the resource graph
1008
+ * starting from the primary data resources.
1009
+ */
1010
+ function walkResourceGraph(presence) {
1011
+ const seen = new Set();
1012
+ for (const resourceType of presence.data.keys()) {
1013
+ for (const resourceId of presence.data.get(resourceType).keys()) {
1014
+ walkResource(presence, seen, presence.data.get(resourceType).get(resourceId));
1015
+ }
1016
+ }
1017
+ return seen;
1018
+ }
1019
+ function walkResource(presence, seen, resource) {
1020
+ const resourceType = resource[0].data.type;
1021
+ const resourceId = resource[0].data.id;
1022
+ const seenId = `${resourceType}:${resourceId}`;
1023
+ if (seen.has(seenId)) {
1024
+ return;
1025
+ }
1026
+ seen.add(`${resourceType}:${resourceId}`);
1027
+ for (const resourceInfo of presence.all.get(resourceType).get(resourceId)) {
1028
+ const relationships = resourceInfo.data.relationships;
1029
+ if (relationships) {
1030
+ for (const relName of Object.keys(relationships)) {
1031
+ const rel = relationships[relName];
1032
+ if (rel && 'data' in rel && rel.data !== null) {
1033
+ // for each linkage in the relationship
1034
+ if (Array.isArray(rel.data)) {
1035
+ for (const linkage of rel.data) {
1036
+ if (checkResourcePresent(presence, linkage)) {
1037
+ walkResource(presence, seen, presence.all.get(linkage.type).get(linkage.id));
1038
+ }
1039
+ }
1040
+ } else {
1041
+ if (checkResourcePresent(presence, rel.data)) {
1042
+ walkResource(presence, seen, presence.all.get(rel.data.type).get(rel.data.id));
1043
+ }
1044
+ }
1045
+ }
1046
+ }
1047
+ }
1048
+ }
1049
+ }
1050
+
1051
+ /**
1052
+ * A common mistake, especially during development when mocking responses, is to
1053
+ * return the same resource multiple times, sometimes with differing attributes or
1054
+ * relationships.
1055
+ *
1056
+ * Version: 1.1
1057
+ * Section: 7.4
1058
+ * Link: https://jsonapi.org/format/#document-compound-documents
1059
+ */
1060
+ function validateNoDuplicateResources(reporter, doc) {
1061
+ const {
1062
+ presence
1063
+ } = reporter;
1064
+
1065
+ // validate that all `data` members of relationships have a matching
1066
+ // resource object in the `included` or `data` section of the document.
1067
+
1068
+ // for each type
1069
+ for (const type of presence.all.keys()) {
1070
+ const typeMap = presence.all.get(type);
1071
+ // for each id of the type
1072
+ for (const id of typeMap.keys()) {
1073
+ const resources = typeMap.get(id);
1074
+ if (resources.length > 1) {
1075
+ for (const resourceInfo of resources) {
1076
+ const {
1077
+ index,
1078
+ location
1079
+ } = resourceInfo;
1080
+ const pathLike = index !== null ? [location, index] : [location];
1081
+ const occurrences = resources.filter(r => r !== resourceInfo).map(r => r.index !== null ? `${r.location}[${r.index}]` : r.location);
1082
+ const ourPath = index !== null ? `${location}[${index}]` : location;
1083
+ const errorMsg = `Duplicate ResourceObject detected for '${type}:${id}'. Each ResourceObject MUST appear only once in a {json:api} Document.\n\nThis Occurrence:\n\t- ${ourPath}\n\nOther Occurrences:\n\t- ${occurrences.join('\n\t- ')}`;
1084
+ reporter.error(pathLike, errorMsg, 'value');
1085
+ }
1086
+ }
1087
+ }
1088
+ }
1089
+ }
1090
+
859
1091
  function validateDocument(capabilities, doc) {
860
1092
  (test => {
861
1093
  if (!test) {
@@ -883,6 +1115,8 @@ function validateResourceDocument(reporter, doc) {
883
1115
  validateTopLevelDocumentMembers(reporter, doc.content);
884
1116
  validateLinks(reporter, doc.content, 'data' in doc.content && Array.isArray(doc.content?.data) ? 'collection-document' : 'resource-document');
885
1117
  validateDocumentResources(reporter, doc.content);
1118
+ validateNoDuplicateResources(reporter, doc.content);
1119
+ validateFullLinkage(reporter, doc.content);
886
1120
 
887
1121
  // TODO @runspired - validateMeta on document
888
1122
  // TODO @runspired - validateMeta on resource
@@ -171,8 +171,44 @@ class Reporter {
171
171
  linkage: true,
172
172
  unknownType: true,
173
173
  unknownAttribute: true,
174
- unknownRelationship: true
174
+ unknownRelationship: true,
175
+ enforceReachable: true
175
176
  };
177
+ _presence = null;
178
+ get presence() {
179
+ if (this._presence) {
180
+ return this._presence;
181
+ }
182
+ const primaryResources = new Map();
183
+ const includedResources = new Map();
184
+ const allResources = new Map();
185
+ const doc = this.contextDocument.content;
186
+ if (doc) {
187
+ if ('data' in doc && doc.data) {
188
+ if (Array.isArray(doc.data)) {
189
+ for (let i = 0; i < doc.data.length; i++) {
190
+ addResourceToMap(primaryResources, doc.data[i], i, 'data');
191
+ addResourceToMap(allResources, doc.data[i], i, 'data');
192
+ }
193
+ } else {
194
+ addResourceToMap(primaryResources, doc.data, null, 'data');
195
+ addResourceToMap(allResources, doc.data, null, 'data');
196
+ }
197
+ }
198
+ }
199
+ if (doc && 'included' in doc && Array.isArray(doc.included)) {
200
+ for (let i = 0; i < doc.included.length; i++) {
201
+ addResourceToMap(includedResources, doc.included[i], i, 'included');
202
+ addResourceToMap(allResources, doc.included[i], i, 'included');
203
+ }
204
+ }
205
+ this._presence = {
206
+ data: primaryResources,
207
+ included: includedResources,
208
+ all: allResources
209
+ };
210
+ return this._presence;
211
+ }
176
212
  constructor(capabilities, doc) {
177
213
  this.capabilities = capabilities;
178
214
  this.contextDocument = doc;
@@ -363,7 +399,7 @@ class Reporter {
363
399
  }
364
400
  }
365
401
  }
366
- const contextStr = `${counts.error} errors and ${counts.warning} warnings found in the {json:api} document returned by ${this.contextDocument.request?.method} ${this.contextDocument.request?.url}`;
402
+ const contextStr = `${counts.error} errors and ${counts.warning} warnings found in the {json:api} document returned by ${this.contextDocument.request?.method ?? 'GET'} ${this.contextDocument.request?.url}`;
367
403
  const errorString = contextStr + `\n\n` + errorLines.join('\n');
368
404
 
369
405
  // eslint-disable-next-line no-console, @typescript-eslint/no-unused-expressions
@@ -423,6 +459,25 @@ function getRemoteField(fields, key) {
423
459
  }
424
460
  return field;
425
461
  }
462
+ function addResourceToMap(map, resource, index, location) {
463
+ if (!map.has(resource.type)) {
464
+ map.set(resource.type, new Map());
465
+ }
466
+ if (!map.get(resource.type).has(resource.id)) {
467
+ map.get(resource.type).set(resource.id, []);
468
+ }
469
+ map.get(resource.type).get(resource.id).push({
470
+ data: resource,
471
+ index,
472
+ location
473
+ });
474
+ }
475
+ function checkResourceInMap(map, resource) {
476
+ return map.has(resource.type) && map.get(resource.type).has(resource.id);
477
+ }
478
+ function checkResourcePresent(presence, resource) {
479
+ return checkResourceInMap(presence.data, resource) || checkResourceInMap(presence.included, resource);
480
+ }
426
481
 
427
482
  const VALID_TOP_LEVEL_MEMBERS = ['data', 'included', 'meta', 'jsonapi', 'links'];
428
483
 
@@ -856,6 +911,183 @@ function validateResourceRelationships(reporter, type, resource, path) {
856
911
  // type instead of the concrete type.
857
912
  }
858
913
 
914
+ /**
915
+ * Validates that all `data` members of relationships have a matching
916
+ * resource object in the `included` or `data` section of the document.
917
+ *
918
+ * Optionally: validates that all resource objects in `included` are reachable
919
+ * from at least one relationship `data` member in the primary data. This is a
920
+ * spec requirement but has allowed caveats such as for sparse-fields wherein
921
+ * related resources may be present with the linkage property omitted.
922
+ * This setting is on by default but can be disabled by setting `strict.enforceReachable`
923
+ * to false.
924
+ *
925
+ * Version: 1.1
926
+ * Section: 7.4
927
+ * Link: https://jsonapi.org/format/#document-compound-documents
928
+ */
929
+ function validateFullLinkage(reporter, doc) {
930
+ const {
931
+ presence
932
+ } = reporter;
933
+
934
+ // validate that all `data` members of relationships have a matching
935
+ // resource object in the `included` or `data` section of the document.
936
+
937
+ // for each type
938
+ for (const type of presence.all.keys()) {
939
+ const typeMap = presence.all.get(type);
940
+ // for each id of the type
941
+ for (const id of typeMap.keys()) {
942
+ const resources = typeMap.get(id);
943
+ // for each occurrence of the resource
944
+ for (const resourceInfo of resources) {
945
+ const relationships = resourceInfo.data.relationships;
946
+ if (relationships) {
947
+ // for each relationship the occurrence has
948
+ for (const relName of Object.keys(relationships)) {
949
+ const rel = relationships[relName];
950
+ if (rel && 'data' in rel && rel.data !== null) {
951
+ // for each linkage in the relationship
952
+ if (Array.isArray(rel.data)) {
953
+ for (const linkage of rel.data) {
954
+ if (!checkResourcePresent(presence, linkage)) {
955
+ // report missing linkage
956
+ const pathLike = resourceInfo.index === null ? [resourceInfo.location, 'relationships', relName, 'data'] : [resourceInfo.location, resourceInfo.index, 'relationships', relName, 'data'];
957
+ const index = rel.data.indexOf(linkage);
958
+ pathLike.push(index);
959
+ reporter.error(pathLike, `No ResourceObject matching the ResourceIdentifier linkage '${linkage.type}:${linkage.id}' was found in this payload. Exclude the relationship data, include the related ResourceObject, or consider using a link to reference this relationship instead.`, 'value');
960
+ }
961
+ }
962
+ } else {
963
+ if (!checkResourcePresent(presence, rel.data)) {
964
+ const pathLike = resourceInfo.index === null ? [resourceInfo.location, 'relationships', relName, 'data'] : [resourceInfo.location, resourceInfo.index, 'relationships', relName, 'data'];
965
+ reporter.error(pathLike, `No ResourceObject matching the ResourceIdentifier linkage '${rel.data.type}:${rel.data.id}' was found in this payload. Exclude the relationship data, include the related ResourceObject, or consider using a link to reference this relationship instead.`, 'value');
966
+ }
967
+ }
968
+ }
969
+ }
970
+ }
971
+ }
972
+ }
973
+ }
974
+
975
+ // Optionally: validate that all resources in `included` are reachable
976
+ // from at least one relationship `data` member in the primary data.
977
+ const seen = walkResourceGraph(presence);
978
+
979
+ // iterate all included resources
980
+ for (const type of presence.included.keys()) {
981
+ const typeMap = presence.included.get(type);
982
+ for (const id of typeMap.keys()) {
983
+ const seenId = `${type}:${id}`;
984
+ if (!seen.has(seenId)) {
985
+ // report unreachable included resource
986
+ const resources = typeMap.get(id);
987
+ for (const resourceInfo of resources) {
988
+ const {
989
+ index,
990
+ location
991
+ } = resourceInfo;
992
+ const pathLike = index !== null ? [location, index] : [location];
993
+ // TODO if fields were present, attempt to use them to determine if this resource was intentionally excluded from relationships
994
+ // as part of sparseFields.
995
+ if (reporter.strict.enforceReachable !== false) {
996
+ reporter.error(pathLike, `The included ResourceObject '${type}:${id} is unreachable by any path originating from a primary ResourceObject. Exclude this ResourceObject or add the appropriate relationship linkages.`, 'value');
997
+ } else {
998
+ reporter.warn(pathLike, `The included ResourceObject '${type}:${id} is unreachable by any path originating from a primary ResourceObject. Exclude this ResourceObject or add the appropriate relationship linkages.`, 'value');
999
+ }
1000
+ }
1001
+ }
1002
+ }
1003
+ }
1004
+ }
1005
+
1006
+ /**
1007
+ * builds a set of all reachable resources by walking the resource graph
1008
+ * starting from the primary data resources.
1009
+ */
1010
+ function walkResourceGraph(presence) {
1011
+ const seen = new Set();
1012
+ for (const resourceType of presence.data.keys()) {
1013
+ for (const resourceId of presence.data.get(resourceType).keys()) {
1014
+ walkResource(presence, seen, presence.data.get(resourceType).get(resourceId));
1015
+ }
1016
+ }
1017
+ return seen;
1018
+ }
1019
+ function walkResource(presence, seen, resource) {
1020
+ const resourceType = resource[0].data.type;
1021
+ const resourceId = resource[0].data.id;
1022
+ const seenId = `${resourceType}:${resourceId}`;
1023
+ if (seen.has(seenId)) {
1024
+ return;
1025
+ }
1026
+ seen.add(`${resourceType}:${resourceId}`);
1027
+ for (const resourceInfo of presence.all.get(resourceType).get(resourceId)) {
1028
+ const relationships = resourceInfo.data.relationships;
1029
+ if (relationships) {
1030
+ for (const relName of Object.keys(relationships)) {
1031
+ const rel = relationships[relName];
1032
+ if (rel && 'data' in rel && rel.data !== null) {
1033
+ // for each linkage in the relationship
1034
+ if (Array.isArray(rel.data)) {
1035
+ for (const linkage of rel.data) {
1036
+ if (checkResourcePresent(presence, linkage)) {
1037
+ walkResource(presence, seen, presence.all.get(linkage.type).get(linkage.id));
1038
+ }
1039
+ }
1040
+ } else {
1041
+ if (checkResourcePresent(presence, rel.data)) {
1042
+ walkResource(presence, seen, presence.all.get(rel.data.type).get(rel.data.id));
1043
+ }
1044
+ }
1045
+ }
1046
+ }
1047
+ }
1048
+ }
1049
+ }
1050
+
1051
+ /**
1052
+ * A common mistake, especially during development when mocking responses, is to
1053
+ * return the same resource multiple times, sometimes with differing attributes or
1054
+ * relationships.
1055
+ *
1056
+ * Version: 1.1
1057
+ * Section: 7.4
1058
+ * Link: https://jsonapi.org/format/#document-compound-documents
1059
+ */
1060
+ function validateNoDuplicateResources(reporter, doc) {
1061
+ const {
1062
+ presence
1063
+ } = reporter;
1064
+
1065
+ // validate that all `data` members of relationships have a matching
1066
+ // resource object in the `included` or `data` section of the document.
1067
+
1068
+ // for each type
1069
+ for (const type of presence.all.keys()) {
1070
+ const typeMap = presence.all.get(type);
1071
+ // for each id of the type
1072
+ for (const id of typeMap.keys()) {
1073
+ const resources = typeMap.get(id);
1074
+ if (resources.length > 1) {
1075
+ for (const resourceInfo of resources) {
1076
+ const {
1077
+ index,
1078
+ location
1079
+ } = resourceInfo;
1080
+ const pathLike = index !== null ? [location, index] : [location];
1081
+ const occurrences = resources.filter(r => r !== resourceInfo).map(r => r.index !== null ? `${r.location}[${r.index}]` : r.location);
1082
+ const ourPath = index !== null ? `${location}[${index}]` : location;
1083
+ const errorMsg = `Duplicate ResourceObject detected for '${type}:${id}'. Each ResourceObject MUST appear only once in a {json:api} Document.\n\nThis Occurrence:\n\t- ${ourPath}\n\nOther Occurrences:\n\t- ${occurrences.join('\n\t- ')}`;
1084
+ reporter.error(pathLike, errorMsg, 'value');
1085
+ }
1086
+ }
1087
+ }
1088
+ }
1089
+ }
1090
+
859
1091
  function validateDocument(capabilities, doc) {
860
1092
  (test => {
861
1093
  if (!test) {
@@ -883,6 +1115,8 @@ function validateResourceDocument(reporter, doc) {
883
1115
  validateTopLevelDocumentMembers(reporter, doc.content);
884
1116
  validateLinks(reporter, doc.content, 'data' in doc.content && Array.isArray(doc.content?.data) ? 'collection-document' : 'resource-document');
885
1117
  validateDocumentResources(reporter, doc.content);
1118
+ validateNoDuplicateResources(reporter, doc.content);
1119
+ validateFullLinkage(reporter, doc.content);
886
1120
 
887
1121
  // TODO @runspired - validateMeta on document
888
1122
  // TODO @runspired - validateMeta on resource
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@warp-drive-mirror/json-api",
3
- "version": "5.9.0-alpha.0",
3
+ "version": "5.9.0-alpha.1",
4
4
  "description": "A {json:api} Cache Implementation for WarpDrive",
5
5
  "keywords": [
6
6
  "ember-addon"
@@ -40,7 +40,7 @@
40
40
  }
41
41
  },
42
42
  "peerDependencies": {
43
- "@warp-drive-mirror/core": "5.9.0-alpha.0"
43
+ "@warp-drive-mirror/core": "5.9.0-alpha.1"
44
44
  },
45
45
  "dependencies": {
46
46
  "@embroider/macros": "^1.18.1",
@@ -52,8 +52,8 @@
52
52
  "@babel/plugin-transform-typescript": "^7.28.0",
53
53
  "@babel/preset-typescript": "^7.27.1",
54
54
  "@types/json-to-ast": "^2.1.4",
55
- "@warp-drive/internal-config": "5.9.0-alpha.0",
56
- "@warp-drive-mirror/core": "5.9.0-alpha.0",
55
+ "@warp-drive/internal-config": "5.9.0-alpha.1",
56
+ "@warp-drive-mirror/core": "5.9.0-alpha.1",
57
57
  "decorator-transforms": "^2.3.0",
58
58
  "expect-type": "^1.2.2",
59
59
  "typescript": "^5.9.2",