Skip to content

Commit 980d958

Browse files
authored
feat: parse monthlyUsage.dailyServiceUsages[].date as Date (#519)
This field does not end with "At", which means it is ignored by the existing parseDateFields helper. This PR adjusts this helper to support parsing other fields as well. To avoid accidental braking changes by applying the new parser on all existing data structures, the helper now has a new optional shouldParseField() param to identify other fields to be parsed. Without this param, the helper behaves just like before. The actual user-facing change (`typeof monthlyUsage.dailyServiceUsages[].date === Date`) was tested manually and will be covered by the integration tests in `apify-core`.
1 parent c6bffd9 commit 980d958

File tree

3 files changed

+59
-11
lines changed

3 files changed

+59
-11
lines changed

src/resource_clients/user.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,10 @@ export class UserClient extends ResourceClient {
3535
};
3636
try {
3737
const response = await this.httpClient.call(requestOpts);
38-
return cast(parseDateFields(pluckData(response.data)));
38+
return cast(parseDateFields(
39+
pluckData(response.data),
40+
// Convert monthlyUsage.dailyServiceUsages[].date to Date (by default it's ignored by parseDateFields)
41+
/* shouldParseField = */ (key) => key === 'date'));
3942
} catch (err) {
4043
catchNotFoundOrThrow(err as ApifyApiError);
4144
}

src/utils.ts

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { Readable } from 'node:stream';
22
import util from 'util';
33
import zlib from 'zlib';
44

5+
import log from '@apify/log';
56
import ow from 'ow';
67
import type { TypedArray, JsonValue } from 'type-fest';
78

@@ -12,8 +13,6 @@ import {
1213
} from './resource_clients/request_queue';
1314
import { WebhookUpdateData } from './resource_clients/webhook';
1415

15-
const PARSE_DATE_FIELDS_MAX_DEPTH = 3; // obj.data.someArrayField.[x].field
16-
const PARSE_DATE_FIELDS_KEY_SUFFIX = 'At';
1716
const NOT_FOUND_STATUS_CODE = 404;
1817
const RECORD_NOT_FOUND_TYPE = 'record-not-found';
1918
const RECORD_OR_TOKEN_NOT_FOUND_TYPE = 'record-or-token-not-found';
@@ -51,24 +50,37 @@ type ReturnJsonObject = { [Key in string]?: ReturnJsonValue; };
5150
type ReturnJsonArray = Array<ReturnJsonValue>;
5251

5352
/**
54-
* Helper function that traverses JSON structure and parses fields such as modifiedAt or createdAt to dates.
53+
* Traverses JSON structure and converts fields that end with "At" to a Date object (fields such as "modifiedAt" or
54+
* "createdAt").
55+
*
56+
* If you want parse other fields as well, you can provide a custom matcher function shouldParseField(). This
57+
* admittedly awkward approach allows this function to be reused for various purposes without introducing potential
58+
* breaking changes.
59+
*
60+
* If the field cannot be converted to Date, it is left as is.
5561
*/
56-
export function parseDateFields(input: JsonValue, depth = 0): ReturnJsonValue {
57-
if (depth > PARSE_DATE_FIELDS_MAX_DEPTH) return input as ReturnJsonValue;
58-
if (Array.isArray(input)) return input.map((child) => parseDateFields(child, depth + 1));
62+
export function parseDateFields(input: JsonValue, shouldParseField: ((key: string) => boolean) | null = null, depth = 0): ReturnJsonValue {
63+
// Don't go too deep to avoid stack overflows (especially if there is a circular reference). The depth of 3
64+
// corresponds to obj.data.someArrayField.[x].field and should be generally enough.
65+
if (depth > 3) {
66+
log.warning('parseDateFields: Maximum depth reached, not parsing further');
67+
return input as ReturnJsonValue;
68+
}
69+
70+
if (Array.isArray(input)) return input.map((child) => parseDateFields(child, shouldParseField, depth + 1));
5971
if (!input || typeof input !== 'object') return input;
6072

6173
return Object.entries(input).reduce((output, [k, v]) => {
6274
const isValObject = !!v && typeof v === 'object';
63-
if (k.endsWith(PARSE_DATE_FIELDS_KEY_SUFFIX)) {
75+
if (k.endsWith('At') || (shouldParseField && shouldParseField(k))) {
6476
if (v) {
6577
const d = new Date(v as string);
6678
output[k] = Number.isNaN(d.getTime()) ? v as string : d;
6779
} else {
6880
output[k] = v;
6981
}
7082
} else if (isValObject || Array.isArray(v)) {
71-
output[k] = parseDateFields(v!, depth + 1);
83+
output[k] = parseDateFields(v!, shouldParseField, depth + 1);
7284
} else {
7385
output[k] = v;
7486
}

test/utils.test.js

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,21 +64,54 @@ describe('utils.parseDateFields()', () => {
6464
expectDatesDame(parsed.data.foo[1].fooAt, date);
6565
});
6666

67-
test('doesn\'t parse falsy values', () => {
67+
test('does not parse falsy values', () => {
6868
const original = { fooAt: null, barAt: '' };
6969
const parsed = utils.parseDateFields(JSON.parse(JSON.stringify(original)));
7070

7171
expect(parsed.fooAt).toEqual(null);
7272
expect(parsed.barAt).toEqual('');
7373
});
7474

75-
test('doesn\'t mangle non-date strings', () => {
75+
test('does not mangle non-date strings', () => {
7676
const original = { fooAt: 'three days ago', barAt: '30+ days' };
7777
const parsed = utils.parseDateFields(original);
7878

7979
expect(parsed.fooAt).toEqual('three days ago');
8080
expect(parsed.barAt).toEqual('30+ days');
8181
});
82+
83+
test('ignores perfectly fine RFC 3339 date', () => {
84+
const original = { fooAt: 'three days ago', date: '2024-02-18T00:00:00.000Z' };
85+
const parsed = utils.parseDateFields(original);
86+
87+
expect(parsed.fooAt).toEqual('three days ago');
88+
expect(parsed.date).toEqual('2024-02-18T00:00:00.000Z');
89+
});
90+
91+
test('parses custom date field detected by matcher', () => {
92+
const original = { fooAt: 'three days ago', date: '2024-02-18T00:00:00.000Z' };
93+
94+
const parsed = utils.parseDateFields(original, (key) => key === 'date');
95+
96+
expect(parsed.fooAt).toEqual('three days ago');
97+
expect(parsed.date).toBeInstanceOf(Date);
98+
});
99+
100+
test('parses custom nested date field detected by matcher', () => {
101+
const original = { fooAt: 'three days ago', foo: { date: '2024-02-18T00:00:00.000Z' } };
102+
103+
const parsed = utils.parseDateFields(original, (key) => key === 'date');
104+
105+
expect(parsed.foo.date).toBeInstanceOf(Date);
106+
});
107+
108+
test('does not mangle non-date strings even when detected by matcher', () => {
109+
const original = { fooAt: 'three days ago', date: '30+ days' };
110+
const parsed = utils.parseDateFields(original, (key) => key === 'date');
111+
112+
expect(parsed.fooAt).toEqual('three days ago');
113+
expect(parsed.date).toEqual('30+ days');
114+
});
82115
});
83116

84117
describe('utils.stringifyWebhooksToBase64()', () => {

0 commit comments

Comments
 (0)