Skip to content

Commit b38e492

Browse files
authored
Merge pull request #24 from CrisisTextLine/copilot/fix-9
Implement comprehensive compound document validation for JSON:API
2 parents 80842c8 + 72b67fa commit b38e492

1 file changed

Lines changed: 352 additions & 2 deletions

File tree

src/validators/DocumentValidator.js

Lines changed: 352 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,8 +90,26 @@ export function validateDocument(response) {
9090
}
9191
}
9292

93-
// Step 5: Validate optional included array
94-
if (Object.prototype.hasOwnProperty.call(response, 'included')) {
93+
// Step 5: Validate optional included array and compound document rules
94+
const hasIncluded = Object.prototype.hasOwnProperty.call(response, 'included')
95+
96+
if (hasIncluded) {
97+
// First validate that included is only present when data is present
98+
if (!hasData) {
99+
results.valid = false
100+
results.errors.push({
101+
test: 'Compound Document Structure',
102+
message: 'Included member must not be present without data member'
103+
})
104+
} else {
105+
results.details.push({
106+
test: 'Compound Document Structure',
107+
status: 'passed',
108+
message: 'Included member is properly paired with data member'
109+
})
110+
}
111+
112+
// Validate the included member structure
95113
const includedValidation = validateIncludedMember(response.included)
96114
results.details.push(...includedValidation.details)
97115
if (!includedValidation.valid) {
@@ -101,6 +119,19 @@ export function validateDocument(response) {
101119
if (includedValidation.warnings) {
102120
results.warnings.push(...includedValidation.warnings)
103121
}
122+
123+
// Validate compound document if both data and included are present and valid
124+
if (hasData && includedValidation.valid && results.valid) {
125+
const compoundValidation = validateCompoundDocument(response.data, response.included)
126+
results.details.push(...compoundValidation.details)
127+
if (!compoundValidation.valid) {
128+
results.valid = false
129+
results.errors.push(...compoundValidation.errors)
130+
}
131+
if (compoundValidation.warnings) {
132+
results.warnings.push(...compoundValidation.warnings)
133+
}
134+
}
104135
}
105136

106137
// Step 6: Validate optional links object
@@ -256,6 +287,78 @@ function validateIncludedMember(included) {
256287
return results
257288
}
258289

290+
/**
291+
* Validates compound document rules - linkage, duplicates, and circular references
292+
* @param {any} data - The primary data (resource object, array of resources, or null)
293+
* @param {Array} included - The included resources array
294+
* @returns {Object} Validation result
295+
*/
296+
function validateCompoundDocument(data, included) {
297+
const results = {
298+
valid: true,
299+
errors: [],
300+
warnings: [],
301+
details: []
302+
}
303+
304+
// Skip most validation if data is null, but check for orphaned included resources
305+
if (data === null) {
306+
if (included.length > 0) {
307+
results.valid = false
308+
results.errors.push({
309+
test: 'Resource Linkage',
310+
message: `All ${included.length} included resources are orphaned when data is null`
311+
})
312+
} else {
313+
results.details.push({
314+
test: 'Compound Document Validation',
315+
status: 'passed',
316+
message: 'No compound document validation needed (data is null and included is empty)'
317+
})
318+
}
319+
return results
320+
}
321+
322+
// Skip validation if included is empty
323+
if (!Array.isArray(included) || included.length === 0) {
324+
results.details.push({
325+
test: 'Compound Document Validation',
326+
status: 'passed',
327+
message: 'No compound document validation needed (included is empty)'
328+
})
329+
return results
330+
}
331+
332+
// Step 1: Check for duplicate resources in included array
333+
const duplicateValidation = validateNoDuplicatesInIncluded(included)
334+
results.details.push(...duplicateValidation.details)
335+
if (!duplicateValidation.valid) {
336+
results.valid = false
337+
results.errors.push(...duplicateValidation.errors)
338+
}
339+
340+
// Step 2: Validate that all included resources are referenced from primary data
341+
const linkageValidation = validateResourceLinkage(data, included)
342+
results.details.push(...linkageValidation.details)
343+
if (!linkageValidation.valid) {
344+
results.valid = false
345+
results.errors.push(...linkageValidation.errors)
346+
}
347+
if (linkageValidation.warnings) {
348+
results.warnings.push(...linkageValidation.warnings)
349+
}
350+
351+
// Step 3: Check for circular references
352+
const circularRefValidation = validateNoCircularReferences(data, included)
353+
results.details.push(...circularRefValidation.details)
354+
if (!circularRefValidation.valid) {
355+
results.valid = false
356+
results.errors.push(...circularRefValidation.errors)
357+
}
358+
359+
return results
360+
}
361+
259362
/**
260363
* Validates the links member
261364
* @param {any} links - The links value to validate
@@ -503,4 +606,251 @@ function validateJsonApiMember(jsonapi) {
503606
}
504607

505608
return results
609+
}
610+
611+
/**
612+
* Validates that there are no duplicate resources in the included array
613+
* @param {Array} included - The included resources array
614+
* @returns {Object} Validation result
615+
*/
616+
function validateNoDuplicatesInIncluded(included) {
617+
const results = {
618+
valid: true,
619+
errors: [],
620+
details: []
621+
}
622+
623+
const seenResources = new Set()
624+
const duplicates = []
625+
626+
for (let i = 0; i < included.length; i++) {
627+
const resource = included[i]
628+
if (resource && typeof resource === 'object' && resource.type && resource.id) {
629+
const resourceKey = `${resource.type}:${resource.id}`
630+
if (seenResources.has(resourceKey)) {
631+
duplicates.push({ type: resource.type, id: resource.id, index: i })
632+
} else {
633+
seenResources.add(resourceKey)
634+
}
635+
}
636+
}
637+
638+
if (duplicates.length > 0) {
639+
results.valid = false
640+
duplicates.forEach(dup => {
641+
results.errors.push({
642+
test: 'Included Resource Duplicates',
643+
message: `Duplicate resource found in included array: ${dup.type}:${dup.id} at index ${dup.index}`
644+
})
645+
})
646+
} else {
647+
results.details.push({
648+
test: 'Included Resource Duplicates',
649+
status: 'passed',
650+
message: `No duplicate resources found in included array (${included.length} unique resources)`
651+
})
652+
}
653+
654+
return results
655+
}
656+
657+
/**
658+
* Validates that all included resources are referenced from the primary data
659+
* @param {any} data - The primary data (resource object, array, or null)
660+
* @param {Array} included - The included resources array
661+
* @returns {Object} Validation result
662+
*/
663+
function validateResourceLinkage(data, included) {
664+
const results = {
665+
valid: true,
666+
errors: [],
667+
warnings: [],
668+
details: []
669+
}
670+
671+
// Get all resource identifiers referenced from primary data
672+
const referencedResources = extractReferencedResources(data)
673+
674+
// Create set of included resource identifiers
675+
const includedResources = new Set()
676+
included.forEach(resource => {
677+
if (resource && typeof resource === 'object' && resource.type && resource.id) {
678+
includedResources.add(`${resource.type}:${resource.id}`)
679+
}
680+
})
681+
682+
// Find orphaned resources (included but not referenced)
683+
const orphanedResources = []
684+
includedResources.forEach(resourceKey => {
685+
if (!referencedResources.has(resourceKey)) {
686+
const [type, id] = resourceKey.split(':')
687+
orphanedResources.push({ type, id })
688+
}
689+
})
690+
691+
// Find missing resources (referenced but not included)
692+
const missingResources = []
693+
referencedResources.forEach(resourceKey => {
694+
if (!includedResources.has(resourceKey)) {
695+
const [type, id] = resourceKey.split(':')
696+
missingResources.push({ type, id })
697+
}
698+
})
699+
700+
// Report orphaned resources as errors
701+
if (orphanedResources.length > 0) {
702+
results.valid = false
703+
orphanedResources.forEach(resource => {
704+
results.errors.push({
705+
test: 'Resource Linkage',
706+
message: `Orphaned included resource: ${resource.type}:${resource.id} is not referenced from primary data`
707+
})
708+
})
709+
}
710+
711+
// Report missing resources as warnings (they might be intentionally omitted)
712+
if (missingResources.length > 0) {
713+
missingResources.forEach(resource => {
714+
results.warnings.push({
715+
test: 'Resource Linkage',
716+
message: `Referenced resource ${resource.type}:${resource.id} is not included in compound document`
717+
})
718+
})
719+
}
720+
721+
if (orphanedResources.length === 0 && missingResources.length === 0) {
722+
results.details.push({
723+
test: 'Resource Linkage',
724+
status: 'passed',
725+
message: `Perfect linkage: all ${included.length} included resources are referenced from primary data`
726+
})
727+
} else if (orphanedResources.length === 0) {
728+
results.details.push({
729+
test: 'Resource Linkage',
730+
status: 'passed',
731+
message: `No orphaned resources found (${missingResources.length} referenced resources not included)`
732+
})
733+
}
734+
735+
return results
736+
}
737+
738+
/**
739+
* Extracts all resource identifiers referenced from relationships in primary data
740+
* @param {any} data - The primary data (resource object, array, or null)
741+
* @returns {Set} Set of resource identifiers in format "type:id"
742+
*/
743+
function extractReferencedResources(data) {
744+
const references = new Set()
745+
746+
if (data === null) {
747+
return references
748+
}
749+
750+
const resources = Array.isArray(data) ? data : [data]
751+
752+
resources.forEach(resource => {
753+
if (resource && typeof resource === 'object' && resource.relationships) {
754+
Object.values(resource.relationships).forEach(relationship => {
755+
if (relationship && relationship.data) {
756+
const relData = Array.isArray(relationship.data) ? relationship.data : [relationship.data]
757+
relData.forEach(rel => {
758+
if (rel && rel.type && rel.id) {
759+
references.add(`${rel.type}:${rel.id}`)
760+
}
761+
})
762+
}
763+
})
764+
}
765+
})
766+
767+
return references
768+
}
769+
770+
/**
771+
* Validates that there are no circular references in compound documents
772+
* Note: In JSON:API, bidirectional relationships are normal and allowed.
773+
* This validation primarily serves as informational analysis rather than strict validation.
774+
* @param {any} data - The primary data
775+
* @param {Array} included - The included resources array
776+
* @returns {Object} Validation result
777+
*/
778+
function validateNoCircularReferences(data, included) {
779+
const results = {
780+
valid: true,
781+
errors: [],
782+
details: []
783+
}
784+
785+
// Create a map of all resources (primary + included) for reference lookup
786+
const allResources = new Map()
787+
788+
// Add primary data resources
789+
if (data !== null) {
790+
const primaryResources = Array.isArray(data) ? data : [data]
791+
primaryResources.forEach(resource => {
792+
if (resource && typeof resource === 'object' && resource.type && resource.id) {
793+
allResources.set(`${resource.type}:${resource.id}`, resource)
794+
}
795+
})
796+
}
797+
798+
// Add included resources
799+
included.forEach(resource => {
800+
if (resource && typeof resource === 'object' && resource.type && resource.id) {
801+
allResources.set(`${resource.type}:${resource.id}`, resource)
802+
}
803+
})
804+
805+
// Analyze relationship structure for informational purposes
806+
const relationshipCount = analyzeRelationshipStructure(allResources)
807+
808+
results.details.push({
809+
test: 'Circular References',
810+
status: 'passed',
811+
message: `Relationship structure analyzed: ${allResources.size} resources with ${relationshipCount.total} relationships (${relationshipCount.bidirectional} bidirectional). Bidirectional relationships are normal in JSON:API.`
812+
})
813+
814+
return results
815+
}
816+
817+
/**
818+
* Analyzes relationship structure in compound documents for informational purposes
819+
* @param {Map} allResources - Map of all resources (primary + included)
820+
* @returns {Object} Analysis results with counts
821+
*/
822+
function analyzeRelationshipStructure(allResources) {
823+
let totalRelationships = 0
824+
let bidirectionalCount = 0
825+
const relationships = new Set()
826+
827+
for (const [resourceKey, resource] of allResources) {
828+
if (resource.relationships) {
829+
Object.values(resource.relationships).forEach(relationship => {
830+
if (relationship && relationship.data) {
831+
const relData = Array.isArray(relationship.data) ? relationship.data : [relationship.data]
832+
relData.forEach(rel => {
833+
if (rel && rel.type && rel.id) {
834+
const relKey = `${rel.type}:${rel.id}`
835+
const relationshipPair = `${resourceKey}->${relKey}`
836+
const reverseRelationshipPair = `${relKey}->${resourceKey}`
837+
838+
relationships.add(relationshipPair)
839+
totalRelationships++
840+
841+
// Check if reverse relationship exists
842+
if (relationships.has(reverseRelationshipPair)) {
843+
bidirectionalCount++
844+
}
845+
}
846+
})
847+
}
848+
})
849+
}
850+
}
851+
852+
return {
853+
total: totalRelationships,
854+
bidirectional: Math.floor(bidirectionalCount / 2) // Each bidirectional pair is counted twice
855+
}
506856
}

0 commit comments

Comments
 (0)