Skip to content

Commit 279426f

Browse files
feat(operators): add JSON serialization and deserialization utilities
1 parent af0dbb8 commit 279426f

File tree

8 files changed

+219
-19
lines changed

8 files changed

+219
-19
lines changed

.vscode/extensions.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
"naumovs.color-highlight",
1616
"humao.rest-client",
1717
"techer.open-in-browser",
18-
"vitest.explorer"
19-
]
18+
"vitest.explorer",
19+
"soulcode.vscode-unwanted-extensions"
20+
],
21+
"unwantedRecommendations": []
2022
}

.vscode/settings.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,5 +48,6 @@
4848
},
4949
"window.title": "${dirty} ${activeEditorMedium}${separator}${rootName}",
5050
"workbench.settings.enableNaturalLanguageSearch": false,
51-
"task.autoDetect": "off"
51+
"task.autoDetect": "off",
52+
"terminal.integrated.scrollback": 50000
5253
}

package-lock.json

Lines changed: 30 additions & 15 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,14 @@
2525
"test:project:observables": "vitest --project packages/observables",
2626
"test:project:operators": "vitest --project packages/operators",
2727
"test:project:playground": "vitest --project packages/playground",
28+
"test:project:poc": "vitest --project packages/poc",
2829
"coverage": "vitest run --coverage"
2930
},
3031
"workspaces": [
3132
"packages/operators",
3233
"packages/observables",
33-
"packages/playground"
34+
"packages/playground",
35+
"packages/poc"
3436
],
3537
"devDependencies": {
3638
"@commitlint/config-conventional": "19.6.0",

packages/operators/src/json.js

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { concatAll, concatMap, from, map, Observable, of, toArray } from 'rxjs';
2+
3+
import { createAsyncReplacer, createSyncReplacer } from './json/replacer.js';
4+
import { createAsyncReviver, createSyncReviver } from './json/reviver.js';
5+
6+
export const serialize = (asyncTransforms, syncTransforms) => source =>
7+
source.pipe(
8+
traverse(createAsyncReplacer(asyncTransforms)),
9+
toJSONString(createSyncReplacer(syncTransforms))
10+
//
11+
);
12+
13+
export const deserialize = (asyncTransforms, syncTransforms) => source =>
14+
source.pipe(
15+
fromJSONString(createSyncReviver(syncTransforms)),
16+
traverse(createAsyncReviver(asyncTransforms))
17+
//
18+
);
19+
20+
const traverse = transforms => source =>
21+
source.pipe(
22+
concatMap(data => of(data).pipe(getOperator(data)(transforms))),
23+
transform(transforms)
24+
//
25+
);
26+
27+
const getOperator = data => traverseInstructions[data.constructor] || (() => source => source);
28+
29+
const traverseInstructions = {
30+
[Object]: transforms => source =>
31+
source.pipe(
32+
map(Object.entries),
33+
traverse(transforms),
34+
map(Object.fromEntries)
35+
//
36+
),
37+
[Array]: transforms => source =>
38+
source.pipe(
39+
concatAll(),
40+
traverse(transforms),
41+
toArray()
42+
//
43+
),
44+
[Promise]: transforms => source =>
45+
source.pipe(
46+
concatMap(value => from(value)),
47+
traverse(transforms)
48+
),
49+
[Observable]: transforms => source =>
50+
source.pipe(
51+
concatMap(value => value),
52+
traverse(transforms)
53+
)
54+
};
55+
56+
const transform = transforms => source =>
57+
source.pipe(concatMap(data => of(data).pipe(findTransform(transforms, data).handler())));
58+
59+
const toJSONString = replacer => source =>
60+
source.pipe(map(data => JSON.stringify(data, (_k, v) => findTransform(replacer, v).handler(v))));
61+
62+
const fromJSONString = reviver => source =>
63+
source.pipe(map(data => JSON.parse(data, (_k, v) => findTransform(reviver, v).handler(v))));
64+
65+
const findTransform = (transforms, value) => transforms.find(({ validator }) => validator(value));

packages/operators/src/json.test.js

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { Buffer } from 'node:buffer';
2+
import { readFile } from 'node:fs/promises';
3+
import { from, lastValueFrom, map, of } from 'rxjs';
4+
import { TestScheduler } from 'rxjs/testing';
5+
import { afterAll, beforeEach, describe, expect, test, vi } from 'vitest';
6+
7+
import { deserialize, serialize } from './json';
8+
9+
describe('log', () => {
10+
let testScheduler;
11+
12+
beforeEach(() => {
13+
testScheduler = new TestScheduler((actual, expected) => expect(actual).deep.equal(expected));
14+
});
15+
16+
afterAll(() => {
17+
vi.restoreAllMocks();
18+
});
19+
20+
test('default', async () => {
21+
const replacer = [
22+
{
23+
validator: value => value?.constructor === Buffer,
24+
handler: () => source =>
25+
source.pipe(map(buffer => `data:image/jpeg;base64,${buffer.toString('base64')}`))
26+
}
27+
];
28+
29+
const reviver = [
30+
{
31+
validator: value => value.startsWith && value.startsWith('data:image/jpeg;base64,'),
32+
handler: () => source =>
33+
source.pipe(
34+
map(value => new Blob([Buffer.from(value, 'base64')], { type: 'image/jpeg' }))
35+
)
36+
}
37+
];
38+
39+
const data = Promise.resolve({
40+
text: Promise.resolve('hello world'),
41+
bigInt: BigInt(123),
42+
date: new Date(),
43+
url: new URL('https://example.com'),
44+
image: readFile('./packages/operators/fixtures/images/test_image.jpg'),
45+
array: [
46+
Promise.resolve('hello world'),
47+
BigInt(123),
48+
new Date(),
49+
new URL('https://example.com')
50+
],
51+
nested: Promise.resolve({
52+
text: Promise.resolve('hello world'),
53+
bigInt: BigInt(123),
54+
date: new Date(),
55+
url: new URL('https://example.com'),
56+
image: from(readFile('./packages/operators/fixtures/images/test_image.jpg'))
57+
})
58+
});
59+
console.log('DATA', data);
60+
61+
const serialized = await lastValueFrom(of(data).pipe(serialize(replacer)));
62+
// console.log('SERIALIZED', serialized);
63+
64+
const deserialized = await lastValueFrom(of(serialized).pipe(deserialize(reviver)));
65+
console.log('DESERIALIZED', deserialized);
66+
});
67+
});
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { map } from 'rxjs';
2+
3+
const asyncReplacer = [
4+
{ validator: () => true, handler: () => source => source.pipe(map(value => value)) }
5+
];
6+
7+
export const syncReplacer = [
8+
{ validator: value => isURL(value), handler: value => value.toString() },
9+
{ validator: value => isDate(value), handler: value => value.toISOString() },
10+
{ validator: value => isBigInt(value), handler: value => value.toString() },
11+
{ validator: () => true, handler: value => value }
12+
];
13+
14+
export const createSyncReplacer = (transforms = []) => [...transforms, ...syncReplacer];
15+
export const createAsyncReplacer = (transforms = []) => [...transforms, ...asyncReplacer];
16+
17+
const isURL = value => value?.constructor === URL;
18+
const isDate = value => value?.constructor === Date;
19+
const isBigInt = value => value?.constructor === BigInt;
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { map } from 'rxjs';
2+
3+
const asyncReviver = [
4+
{ validator: () => true, handler: () => source => source.pipe(map(value => value)) }
5+
];
6+
7+
export const syncReviver = [
8+
{ validator: value => isValidUrl(value), handler: value => new URL(value) },
9+
{ validator: value => isValidISODateString(value), handler: value => new Date(value) },
10+
{ validator: value => isBigInt(value), handler: value => BigInt(value) },
11+
{ validator: () => true, handler: value => value }
12+
];
13+
14+
export const createSyncReviver = (transforms = []) => [...transforms, ...syncReviver];
15+
export const createAsyncReviver = (transforms = []) => [...transforms, ...asyncReviver];
16+
17+
const isValidUrl = value => {
18+
return URL.canParse(value) && /^[\w]+:\/\/\S+$/gm.test(value);
19+
};
20+
21+
function isValidISODateString(value) {
22+
if (!/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/.test(value)) return false;
23+
const d = new Date(value);
24+
return d instanceof Date && !isNaN(d.getTime()) && d.toISOString() === value; // valid date
25+
}
26+
27+
function isBigInt(value) {
28+
return value?.constructor === String && /^\d+$/.test(value);
29+
}

0 commit comments

Comments
 (0)