Skip to content

Commit

Permalink
prevent circular deserialization (#107)
Browse files Browse the repository at this point in the history
fix "Maximum call stack size exceeded" error.

closes #106 #107
  • Loading branch information
danivek authored Apr 30, 2020
1 parent 9de7a78 commit a7b4e88
Show file tree
Hide file tree
Showing 2 changed files with 205 additions and 57 deletions.
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": "user@example.com"
},
"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": "user@example.com"
},
"relationships": {
"profile": { "data": [{ "type": "profile", "id": "1" }] }
}
},
{
"type": "user",
"id": "2",
"attributes": {
"email": "user2@example.com"
},
"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

0 comments on commit a7b4e88

Please sign in to comment.