@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.
- package/declarations/-private/validator/1.1/7.4_full-linkage.d.ts +18 -0
- package/declarations/-private/validator/1.1/7.4_no-duplicate-resources.d.ts +12 -0
- package/declarations/-private/validator/utils.d.ts +25 -0
- package/dist/index.js +236 -2
- package/dist/unpkg/dev/index.js +236 -2
- package/dist/unpkg/dev-deprecated/index.js +236 -2
- package/package.json +4 -4
|
@@ -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
|
package/dist/unpkg/dev/index.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
56
|
-
"@warp-drive-mirror/core": "5.9.0-alpha.
|
|
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",
|