Skip to content
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

feat: parse monthlyUsage.dailyServiceUsages[].date as Date #519

Merged
merged 3 commits into from
Feb 23, 2024
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
5 changes: 4 additions & 1 deletion src/resource_clients/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,10 @@ export class UserClient extends ResourceClient {
};
try {
const response = await this.httpClient.call(requestOpts);
return cast(parseDateFields(pluckData(response.data)));
return cast(parseDateFields(
pluckData(response.data),
// Convert monthlyUsage.dailyServiceUsages[].date to Date (by default it's ignored by parseDateFields)
/* shouldParseField = */ (key) => key === 'date'));
} catch (err) {
catchNotFoundOrThrow(err as ApifyApiError);
}
Expand Down
28 changes: 20 additions & 8 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { Readable } from 'node:stream';
import util from 'util';
import zlib from 'zlib';

import log from '@apify/log';
import ow from 'ow';
import type { TypedArray, JsonValue } from 'type-fest';

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

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

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

if (Array.isArray(input)) return input.map((child) => parseDateFields(child, shouldParseField, depth + 1));
if (!input || typeof input !== 'object') return input;

return Object.entries(input).reduce((output, [k, v]) => {
const isValObject = !!v && typeof v === 'object';
if (k.endsWith(PARSE_DATE_FIELDS_KEY_SUFFIX)) {
if (k.endsWith('At') || (shouldParseField && shouldParseField(k))) {
if (v) {
const d = new Date(v as string);
output[k] = Number.isNaN(d.getTime()) ? v as string : d;
} else {
output[k] = v;
}
} else if (isValObject || Array.isArray(v)) {
output[k] = parseDateFields(v!, depth + 1);
output[k] = parseDateFields(v!, shouldParseField, depth + 1);
} else {
output[k] = v;
}
Expand Down
37 changes: 35 additions & 2 deletions test/utils.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -64,21 +64,54 @@ describe('utils.parseDateFields()', () => {
expectDatesDame(parsed.data.foo[1].fooAt, date);
});

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

expect(parsed.fooAt).toEqual(null);
expect(parsed.barAt).toEqual('');
});

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

expect(parsed.fooAt).toEqual('three days ago');
expect(parsed.barAt).toEqual('30+ days');
});

test('ignores perfectly fine RFC 3339 date', () => {
const original = { fooAt: 'three days ago', date: '2024-02-18T00:00:00.000Z' };
const parsed = utils.parseDateFields(original);

expect(parsed.fooAt).toEqual('three days ago');
expect(parsed.date).toEqual('2024-02-18T00:00:00.000Z');
});

test('parses custom date field detected by matcher', () => {
const original = { fooAt: 'three days ago', date: '2024-02-18T00:00:00.000Z' };

const parsed = utils.parseDateFields(original, (key) => key === 'date');

expect(parsed.fooAt).toEqual('three days ago');
expect(parsed.date).toBeInstanceOf(Date);
});

test('parses custom nested date field detected by matcher', () => {
const original = { fooAt: 'three days ago', foo: { date: '2024-02-18T00:00:00.000Z' } };

const parsed = utils.parseDateFields(original, (key) => key === 'date');

expect(parsed.foo.date).toBeInstanceOf(Date);
});

test('does not mangle non-date strings even when detected by matcher', () => {
const original = { fooAt: 'three days ago', date: '30+ days' };
const parsed = utils.parseDateFields(original, (key) => key === 'date');

expect(parsed.fooAt).toEqual('three days ago');
expect(parsed.date).toEqual('30+ days');
});
});

describe('utils.stringifyWebhooksToBase64()', () => {
Expand Down