From 163d34ef446369aa4ff977df14d9175078c7b7e5 Mon Sep 17 00:00:00 2001 From: Jimmy Callin Date: Tue, 14 Feb 2023 14:23:25 +0100 Subject: [PATCH 1/7] feat: support encoding non-moment date objects --- source/session.ts | 18 ++++++++---------- source/util/convert_to_iso_string.ts | 20 ++++++++++++++++++++ test/session.test.js | 2 +- 3 files changed, 29 insertions(+), 11 deletions(-) create mode 100644 source/util/convert_to_iso_string.ts diff --git a/source/session.ts b/source/session.ts index b4ab8bb6..bf1cdfba 100644 --- a/source/session.ts +++ b/source/session.ts @@ -16,6 +16,7 @@ import { SERVER_LOCATION_ID } from "./constant"; import normalizeString from "./util/normalize_string"; import { Data } from "./types"; +import { convertToISOString } from "./util/convert_to_iso_string"; const logger = loglevel.getLogger("ftrack_api"); @@ -307,9 +308,7 @@ export class Session { /** * Return encoded *data* as JSON string. * - * This will translate objects with type moment into string representation. - * If time zone support is enabled on the server the date - * will be sent as UTC, otherwise in local time. + * This will translate date, moment, and dayjs objects into ISO8601 string representation in UTC. * * @private * @param {*} data The data to encode. @@ -331,7 +330,8 @@ export class Session { return out; } - if (data && data._isAMomentObject) { + const date = convertToISOString(data); + if (date) { if ( this.serverInformation && this.serverInformation.is_timezone_support_enabled @@ -340,7 +340,7 @@ export class Session { // to timezone naive string. return { __type__: "datetime", - value: data.utc().format(ENCODE_DATETIME_FORMAT), + value: date, }; } @@ -348,7 +348,7 @@ export class Session { // to timezone naive string. return { __type__: "datetime", - value: data.local().format(ENCODE_DATETIME_FORMAT), + value: moment(date).local().format(ENCODE_DATETIME_FORMAT), }; } @@ -657,10 +657,8 @@ export class Session { if (value != null && typeof value.valueOf() === "string") { value = `"${value}"`; - } else if (value && value._isAMomentObject) { - // Server does not store microsecond or timezone currently so - // need to strip from query. - value = moment(value).utc().format(ENCODE_DATETIME_FORMAT); + } else if (convertToISOString(value)) { + value = convertToISOString(value); value = `"${value}"`; } return `${identifyingKey} is ${value}`; diff --git a/source/util/convert_to_iso_string.ts b/source/util/convert_to_iso_string.ts new file mode 100644 index 00000000..7f99a15c --- /dev/null +++ b/source/util/convert_to_iso_string.ts @@ -0,0 +1,20 @@ +function isIsoDate(str: string) { + if (!/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/.test(str)) return false; + const d = new Date(str); + return d instanceof Date && !isNaN(d.getTime()) && d.toISOString() === str; // valid date +} + +export function convertToISOString(data: string | Date) { + if ( + data && + // if this is a date object of type moment or dayjs, or regular date object (all of them has toISOString) + ((typeof data !== "string" && typeof data.toISOString === "function") || + // if it's a ISO string already + (typeof data == "string" && isIsoDate(data))) + ) { + // wrap it new Date() to convert it to UTC based ISO string in case it is in another timezone + return new Date(data).toISOString(); + } + + return null; +} diff --git a/test/session.test.js b/test/session.test.js index dcb6e445..0c4be7b4 100755 --- a/test/session.test.js +++ b/test/session.test.js @@ -523,7 +523,7 @@ describe("Session", () => { { foo: { __type__: "datetime", - value: now.format("YYYY-MM-DDTHH:mm:ss"), + value: now.toISOString(), }, bar: "baz", }, From 99c1c8e6d8c1f6adbae475c84d14c9560172d5cd Mon Sep 17 00:00:00 2001 From: Jimmy Callin Date: Sun, 19 Feb 2023 23:33:34 +0100 Subject: [PATCH 2/7] add tests for convert_to_iso_string --- package.json | 1 + source/util/convert_to_iso_string.ts | 12 ++++-- test/convert_to_iso_string.test.js | 61 ++++++++++++++++++++++++++++ yarn.lock | 8 ++++ 4 files changed, 78 insertions(+), 4 deletions(-) create mode 100644 test/convert_to_iso_string.test.js diff --git a/package.json b/package.json index 236c2aaf..0780f8e4 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "@rollup/plugin-commonjs": "^24.0.1", "@types/uuid": "^9.0.0", "cross-fetch": "^3.1.5", + "dayjs": "^1.11.7", "eslint": "^8.33.0", "eslint-config-react-app": "^7.0.1", "jsdom": "^21.1.0", diff --git a/source/util/convert_to_iso_string.ts b/source/util/convert_to_iso_string.ts index 7f99a15c..7be59940 100644 --- a/source/util/convert_to_iso_string.ts +++ b/source/util/convert_to_iso_string.ts @@ -1,7 +1,7 @@ function isIsoDate(str: string) { - if (!/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/.test(str)) return false; - const d = new Date(str); - return d instanceof Date && !isNaN(d.getTime()) && d.toISOString() === str; // valid date + return /^([+-]?\d{4}(?!\d{2}\b))((-?)((0[1-9]|1[0-2])(\3([12]\d|0[1-9]|3[01]))?|W([0-4]\d|5[0-2])(-?[1-7])?|(00[1-9]|0[1-9]\d|[12]\d{2}|3([0-5]\d|6[1-6])))([T\s]((([01]\d|2[0-3])((:?)[0-5]\d)?|24:?00)([.,]\d+(?!:))?)?(\17[0-5]\d([.,]\d+)?)?([zZ]|([+-])([01]\d|2[0-3]):?([0-5]\d)?)?)?)?$/.test( + str + ); } export function convertToISOString(data: string | Date) { @@ -13,7 +13,11 @@ export function convertToISOString(data: string | Date) { (typeof data == "string" && isIsoDate(data))) ) { // wrap it new Date() to convert it to UTC based ISO string in case it is in another timezone - return new Date(data).toISOString(); + try { + return new Date(data).toISOString(); + } catch (err) { + return null; + } } return null; diff --git a/test/convert_to_iso_string.test.js b/test/convert_to_iso_string.test.js new file mode 100644 index 00000000..8ac62f7c --- /dev/null +++ b/test/convert_to_iso_string.test.js @@ -0,0 +1,61 @@ +// :copyright: Copyright (c) 2022 ftrack + +import { convertToISOString } from "../source/util/convert_to_iso_string"; +import moment from "moment"; +import dayjs from "dayjs"; + +describe("convertToISOString", () => { + it("should convert date object to ISO", () => { + const isoDate = "2023-01-01T00:00:00.000Z"; + const date = new Date(isoDate); + const converted = convertToISOString(date); + expect(converted).toEqual(isoDate); + }); + + it("should return ISO in UTC to itself", () => { + const isoDate = "2023-01-01T00:00:00.000Z"; + const date = new Date(isoDate); + const converted = convertToISOString(date); + expect(converted).toEqual(isoDate); + }); + + it("should convert ISO string with other timezone to UTC", () => { + const tzDate = "2023-01-01T01:00:00+01:00"; + const isoDate = "2023-01-01T00:00:00.000Z"; + const converted = convertToISOString(tzDate); + expect(converted).toEqual(isoDate); + }); + + it("should convert moment objects to ISO strings in UTC", () => { + const tzDate = "2023-01-01T01:00:00+01:00"; + const isoDate = "2023-01-01T00:00:00.000Z"; + const converted = convertToISOString(moment(tzDate)); + expect(converted).toEqual(isoDate); + }); + + it("should convert dayjs objects to ISO strings", () => { + const tzDate = "2023-01-01T01:00:00+01:00"; + const isoDate = "2023-01-01T00:00:00.000Z"; + const converted = convertToISOString(dayjs(tzDate)); + expect(converted).toEqual(isoDate); + }); + + it.each([ + "hello world", + "202f", + "ffff", + "1", + "2", + "20", + 1, + -1, + 0, + null, + undefined, + new Date("hello world"), + NaN, + ])("should return null for invalid ISO string: %s", (invalidDate) => { + const converted = convertToISOString(invalidDate); + expect(converted).toEqual(null); + }); +}); diff --git a/yarn.lock b/yarn.lock index f70bccf6..6058b99a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1778,6 +1778,7 @@ __metadata: "@rollup/plugin-commonjs": ^24.0.1 "@types/uuid": ^9.0.0 cross-fetch: ^3.1.5 + dayjs: ^1.11.7 eslint: ^8.33.0 eslint-config-react-app: ^7.0.1 jsdom: ^21.1.0 @@ -3335,6 +3336,13 @@ __metadata: languageName: node linkType: hard +"dayjs@npm:^1.11.7": + version: 1.11.7 + resolution: "dayjs@npm:1.11.7" + checksum: 5003a7c1dd9ed51385beb658231c3548700b82d3548c0cfbe549d85f2d08e90e972510282b7506941452c58d32136d6362f009c77ca55381a09c704e9f177ebb + languageName: node + linkType: hard + "debug@npm:4, debug@npm:^4.1.0, debug@npm:^4.1.1, debug@npm:^4.3.2, debug@npm:^4.3.3, debug@npm:^4.3.4": version: 4.3.4 resolution: "debug@npm:4.3.4" From 4604cdc14677bfb174af477178ae1ebae76ca5a2 Mon Sep 17 00:00:00 2001 From: Jimmy Callin Date: Mon, 20 Feb 2023 00:03:42 +0100 Subject: [PATCH 3/7] added docstring --- source/util/convert_to_iso_string.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/source/util/convert_to_iso_string.ts b/source/util/convert_to_iso_string.ts index 7be59940..672e8a1e 100644 --- a/source/util/convert_to_iso_string.ts +++ b/source/util/convert_to_iso_string.ts @@ -1,9 +1,19 @@ +/** + * Checks if string is in ISO 6801 format. + */ function isIsoDate(str: string) { return /^([+-]?\d{4}(?!\d{2}\b))((-?)((0[1-9]|1[0-2])(\3([12]\d|0[1-9]|3[01]))?|W([0-4]\d|5[0-2])(-?[1-7])?|(00[1-9]|0[1-9]\d|[12]\d{2}|3([0-5]\d|6[1-6])))([T\s]((([01]\d|2[0-3])((:?)[0-5]\d)?|24:?00)([.,]\d+(?!:))?)?(\17[0-5]\d([.,]\d+)?)?([zZ]|([+-])([01]\d|2[0-3]):?([0-5]\d)?)?)?)?$/.test( str ); } +/** + * Converts a string or date object to ISO 6801 compatible string. + * Supports converting regular date objects, or any object that has toISOString() method such as moment or dayjs. + * + * @param data - string or date object + * @returns ISO 6801 compatible string, or null if invalid date + */ export function convertToISOString(data: string | Date) { if ( data && From e401e3996d04506098b808fec85cba210f399232 Mon Sep 17 00:00:00 2001 From: Jimmy Callin Date: Mon, 20 Feb 2023 12:11:18 +0100 Subject: [PATCH 4/7] add additional encoding tests --- test/session.test.js | 83 ++++++++++++++++++++++++++++++++++++-------- 1 file changed, 68 insertions(+), 15 deletions(-) diff --git a/test/session.test.js b/test/session.test.js index 0c4be7b4..87f75cf7 100755 --- a/test/session.test.js +++ b/test/session.test.js @@ -516,21 +516,6 @@ describe("Session", () => { session.apiUser = previousUser; }); - it("Should support encoding moment dates", () => { - const now = moment(); - const output = session.encode([{ foo: now, bar: "baz" }, 12321]); - expect(output).toEqual([ - { - foo: { - __type__: "datetime", - value: now.toISOString(), - }, - bar: "baz", - }, - 12321, - ]); - }); - it("Should return correct error", () => { expect( session.getErrorFromResponse({ @@ -558,3 +543,71 @@ describe("Session", () => { ).toBeInstanceOf(ServerError); }); }); + +describe("Encoding entities", () => { + it("Should support encoding moment dates", () => { + const now = moment(); + const output = session.encode([{ foo: now, bar: "baz" }, 12321]); + expect(output).toEqual([ + { + foo: { + __type__: "datetime", + value: now.toISOString(), + }, + bar: "baz", + }, + 12321, + ]); + }); + + it("Should support encoding moment dates to local timezone if timezone is disabled", () => { + const now = moment(); + server.use( + rest.post("http://ftrack.test/api", (req, res, ctx) => { + return res.once( + ctx.json({ + ...getInitialSessionQuery(), + serverInformation: { is_timezone_support_enabled: false }, + }) + ); + }) + ); + const timezoneDisabledSession = new Session( + credentials.serverUrl, + credentials.apiUser, + credentials.apiKey, + { + autoConnectEventHub: false, + } + ); + const output = timezoneDisabledSession.encode([ + { foo: now, bar: "baz" }, + 12321, + ]); + expect(output).toEqual([ + { + foo: { + __type__: "datetime", + value: now.local().format("YYYY-MM-DDTHH:mm:ss"), + }, + bar: "baz", + }, + 12321, + ]); + }); + + it("Should support encoding Date object dates", () => { + const now = new Date(); + const output = session.encode([{ foo: now, bar: "baz" }, 12321]); + expect(output).toEqual([ + { + foo: { + __type__: "datetime", + value: now.toISOString(), + }, + bar: "baz", + }, + 12321, + ]); + }); +}); From 001eb839fbb4b14722bc9c7399e951918234cdf5 Mon Sep 17 00:00:00 2001 From: Jimmy Callin Date: Mon, 20 Feb 2023 15:11:04 +0100 Subject: [PATCH 5/7] support non-moment decoding --- source/session.ts | 125 ++++++-- ...ct_name,_created_at_from_task_limit_1.json | 22 ++ test/session.test.js | 303 +++++++++++------- 3 files changed, 304 insertions(+), 146 deletions(-) create mode 100644 test/fixtures/query_select_name,_created_at_from_task_limit_1.json diff --git a/source/session.ts b/source/session.ts index bf1cdfba..9748d718 100644 --- a/source/session.ts +++ b/source/session.ts @@ -96,18 +96,20 @@ export interface ResponseError { error?: Data; } -export interface MutatationOptions { +export interface MutationOptions { pushToken?: string; additionalHeaders?: Data; + decodeDatesAsISO?: boolean; } export interface QueryOptions { abortController?: AbortController; signal?: AbortSignal; additionalHeaders?: Data; + decodeDatesAsISO?: boolean; } -export interface CallOptions extends MutatationOptions, QueryOptions {} +export interface CallOptions extends MutationOptions, QueryOptions {} /** * ftrack API session @@ -142,6 +144,7 @@ export class Session { * @param {string} [options.apiEndpoint=/api] - API endpoint. * @param {object} [options.headers] - Additional headers to send with the request * @param {object} [options.strictApi] - Turn on strict API mode + * @param {object} options.decodeDatesAsISO - Decode dates as ISO strings instead of moment objects * * @constructs Session */ @@ -398,22 +401,54 @@ export class Session { * @return {*} Decoded data */ - private decode(data: any, identityMap: Data = {}): any { + private decode( + data: any, + identityMap: Data = {}, + decodeDatesAsISO: boolean = false + ): any { if (Array.isArray(data)) { - return this._decodeArray(data, identityMap); + return this._decodeArray(data, identityMap, decodeDatesAsISO); } if (typeof data === "object" && data?.constructor === Object) { if (data.__entity_type__) { - return this._mergeEntity(data, identityMap); + return this._mergeEntity(data, identityMap, decodeDatesAsISO); } - if (data.__type__ === "datetime") { - return this._decodeDateTime(data); + if (data.__type__ === "datetime" && decodeDatesAsISO) { + return this._decodeDateTimeAsISO(data); + } else if (data.__type__ === "datetime") { + return this._decodeDateTimeAsMoment(data); } - return this._decodePlainObject(data, identityMap); + return this._decodePlainObject(data, identityMap, decodeDatesAsISO); } return data; } + /** + * Decode datetime *data* into ISO 8601 strings. + * + * Translate objects with __type__ equal to 'datetime' into moment + * datetime objects. If time zone support is enabled on the server the date + * will be assumed to be UTC and the moment will be in utc. + * @private + */ + private _decodeDateTimeAsISO(data: any) { + let dateValue = data.value; + if ( + this.serverInformation && + this.serverInformation.is_timezone_support_enabled + ) { + // Server responds with timezone naive strings, add Z to indicate UTC. + // If the string somehow already contains a timezone offset, do not add Z. + if (!dateValue.endsWith("Z") && !dateValue.includes("+")) { + dateValue += "Z"; + } + // Return date as moment object with UTC set to true. + return new Date(dateValue).toISOString(); + } + // Server has no timezone support, return date in ISO format + return new Date(dateValue).toISOString(); + } + /** * Decode datetime *data* into moment objects. * @@ -422,7 +457,7 @@ export class Session { * will be assumed to be UTC and the moment will be in utc. * @private */ - private _decodeDateTime(data: any) { + private _decodeDateTimeAsMoment(data: any) { if ( this.serverInformation && this.serverInformation.is_timezone_support_enabled @@ -439,9 +474,13 @@ export class Session { * Return new object where all values have been decoded. * @private */ - private _decodePlainObject(object: Data, identityMap: Data) { + private _decodePlainObject( + object: Data, + identityMap: Data, + decodeDatesAsISO: boolean + ) { return Object.keys(object).reduce((previous, key) => { - previous[key] = this.decode(object[key], identityMap); + previous[key] = this.decode(object[key], identityMap, decodeDatesAsISO); return previous; }, {}); } @@ -450,15 +489,25 @@ export class Session { * Return new Array where all items have been decoded. * @private */ - private _decodeArray(collection: any[], identityMap: Data): any[] { - return collection.map((item) => this.decode(item, identityMap)); + private _decodeArray( + collection: any[], + identityMap: Data, + decodeDatesAsISO: boolean + ): any[] { + return collection.map((item) => + this.decode(item, identityMap, decodeDatesAsISO) + ); } /** * Return merged *entity* using *identityMap*. * @private */ - private _mergeEntity(entity: Data, identityMap: Data) { + private _mergeEntity( + entity: Data, + identityMap: Data, + decodeDatesAsISO: boolean + ) { const identifier = this.getIdentifyingKey(entity); if (!identifier) { logger.warn("Identifier could not be determined for: ", identifier); @@ -479,7 +528,11 @@ export class Session { for (const key in entity) { if (entity.hasOwnProperty(key)) { - mergedEntity[key] = this.decode(entity[key], identityMap); + mergedEntity[key] = this.decode( + entity[key], + identityMap, + decodeDatesAsISO + ); } } return mergedEntity; @@ -511,6 +564,7 @@ export class Session { * @param {AbortSignal} options.signal - Abort signal * @param {string} options.pushToken - push token to associate with the request * @param {object} options.headers - Additional headers to send with the request + * @param {string} options.decodeDatesAsISO - Return dates as ISO strings instead of moment objects * */ call( @@ -520,6 +574,7 @@ export class Session { pushToken, signal, additionalHeaders = {}, + decodeDatesAsISO = false, }: CallOptions = {} ): Promise[]> { const url = `${this.serverUrl}${this.apiEndpoint}`; @@ -574,7 +629,7 @@ export class Session { }) .then((data) => { if (this.initialized) { - return this.decode(data); + return this.decode(data, {}, decodeDatesAsISO); } return data; @@ -732,6 +787,7 @@ export class Session { * @param {object} options.abortController - Deprecated in favour of options.signal * @param {object} options.signal - Abort signal user for aborting requests prematurely * @param {object} options.headers - Additional headers to send with the request + * @param {object} options.decodeDatesAsISO - Decode dates as ISO strings instead of moment objects * @return {Promise} Promise which will be resolved with an object * containing action, data and metadata */ @@ -759,6 +815,7 @@ export class Session { * @param {object} options.abortController - Deprecated in favour of options.signal * @param {object} options.signal - Abort signal user for aborting requests prematurely * @param {object} options.headers - Additional headers to send with the request + * @param {object} options.decodeDatesAsISO - Decode dates as ISO strings instead of moment objects * @return {Promise} Promise which will be resolved with an object * containing data and metadata */ @@ -803,17 +860,18 @@ export class Session { * @param {Object} options * @param {string} options.pushToken - push token to associate with the request * @param {object} options.headers - Additional headers to send with the request + * @param {object} options.decodeDatesAsISO - Decode dates as ISO strings instead of moment objects * @return {Promise} Promise which will be resolved with the response. */ - create(entityType: string, data: Data, { pushToken }: CallOptions = {}) { - logger.debug("Create", entityType, data, pushToken); + create(entityType: string, data: Data, options: MutationOptions = {}) { + logger.debug("Create", entityType, data, options); - let request = this.call([operation.create(entityType, data)], { - pushToken, - }).then((responses) => { - const response = responses[0]; - return response; - }); + let request = this.call([operation.create(entityType, data)], options).then( + (responses) => { + const response = responses[0]; + return response; + } + ); return request; } @@ -827,19 +885,21 @@ export class Session { * @param {Object} options * @param {string} options.pushToken - push token to associate with the request * @param {object} options.headers - Additional headers to send with the request + * @param {object} options.decodeDatesAsISO - Decode dates as ISO strings instead of moment objects * @return {Promise} Promise resolved with the response. */ update( type: string, keys: string[], data: Data, - { pushToken }: MutatationOptions = {} + options: MutationOptions = {} ) { - logger.debug("Update", type, keys, data, pushToken); + logger.debug("Update", type, keys, data, options); - const request = this.call([operation.update(type, keys, data)], { - pushToken, - }).then((responses) => { + const request = this.call( + [operation.update(type, keys, data)], + options + ).then((responses) => { const response = responses[0]; return response; }); @@ -855,12 +915,13 @@ export class Session { * @param {Object} options * @param {string} options.pushToken - push token to associate with the request * @param {object} options.headers - Additional headers to send with the request + * @param {object} options.decodeDatesAsISO - Decode dates as ISO strings instead of moment objects * @return {Promise} Promise resolved with the response. */ - delete(type: string, keys: string[], { pushToken }: MutatationOptions = {}) { - logger.debug("Delete", type, keys, pushToken); + delete(type: string, keys: string[], options: MutationOptions = {}) { + logger.debug("Delete", type, keys, options); - let request = this.call([operation.delete(type, keys)], { pushToken }).then( + let request = this.call([operation.delete(type, keys)], options).then( (responses) => { const response = responses[0]; return response; diff --git a/test/fixtures/query_select_name,_created_at_from_task_limit_1.json b/test/fixtures/query_select_name,_created_at_from_task_limit_1.json new file mode 100644 index 00000000..4c9473a2 --- /dev/null +++ b/test/fixtures/query_select_name,_created_at_from_task_limit_1.json @@ -0,0 +1,22 @@ +{ + "action": "query", + "data": [ + { + "name": "Filters via \"At a glance\" rendered with different paddings in Filters panel", + "created_at": { + "__type__": "datetime", + "value": "2022-10-10T10:12:09" + }, + "context_type": "task", + "__entity_type__": "Task", + "object_type_id": "11c137c0-ee7e-4f9c-91c5-8c77cec22b2c", + "project_id": "95f5c536-4e65-11e1-afaa-f23c91df25eb", + "id": "00a574b0-1c44-11e2-a9da-f23c91df25eb" + } + ], + "metadata": { + "next": { + "offset": 1 + } + } +} diff --git a/test/session.test.js b/test/session.test.js index 87f75cf7..de39b36f 100755 --- a/test/session.test.js +++ b/test/session.test.js @@ -1,5 +1,5 @@ // :copyright: Copyright (c) 2022 ftrack -import { beforeAll } from "vitest"; +import { beforeAll, describe } from "vitest"; import { v4 as uuidV4 } from "uuid"; import loglevel from "loglevel"; @@ -12,6 +12,8 @@ import { import { Session } from "../source/session"; import * as operation from "../source/operation"; import { expect } from "chai"; +import querySchemas from "./fixtures/query_schemas.json"; +import queryServerInformation from "./fixtures/query_server_information.json"; import { getExampleQuery, getInitialSessionQuery, server } from "./server"; import { rest } from "msw"; @@ -115,6 +117,50 @@ describe("Session", () => { return expect((await headers).get("ftrack-strict-api")).toEqual("true"); }); + it("Should allow querying with datetimes decoded as moment objects (default)", async () => { + const result = await session.query( + "select name, created_at from Task limit 1" + ); + expect(result.data[0].created_at).to.be.instanceOf(moment); + expect(result.data[0].created_at.toISOString()).toEqual( + "2022-10-10T10:12:09.000Z" + ); + }); + + it("Should allow querying with datetimes decoded as ISO objects", async () => { + const result = await session.query( + "select name, created_at from Task limit 1", + { decodeDatesAsISO: true } + ); + expect(result.data[0].created_at).toEqual("2022-10-10T10:12:09.000Z"); + }); + + it("Should allow querying with datetimes decoded as ISO objects with timezone support disabled", async () => { + server.use( + rest.post("http://ftrack.test/api", (req, res, ctx) => { + return res.once( + ctx.json([ + { ...queryServerInformation, is_timezone_support_enabled: false }, + querySchemas, + ]) + ); + }) + ); + const timezoneDisabledSession = new Session( + credentials.serverUrl, + credentials.apiUser, + credentials.apiKey, + { + autoConnectEventHub: false, + } + ); + const result = await timezoneDisabledSession.query( + "select name, created_at from Task limit 1", + { decodeDatesAsISO: true } + ); + expect(result.data[0].created_at).toEqual("2022-10-10T08:12:09.000Z"); + }); + it("Should allow adding additional headers on calls", async () => { const headers = new Promise((resolve) => { server.use( @@ -179,114 +225,6 @@ describe("Session", () => { ).resolves.toMatch(/^testName-[0-9a-f-]{36}$/); }); - it("Should support merging 0-level nested data", async () => { - await session.initializing; - const data = session.decode([ - { - id: 1, - __entity_type__: "Task", - name: "foo", - }, - { - id: 1, - __entity_type__: "Task", - }, - { - id: 2, - __entity_type__: "Task", - name: "bar", - }, - ]); - expect(data[0].name).toEqual("foo"); - expect(data[1].name).toEqual("foo"); - expect(data[2].name).toEqual("bar"); - }); - - it("Should support merging 1-level nested data", async () => { - await session.initializing; - const data = session.decode([ - { - id: 1, - __entity_type__: "Task", - name: "foo", - status: { - __entity_type__: "Status", - id: 2, - name: "In progress", - }, - }, - { - id: 2, - __entity_type__: "Task", - name: "foo", - status: { - __entity_type__: "Status", - id: 1, - name: "Done", - }, - }, - { - id: 3, - __entity_type__: "Task", - status: { - __entity_type__: "Status", - id: 1, - }, - }, - ]); - expect(data[0].status.name).toEqual("In progress"); - expect(data[1].status.name).toEqual("Done"); - expect(data[2].status.name).toEqual("Done"); - }); - - it("Should support merging 2-level nested data", async () => { - await session.initializing; - const data = session.decode([ - { - id: 1, - __entity_type__: "Task", - name: "foo", - status: { - __entity_type__: "Status", - id: 1, - state: { - __entity_type__: "State", - id: 1, - short: "DONE", - }, - }, - }, - { - id: 2, - __entity_type__: "Task", - status: { - __entity_type__: "Status", - id: 2, - state: { - __entity_type__: "State", - id: 2, - short: "NOT_STARTED", - }, - }, - }, - { - id: 3, - __entity_type__: "Task", - status: { - __entity_type__: "Status", - id: 1, - state: { - __entity_type__: "State", - id: 1, - }, - }, - }, - ]); - expect(data[0].status.state.short).toEqual("DONE"); - expect(data[1].status.state.short).toEqual("NOT_STARTED"); - expect(data[2].status.state.short).toEqual("DONE"); - }); - it("Should support api query 2-level nested data", async () => { const response = await session.query( "select status.state.short from Task where status.state.short is NOT_STARTED limit 2" @@ -560,15 +498,15 @@ describe("Encoding entities", () => { ]); }); - it("Should support encoding moment dates to local timezone if timezone is disabled", () => { + it("Should support encoding moment dates to local timezone if timezone support is disabled", () => { const now = moment(); server.use( rest.post("http://ftrack.test/api", (req, res, ctx) => { return res.once( - ctx.json({ - ...getInitialSessionQuery(), - serverInformation: { is_timezone_support_enabled: false }, - }) + ctx.json([ + { ...queryServerInformation, is_timezone_support_enabled: false }, + querySchemas, + ]) ); }) ); @@ -596,6 +534,143 @@ describe("Encoding entities", () => { ]); }); + describe("Decoding entities", () => { + it("Should support merging 0-level nested data", async () => { + await session.initializing; + const data = session.decode([ + { + id: 1, + __entity_type__: "Task", + name: "foo", + }, + { + id: 1, + __entity_type__: "Task", + }, + { + id: 2, + __entity_type__: "Task", + name: "bar", + }, + ]); + expect(data[0].name).toEqual("foo"); + expect(data[1].name).toEqual("foo"); + expect(data[2].name).toEqual("bar"); + }); + + it("Should support merging 1-level nested data", async () => { + await session.initializing; + const data = session.decode([ + { + id: 1, + __entity_type__: "Task", + name: "foo", + status: { + __entity_type__: "Status", + id: 2, + name: "In progress", + }, + }, + { + id: 2, + __entity_type__: "Task", + name: "foo", + status: { + __entity_type__: "Status", + id: 1, + name: "Done", + }, + }, + { + id: 3, + __entity_type__: "Task", + status: { + __entity_type__: "Status", + id: 1, + }, + }, + ]); + expect(data[0].status.name).toEqual("In progress"); + expect(data[1].status.name).toEqual("Done"); + expect(data[2].status.name).toEqual("Done"); + }); + + it("Should support merging 2-level nested data", async () => { + await session.initializing; + const data = session.decode([ + { + id: 1, + __entity_type__: "Task", + name: "foo", + status: { + __entity_type__: "Status", + id: 1, + state: { + __entity_type__: "State", + id: 1, + short: "DONE", + }, + }, + }, + { + id: 2, + __entity_type__: "Task", + status: { + __entity_type__: "Status", + id: 2, + state: { + __entity_type__: "State", + id: 2, + short: "NOT_STARTED", + }, + }, + }, + { + id: 3, + __entity_type__: "Task", + status: { + __entity_type__: "Status", + id: 1, + state: { + __entity_type__: "State", + id: 1, + }, + }, + }, + ]); + expect(data[0].status.state.short).toEqual("DONE"); + expect(data[1].status.state.short).toEqual("NOT_STARTED"); + expect(data[2].status.state.short).toEqual("DONE"); + }); + + it("Should support decoding datetime as moment (default)", () => { + const now = moment(); + const output = session.decode({ + foo: { + __type__: "datetime", + value: now.toISOString(), + }, + }); + expect(output.foo).toBeInstanceOf(moment); + expect(output.foo.toISOString()).toEqual(now.toISOString()); + }); + + it("Should support decoding datetime as ISO string", () => { + const now = new Date(); + const output = session.decode( + { + foo: { + __type__: "datetime", + value: now.toISOString(), + }, + }, + {}, + { decodeDatesAsISO: true } + ); + expect(output.foo).toEqual(now.toISOString()); + }); + }); + it("Should support encoding Date object dates", () => { const now = new Date(); const output = session.encode([{ foo: now, bar: "baz" }, 12321]); From 26351d45b6487fc11e5d7a700fdcaeb8d3d2812e Mon Sep 17 00:00:00 2001 From: Jimmy Callin Date: Mon, 20 Feb 2023 15:18:04 +0100 Subject: [PATCH 6/7] non-UTC timezone for tests in CI --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c39325ca..fe7b5426 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,6 +11,8 @@ on: jobs: build: runs-on: ubuntu-latest + env: + TZ: Europe/Stockholm steps: - uses: actions/checkout@v3 - name: Using Node.js from .nvmrc From 0394d8b5ec3b129e817f4ddaed1c3c136f76bf64 Mon Sep 17 00:00:00 2001 From: Jimmy Callin Date: Wed, 22 Feb 2023 11:53:20 +0100 Subject: [PATCH 7/7] consistent camel casing --- source/session.ts | 56 ++++++++++++++-------------- source/util/convert_to_iso_string.ts | 2 +- test/convert_to_iso_string.test.js | 16 ++++---- test/session.test.js | 6 +-- 4 files changed, 40 insertions(+), 40 deletions(-) diff --git a/source/session.ts b/source/session.ts index 2fec0ed2..4e2eb6a5 100644 --- a/source/session.ts +++ b/source/session.ts @@ -16,7 +16,7 @@ import { SERVER_LOCATION_ID } from "./constant"; import normalizeString from "./util/normalize_string"; import { Data } from "./types"; -import { convertToISOString } from "./util/convert_to_iso_string"; +import { convertToIsoString } from "./util/convert_to_iso_string"; const logger = loglevel.getLogger("ftrack_api"); @@ -99,14 +99,14 @@ export interface ResponseError { export interface MutationOptions { pushToken?: string; additionalHeaders?: Data; - decodeDatesAsISO?: boolean; + decodeDatesAsIso?: boolean; } export interface QueryOptions { abortController?: AbortController; signal?: AbortSignal; additionalHeaders?: Data; - decodeDatesAsISO?: boolean; + decodeDatesAsIso?: boolean; } export interface CallOptions extends MutationOptions, QueryOptions {} @@ -144,7 +144,7 @@ export class Session { * @param {string} [options.apiEndpoint=/api] - API endpoint. * @param {object} [options.headers] - Additional headers to send with the request * @param {object} [options.strictApi] - Turn on strict API mode - * @param {object} options.decodeDatesAsISO - Decode dates as ISO strings instead of moment objects + * @param {object} options.decodeDatesAsIso - Decode dates as ISO strings instead of moment objects * * @constructs Session */ @@ -333,7 +333,7 @@ export class Session { return out; } - const date = convertToISOString(data); + const date = convertToIsoString(data); if (date) { if ( this.serverInformation && @@ -404,21 +404,21 @@ export class Session { private decode( data: any, identityMap: Data = {}, - decodeDatesAsISO: boolean = false + decodeDatesAsIso: boolean = false ): any { if (Array.isArray(data)) { - return this._decodeArray(data, identityMap, decodeDatesAsISO); + return this._decodeArray(data, identityMap, decodeDatesAsIso); } if (typeof data === "object" && data?.constructor === Object) { if (data.__entity_type__) { - return this._mergeEntity(data, identityMap, decodeDatesAsISO); + return this._mergeEntity(data, identityMap, decodeDatesAsIso); } - if (data.__type__ === "datetime" && decodeDatesAsISO) { - return this._decodeDateTimeAsISO(data); + if (data.__type__ === "datetime" && decodeDatesAsIso) { + return this._decodeDateTimeAsIso(data); } else if (data.__type__ === "datetime") { return this._decodeDateTimeAsMoment(data); } - return this._decodePlainObject(data, identityMap, decodeDatesAsISO); + return this._decodePlainObject(data, identityMap, decodeDatesAsIso); } return data; } @@ -431,7 +431,7 @@ export class Session { * will be assumed to be UTC and the moment will be in utc. * @private */ - private _decodeDateTimeAsISO(data: any) { + private _decodeDateTimeAsIso(data: any) { let dateValue = data.value; if ( this.serverInformation && @@ -477,10 +477,10 @@ export class Session { private _decodePlainObject( object: Data, identityMap: Data, - decodeDatesAsISO: boolean + decodeDatesAsIso: boolean ) { return Object.keys(object).reduce((previous, key) => { - previous[key] = this.decode(object[key], identityMap, decodeDatesAsISO); + previous[key] = this.decode(object[key], identityMap, decodeDatesAsIso); return previous; }, {}); } @@ -492,10 +492,10 @@ export class Session { private _decodeArray( collection: any[], identityMap: Data, - decodeDatesAsISO: boolean + decodeDatesAsIso: boolean ): any[] { return collection.map((item) => - this.decode(item, identityMap, decodeDatesAsISO) + this.decode(item, identityMap, decodeDatesAsIso) ); } @@ -506,7 +506,7 @@ export class Session { private _mergeEntity( entity: Data, identityMap: Data, - decodeDatesAsISO: boolean + decodeDatesAsIso: boolean ) { const identifier = this.getIdentifyingKey(entity); if (!identifier) { @@ -531,7 +531,7 @@ export class Session { mergedEntity[key] = this.decode( entity[key], identityMap, - decodeDatesAsISO + decodeDatesAsIso ); } } @@ -564,7 +564,7 @@ export class Session { * @param {AbortSignal} options.signal - Abort signal * @param {string} options.pushToken - push token to associate with the request * @param {object} options.headers - Additional headers to send with the request - * @param {string} options.decodeDatesAsISO - Return dates as ISO strings instead of moment objects + * @param {string} options.decodeDatesAsIso - Return dates as ISO strings instead of moment objects * */ call( @@ -574,7 +574,7 @@ export class Session { pushToken, signal, additionalHeaders = {}, - decodeDatesAsISO = false, + decodeDatesAsIso = false, }: CallOptions = {} ): Promise[]> { const url = `${this.serverUrl}${this.apiEndpoint}`; @@ -629,7 +629,7 @@ export class Session { }) .then((data) => { if (this.initialized) { - return this.decode(data, {}, decodeDatesAsISO); + return this.decode(data, {}, decodeDatesAsIso); } return data; @@ -712,8 +712,8 @@ export class Session { if (value != null && typeof value.valueOf() === "string") { value = `"${value}"`; - } else if (convertToISOString(value)) { - value = convertToISOString(value); + } else if (convertToIsoString(value)) { + value = convertToIsoString(value); value = `"${value}"`; } return `${identifyingKey} is ${value}`; @@ -787,7 +787,7 @@ export class Session { * @param {object} options.abortController - Deprecated in favour of options.signal * @param {object} options.signal - Abort signal user for aborting requests prematurely * @param {object} options.headers - Additional headers to send with the request - * @param {object} options.decodeDatesAsISO - Decode dates as ISO strings instead of moment objects + * @param {object} options.decodeDatesAsIso - Decode dates as ISO strings instead of moment objects * @return {Promise} Promise which will be resolved with an object * containing action, data and metadata */ @@ -815,7 +815,7 @@ export class Session { * @param {object} options.abortController - Deprecated in favour of options.signal * @param {object} options.signal - Abort signal user for aborting requests prematurely * @param {object} options.headers - Additional headers to send with the request - * @param {object} options.decodeDatesAsISO - Decode dates as ISO strings instead of moment objects + * @param {object} options.decodeDatesAsIso - Decode dates as ISO strings instead of moment objects * @return {Promise} Promise which will be resolved with an object * containing data and metadata */ @@ -860,7 +860,7 @@ export class Session { * @param {Object} options * @param {string} options.pushToken - push token to associate with the request * @param {object} options.headers - Additional headers to send with the request - * @param {object} options.decodeDatesAsISO - Decode dates as ISO strings instead of moment objects + * @param {object} options.decodeDatesAsIso - Decode dates as ISO strings instead of moment objects * @return {Promise} Promise which will be resolved with the response. */ create(entityType: string, data: Data, options: MutationOptions = {}) { @@ -885,7 +885,7 @@ export class Session { * @param {Object} options * @param {string} options.pushToken - push token to associate with the request * @param {object} options.headers - Additional headers to send with the request - * @param {object} options.decodeDatesAsISO - Decode dates as ISO strings instead of moment objects + * @param {object} options.decodeDatesAsIso - Decode dates as ISO strings instead of moment objects * @return {Promise} Promise resolved with the response. */ update( @@ -915,7 +915,7 @@ export class Session { * @param {Object} options * @param {string} options.pushToken - push token to associate with the request * @param {object} options.headers - Additional headers to send with the request - * @param {object} options.decodeDatesAsISO - Decode dates as ISO strings instead of moment objects + * @param {object} options.decodeDatesAsIso - Decode dates as ISO strings instead of moment objects * @return {Promise} Promise resolved with the response. */ delete(type: string, keys: string[], options: MutationOptions = {}) { diff --git a/source/util/convert_to_iso_string.ts b/source/util/convert_to_iso_string.ts index 672e8a1e..aece1386 100644 --- a/source/util/convert_to_iso_string.ts +++ b/source/util/convert_to_iso_string.ts @@ -14,7 +14,7 @@ function isIsoDate(str: string) { * @param data - string or date object * @returns ISO 6801 compatible string, or null if invalid date */ -export function convertToISOString(data: string | Date) { +export function convertToIsoString(data: string | Date) { if ( data && // if this is a date object of type moment or dayjs, or regular date object (all of them has toISOString) diff --git a/test/convert_to_iso_string.test.js b/test/convert_to_iso_string.test.js index 8ac62f7c..2aec7fe0 100644 --- a/test/convert_to_iso_string.test.js +++ b/test/convert_to_iso_string.test.js @@ -1,42 +1,42 @@ // :copyright: Copyright (c) 2022 ftrack -import { convertToISOString } from "../source/util/convert_to_iso_string"; +import { convertToIsoString } from "../source/util/convert_to_iso_string"; import moment from "moment"; import dayjs from "dayjs"; -describe("convertToISOString", () => { +describe("convertToIsoString", () => { it("should convert date object to ISO", () => { const isoDate = "2023-01-01T00:00:00.000Z"; const date = new Date(isoDate); - const converted = convertToISOString(date); + const converted = convertToIsoString(date); expect(converted).toEqual(isoDate); }); it("should return ISO in UTC to itself", () => { const isoDate = "2023-01-01T00:00:00.000Z"; const date = new Date(isoDate); - const converted = convertToISOString(date); + const converted = convertToIsoString(date); expect(converted).toEqual(isoDate); }); it("should convert ISO string with other timezone to UTC", () => { const tzDate = "2023-01-01T01:00:00+01:00"; const isoDate = "2023-01-01T00:00:00.000Z"; - const converted = convertToISOString(tzDate); + const converted = convertToIsoString(tzDate); expect(converted).toEqual(isoDate); }); it("should convert moment objects to ISO strings in UTC", () => { const tzDate = "2023-01-01T01:00:00+01:00"; const isoDate = "2023-01-01T00:00:00.000Z"; - const converted = convertToISOString(moment(tzDate)); + const converted = convertToIsoString(moment(tzDate)); expect(converted).toEqual(isoDate); }); it("should convert dayjs objects to ISO strings", () => { const tzDate = "2023-01-01T01:00:00+01:00"; const isoDate = "2023-01-01T00:00:00.000Z"; - const converted = convertToISOString(dayjs(tzDate)); + const converted = convertToIsoString(dayjs(tzDate)); expect(converted).toEqual(isoDate); }); @@ -55,7 +55,7 @@ describe("convertToISOString", () => { new Date("hello world"), NaN, ])("should return null for invalid ISO string: %s", (invalidDate) => { - const converted = convertToISOString(invalidDate); + const converted = convertToIsoString(invalidDate); expect(converted).toEqual(null); }); }); diff --git a/test/session.test.js b/test/session.test.js index de39b36f..14794d50 100755 --- a/test/session.test.js +++ b/test/session.test.js @@ -130,7 +130,7 @@ describe("Session", () => { it("Should allow querying with datetimes decoded as ISO objects", async () => { const result = await session.query( "select name, created_at from Task limit 1", - { decodeDatesAsISO: true } + { decodeDatesAsIso: true } ); expect(result.data[0].created_at).toEqual("2022-10-10T10:12:09.000Z"); }); @@ -156,7 +156,7 @@ describe("Session", () => { ); const result = await timezoneDisabledSession.query( "select name, created_at from Task limit 1", - { decodeDatesAsISO: true } + { decodeDatesAsIso: true } ); expect(result.data[0].created_at).toEqual("2022-10-10T08:12:09.000Z"); }); @@ -665,7 +665,7 @@ describe("Encoding entities", () => { }, }, {}, - { decodeDatesAsISO: true } + { decodeDatesAsIso: true } ); expect(output.foo).toEqual(now.toISOString()); });