Skip to content

Prevent circular deserialization #107

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Apr 30, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 45 additions & 57 deletions lib/JSONAPISerializer.js
Original file line number Diff line number Diff line change
Expand Up @@ -438,9 +438,10 @@ module.exports = class JSONAPISerializer {
* @param {object} data JSON API resource data.
* @param {string} [schema='default'] resource's schema name.
* @param {Map<string, object>} included Included resources.
* @param {string[]} lineage resource identifiers already deserialized to prevent circular references.
* @returns {object} deserialized data.
*/
deserializeResource(type, data, schema = 'default', included) {
deserializeResource(type, data, schema = 'default', included, lineage = []) {
if (typeof type === 'object') {
type = typeof type.type === 'function' ? type.type(data) : get(data, type.type);
}
Expand Down Expand Up @@ -487,73 +488,54 @@ module.exports = class JSONAPISerializer {
};

if (relationship.data !== undefined) {
if (Array.isArray(relationship.data)) {
if (relationshipOptions && relationshipOptions.alternativeKey) {
set(
deserializedData,
relationshipOptions.alternativeKey,
relationship.data.map((d) => deserializeFunction(d))
);

if (included) {
set(
deserializedData,
relationshipKey,
relationship.data.map((d) =>
this.deserializeIncluded(d.type, d.id, relationshipOptions, included)
)
);
}
} else {
set(
deserializedData,
relationshipKey,
relationship.data.map((d) =>
included
? this.deserializeIncluded(d.type, d.id, relationshipOptions, included)
: deserializeFunction(d)
)
);
}
} else if (relationship.data === null) {
if (relationship.data === null) {
// null data
set(
deserializedData,
(relationshipOptions && relationshipOptions.alternativeKey) || relationshipKey,
null
);
} else if (relationshipOptions && relationshipOptions.alternativeKey) {
set(
deserializedData,
relationshipOptions.alternativeKey,
deserializeFunction(relationship.data)
);
} else {
if ((relationshipOptions && relationshipOptions.alternativeKey) || !included) {
set(
deserializedData,
(relationshipOptions && relationshipOptions.alternativeKey) || relationshipKey,
Array.isArray(relationship.data)
? relationship.data.map((d) => deserializeFunction(d))
: deserializeFunction(relationship.data)
);
}

if (included) {
const deserializeIncludedRelationship = (relationshipData) => {
const lineageCopy = [...lineage];
// Prevent circular relationships
const isCircular = lineageCopy.includes(
`${relationshipData.type}-${relationshipData.id}`
);

if (isCircular) {
return deserializeFunction(data);
}

lineageCopy.push(`${type}-${data.id}`);
return this.deserializeIncluded(
relationshipData.type,
relationshipData.id,
relationshipOptions,
included,
lineageCopy
);
};

set(
deserializedData,
relationshipKey,
this.deserializeIncluded(
relationship.data.type,
relationship.data.id,
relationshipOptions,
included
)
Array.isArray(relationship.data)
? relationship.data.map((d) => deserializeIncludedRelationship(d))
: deserializeIncludedRelationship(relationship.data)
);
}
} else {
set(
deserializedData,
relationshipKey,
included
? this.deserializeIncluded(
relationship.data.type,
relationship.data.id,
relationshipOptions,
included
)
: deserializeFunction(relationship.data)
);
}
}
});
Expand All @@ -578,7 +560,7 @@ module.exports = class JSONAPISerializer {
return deserializedData;
}

deserializeIncluded(type, id, relationshipOpts, included) {
deserializeIncluded(type, id, relationshipOpts, included, lineage) {
const includedResource = included.find(
(resource) => resource.type === type && resource.id === id
);
Expand All @@ -587,7 +569,13 @@ module.exports = class JSONAPISerializer {
return id;
}

return this.deserializeResource(type, includedResource, relationshipOpts.schema, included);
return this.deserializeResource(
type,
includedResource,
relationshipOpts.schema,
included,
lineage
);
}

/**
Expand Down
160 changes: 160 additions & 0 deletions test/unit/JSONAPISerializer.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1564,6 +1564,166 @@ describe('JSONAPISerializer', function() {
done();
});

it('should deserialize data with circular included', function(done) {
const Serializer = new JSONAPISerializer();

Serializer.register('article', {
relationships: {
author: { type: 'user' },
user: { type: 'user' },
profile: { type: 'profile' }
},
});

Serializer.register('user', {
relationships: {
profile: { type: 'profile' },
},
});

Serializer.register('profile', {
relationships: {
user: { type: 'user' },
},
});

const data = {
"data": {
"type": "article",
"id": "1",
"attributes": {
"title": "title"
},
"relationships": {
"author": { "data": { "type": "user", "id": "1" } },
"user": { "data": { "type": "user", "id": "1" } },
"profile": { "data": { "type": "profile", "id": "1" } }
}
},
"included": [
{
"type": "user",
"id": "1",
"attributes": {
"email": "[email protected]"
},
"relationships": {
"profile": { "data": { "type": "profile", "id": "1" } }
}
},
{
"type": "profile",
"id": "1",
"attributes": {
"firstName": "first-name",
"lastName": "last-name"
},
"relationships": {
"user": { "data": { "type": "user", "id": "1" } }
}
}
]
}

const deserializedData = Serializer.deserialize('article', data);
expect(deserializedData).to.have.property('id');
expect(deserializedData.author).to.have.property('id');
expect(deserializedData.author.profile).to.have.property('id');
expect(deserializedData.author.profile.user).to.equal('1');
expect(deserializedData.user).to.have.property('id');
expect(deserializedData.profile).to.have.property('id');
done();
});

it('should deserialize data with circular included array', function(done) {
const Serializer = new JSONAPISerializer();

Serializer.register('article', {
relationships: {
author: { type: 'user' },
profile: {type: 'profile'}
},
});

Serializer.register('user', {
relationships: {
profile: { type: 'profile' },
},
});

Serializer.register('profile', {
relationships: {
user: { type: 'user' },
profile: {type: 'profile'}
},
});

const data = {
"data": {
"type": "article",
"id": "1",
"attributes": {
"title": "title"
},
"relationships": {
"author": { "data": [{ "type": "user", "id": "1" }, { "type": "user", "id": "2" }] },
}
},
"included": [
{
"type": "user",
"id": "1",
"attributes": {
"email": "[email protected]"
},
"relationships": {
"profile": { "data": [{ "type": "profile", "id": "1" }] }
}
},
{
"type": "user",
"id": "2",
"attributes": {
"email": "[email protected]"
},
"relationships": {
"profile": { "data": [{ "type": "profile", "id": "2" }] }
}
},
{
"type": "profile",
"id": "1",
"attributes": {
"firstName": "first-name",
"lastName": "last-name"
},
"relationships": {
"user": { "data": { "type": "user", "id": "1" } }
}
},
{
"type": "profile",
"id": "2",
"attributes": {
"firstName": "first-name",
"lastName": "last-name"
},
"relationships": {
"user": { "data": { "type": "user", "id": "2" } }
}
}
]
}

const deserializedData = Serializer.deserialize('article', data);
expect(deserializedData).to.have.property('id');
expect(deserializedData).to.have.property('author').to.be.instanceof(Array).to.have.length(2);
expect(deserializedData.author[0]).to.have.property('profile').to.be.instanceof(Array).to.have.length(1);
expect(deserializedData.author[0].profile[0]).to.have.property('user').to.equal('1');
expect(deserializedData.author[1].profile[0]).to.have.property('user').to.equal('2');
done();
});

it('should deserialize with missing included relationship', function(done) {
const Serializer = new JSONAPISerializer();
Serializer.register('articles', {
Expand Down