Skip to content

Commit d7815de

Browse files
javierturydanivek
authored andcommitted
feat: serialization/deserialization hooks (#105)
closes #105 closes #104 closes #37 closes #66
1 parent 4452605 commit d7815de

File tree

7 files changed

+227
-21
lines changed

7 files changed

+227
-21
lines changed

README.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,12 +54,14 @@ Serializer.register(type, options);
5454
* **deserialize** (optional): Describes the function which should be used to deserialize a related property which is not included in the JSON:API document. It should be:
5555
* A _function_ with one argument `function(data) { ... }`which defines the format to which a relation should be deserialized. By default, the ID of the related object is returned, which would be equal to `function(data) {return data.id}`. See [issue #65](https://github.com/danivek/json-api-serializer/issues/65).
5656
* **convertCase** (optional): Case conversion for serializing data. Value can be : `kebab-case`, `snake_case`, `camelCase`
57+
* **beforeSerialize** (optional): A _function_ with one argument `beforeSerialize(data) => newData` to transform data before serialization.
5758

5859
**Deserialization options:**
5960

6061
* **unconvertCase** (optional): Case conversion for deserializing data. Value can be : `kebab-case`, `snake_case`, `camelCase`
6162
* **blacklistOnDeserialize** (optional): An array of blacklisted attributes. Default = [].
6263
* **whitelistOnDeserialize** (optional): An array of whitelisted attributes. Default = [].
64+
* **afterDeserialize** (optional): A _function_ with one argument `afterDeserialize(data) => newData` to transform data after deserialization.
6365

6466
**Global options:**
6567

@@ -525,6 +527,44 @@ const deserialized = Serializer.deserializeAsync(typeConfig, data).then(result =
525527
});
526528
```
527529

530+
## Custom serialization and deserialization
531+
532+
If your data requires some specific transformations, those can be applied using `beforeSerialize` and `afterDeserialize`
533+
534+
Example for composite primary keys:
535+
536+
```javascript
537+
Serializer.register('translation', {
538+
beforeSerialize: (data) => {
539+
// Exclude pk1 and pk2 from data
540+
const { pk1, pk2, ...attributes } = data;
541+
542+
// Compute external id
543+
const id = `${pk1}-${pk2}`;
544+
545+
// Return data with id
546+
return {
547+
...attributes,
548+
id
549+
};
550+
},
551+
afterDeserialize: (data) => {
552+
// Exclude id from data
553+
const { id, ...attributes } = data;
554+
555+
// Recover PKs
556+
const [pk1, pk2] = id.split('-');
557+
558+
// Return data with PKs
559+
return {
560+
...attributes,
561+
pk1,
562+
pk2,
563+
};
564+
},
565+
});
566+
```
567+
528568
## Benchmark
529569

530570
```bash

lib/JSONAPISerializer.js

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -571,6 +571,10 @@ module.exports = class JSONAPISerializer {
571571
deserializedData.meta = data.meta;
572572
}
573573

574+
if (options.afterDeserialize) {
575+
return options.afterDeserialize(deserializedData);
576+
}
577+
574578
return deserializedData;
575579
}
576580

@@ -612,6 +616,10 @@ module.exports = class JSONAPISerializer {
612616
);
613617
}
614618

619+
if (options.beforeSerialize) {
620+
data = options.beforeSerialize(data);
621+
}
622+
615623
return {
616624
type,
617625
id: data[options.id] ? data[options.id].toString() : undefined,
@@ -848,19 +856,20 @@ module.exports = class JSONAPISerializer {
848856
if (!isObjectLike(rData)) {
849857
serializedRelationship.id = rData.toString();
850858
} else {
851-
serializedRelationship.id = rData[rOptions.id].toString();
852-
// Not include relationship object which only contains an id
853-
if (!(Object.keys(rData).length === 1 && rData[rOptions.id])) {
854-
const identifier = `${type}-${serializedRelationship.id}`;
855-
const serializedIncluded = this.serializeResource(
856-
type,
857-
rData,
858-
rOptions,
859-
included,
860-
extraData,
861-
overrideSchemaOptions
862-
);
859+
const serializedIncluded = this.serializeResource(
860+
type,
861+
rData,
862+
rOptions,
863+
included,
864+
extraData,
865+
overrideSchemaOptions
866+
);
867+
868+
serializedRelationship.id = serializedIncluded.id;
869+
const identifier = `${type}-${serializedRelationship.id}`;
863870

871+
// Not include relationship object which only contains an id
872+
if (serializedIncluded.attributes && Object.keys(serializedIncluded.attributes).length) {
864873
// Merge relationships data if already included
865874
if (included.has(identifier)) {
866875
const alreadyIncluded = included.get(identifier);

lib/validator.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,12 @@ function validateOptions(options) {
6060
"option 'unconvertCase' must be one of 'kebab-case', 'snake_case', 'camelCase'"
6161
);
6262

63+
if (options.beforeSerialize && typeof options.beforeSerialize !== 'function')
64+
throw new Error("option 'beforeSerialize' must be function");
65+
66+
if (options.afterDeserialize && typeof options.afterDeserialize !== 'function')
67+
throw new Error("option 'afterDeserialize' must be function");
68+
6369
const { relationships } = options;
6470
Object.keys(relationships).forEach((key) => {
6571
relationships[key] = {

test/fixture/articles.data.js

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,29 @@ module.exports = [
3636
created: '2015-09-15T18:42:12.475Z',
3737
},
3838
],
39+
translations: [
40+
{
41+
id: '1',
42+
lang: 'es',
43+
title: '¡JSON API pinta la caseta de la bici!',
44+
body: 'El artículo más corto jamás escrito',
45+
created: '2015-06-07T18:01:02.123Z',
46+
},
47+
{
48+
id: '1',
49+
lang: 'pt',
50+
title: 'API JSON pinta a garagem da bicicleta!',
51+
body: 'O artículo más corto jamás escrito',
52+
created: '2015-06-07T20:01:02.123Z',
53+
},
54+
{
55+
id: '1',
56+
lang: 'de',
57+
title: 'JSON API malt meinen Fahrradschuppen!',
58+
body: 'Der kürzeste Artikel, der jemals geschrieben wurde',
59+
created: '2015-06-07T22:01:02.123Z',
60+
},
61+
],
3962
},
4063
{
4164
id: '2',
@@ -83,5 +106,28 @@ module.exports = [
83106
created: '2015-09-15T18:42:12.475Z',
84107
},
85108
],
109+
translations: [
110+
{
111+
id: '2',
112+
lang: 'es',
113+
title: 'JSON API 1.0',
114+
body: 'Especificaciones de JSON API',
115+
created: '2015-06-07T18:01:02.123Z',
116+
},
117+
{
118+
id: '2',
119+
lang: 'pt',
120+
title: 'JSON API 1.0',
121+
body: 'Especificações de JSON API',
122+
created: '2015-06-07T20:01:02.123Z',
123+
},
124+
{
125+
id: '2',
126+
lang: 'de',
127+
title: 'JSON API 1.0',
128+
body: 'JSON API Spezifikationen',
129+
created: '2015-06-07T22:01:02.123Z',
130+
},
131+
],
86132
},
87133
];

test/integration/examples.js

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,10 @@ describe('Examples', function() {
4343
comments: {
4444
type: 'comment',
4545
schema: 'only-body'
46-
}
46+
},
47+
translations: {
48+
type: 'translation'
49+
},
4750
},
4851
topLevelMeta: function(extraOptions) {
4952
return {
@@ -72,6 +75,25 @@ describe('Examples', function() {
7275
id: '_id',
7376
whitelist: ['body']
7477
});
78+
Serializer.register('translation', {
79+
beforeSerialize: (data) => {
80+
const { id: articleId, lang, ...attributes } = data;
81+
const id = `${articleId}-${lang}`;
82+
return {
83+
...attributes,
84+
id
85+
}
86+
},
87+
afterDeserialize: (data) => {
88+
const { id, ...attributes } = data;
89+
const [articleId, lang] = id.split('-');
90+
return {
91+
...attributes,
92+
id: articleId,
93+
lang,
94+
};
95+
},
96+
});
7597

7698
it('should serialize articles data', function(done) {
7799
var serializedData = Serializer.serialize('article', articlesData, {
@@ -116,7 +138,7 @@ describe('Examples', function() {
116138
expect(serializedData.data[0].links).to.have.property('self').to.eql('/articles/1');
117139
expect(serializedData.data[0].meta).to.have.property('meta').to.eql('metadata');
118140
expect(serializedData).to.have.property('included');
119-
expect(serializedData.included).to.be.instanceof(Array).to.have.lengthOf(10);
141+
expect(serializedData.included).to.be.instanceof(Array).to.have.lengthOf(16);
120142
var includedAuhor1 = _.find(serializedData.included, {
121143
'type': 'people',
122144
'id': '1'
@@ -136,6 +158,16 @@ describe('Examples', function() {
136158
expect(includedComment1).to.have.property('attributes');
137159
expect(includedComment1.attributes).to.have.property('body');
138160
expect(includedComment1.attributes).to.not.have.property('created');
161+
var includedPublishing1 = _.find(serializedData.included, {
162+
'type': 'translation',
163+
'id': '1-es'
164+
});
165+
expect(includedPublishing1).to.have.property('attributes');
166+
expect(includedPublishing1.attributes).to.have.property('title');
167+
expect(includedPublishing1.attributes).to.have.property('body');
168+
expect(includedPublishing1.attributes).to.have.property('created');
169+
expect(includedPublishing1.attributes).to.not.have.property('id');
170+
expect(includedPublishing1.attributes).to.not.have.property('lang');
139171
done();
140172
});
141173

test/unit/JSONAPISerializer.test.js

Lines changed: 60 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ describe('JSONAPISerializer', function() {
134134
const singleData = {
135135
_id: '1',
136136
};
137-
const serializedData = Serializer.serializeResource('articles', singleData, _.merge(defaultOptions, {
137+
const serializedData = Serializer.serializeResource('articles', singleData, _.merge({}, defaultOptions, {
138138
id: '_id',
139139
}));
140140
expect(serializedData).to.have.property('type').to.eql('articles');
@@ -146,13 +146,35 @@ describe('JSONAPISerializer', function() {
146146
done();
147147
});
148148

149+
it('should return serialized data with option beforeSerialize', function(done) {
150+
const singleData = {
151+
pk1: '1',
152+
pk2: '4',
153+
};
154+
const serializedData = Serializer.serializeResource('articles', singleData, _.merge({}, defaultOptions, {
155+
beforeSerialize: (data) => {
156+
const { pk1, pk2, ...attributes } = data;
157+
const id = `${pk1}-${pk2}`;
158+
return {
159+
...attributes,
160+
id
161+
};
162+
}
163+
}));
164+
expect(serializedData).to.have.property('type').to.eql('articles');
165+
expect(serializedData).to.have.property('id').to.eql('1-4');
166+
expect(serializedData.relationships).to.be.undefined;
167+
expect(serializedData.links).to.be.undefined;
168+
expect(serializedData.meta).to.be.undefined;
169+
170+
done();
171+
});
172+
149173
it('should return type of string for a non string id in input', function(done) {
150174
const singleData = {
151175
id: 1,
152176
};
153-
const serializedData = Serializer.serializeResource('articles', singleData, _.merge(defaultOptions, {
154-
id: 'id',
155-
}));
177+
const serializedData = Serializer.serializeResource('articles', singleData, defaultOptions);
156178
expect(serializedData).to.have.property('type').to.eql('articles');
157179
expect(serializedData).to.have.property('id').to.be.a('string').to.eql('1');
158180
done();
@@ -1631,6 +1653,39 @@ describe('JSONAPISerializer', function() {
16311653
done();
16321654
});
16331655

1656+
it('should return deserialized data with option afterDeserialize', function(done) {
1657+
const Serializer = new JSONAPISerializer();
1658+
Serializer.register('articles', {
1659+
afterDeserialize: (data) => {
1660+
const { id, ...attributes } = data;
1661+
const [pk1, pk2] = id.split('-');
1662+
return {
1663+
...attributes,
1664+
pk1,
1665+
pk2,
1666+
};
1667+
},
1668+
});
1669+
1670+
const data = {
1671+
data: {
1672+
type: 'article',
1673+
id: '1-2',
1674+
attributes: {
1675+
title: 'JSON API paints my bikeshed!',
1676+
body: 'The shortest article. Ever.',
1677+
created: '2015-05-22T14:56:29.000Z'
1678+
}
1679+
}
1680+
};
1681+
1682+
const deserializedData = Serializer.deserialize('articles', data);
1683+
expect(deserializedData).to.have.property('pk1').to.eql('1');
1684+
expect(deserializedData).to.have.property('pk2').to.eql('2');
1685+
expect(deserializedData).to.not.have.property('id');
1686+
done();
1687+
});
1688+
16341689
it('should deserialize with \'alternativeKey\' option and no included', function(done) {
16351690
const Serializer = new JSONAPISerializer();
16361691
Serializer.register('article', {
@@ -2057,9 +2112,7 @@ describe('JSONAPISerializer', function() {
20572112

20582113
it('should deserialize with \'links\' and \'meta\' properties', function(done) {
20592114
const Serializer = new JSONAPISerializer();
2060-
Serializer.register('articles', {
2061-
id: '_id'
2062-
});
2115+
Serializer.register('articles');
20632116

20642117
const data = {
20652118
data: {

test/unit/validator.test.js

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,26 @@ describe('validator', function () {
171171
done();
172172
});
173173

174+
it('incorrect beforeSerialize', (done) => {
175+
expect(function () {
176+
validator.validateOptions({
177+
beforeSerialize: 'test',
178+
});
179+
}).to.throw(Error, "option 'beforeSerialize' must be function");
180+
181+
done();
182+
});
183+
184+
it('incorrect afterDeserialize', (done) => {
185+
expect(function () {
186+
validator.validateOptions({
187+
afterDeserialize: 'test',
188+
});
189+
}).to.throw(Error, "option 'afterDeserialize' must be function");
190+
191+
done();
192+
});
193+
174194
it('no type provided on relationship', (done) => {
175195
expect(function () {
176196
validator.validateOptions({

0 commit comments

Comments
 (0)