From 6dac5d6773479996cc109aea10758c26daa5824f Mon Sep 17 00:00:00 2001 From: bryan newbold Date: Wed, 11 Oct 2023 19:01:38 -0700 Subject: [PATCH] datetime: docs, normalize and always variant --- packages/syntax/src/datetime.ts | 71 ++++++++++++++++++++------ packages/syntax/tests/datetime.test.ts | 36 +++++++++++-- 2 files changed, 86 insertions(+), 21 deletions(-) diff --git a/packages/syntax/src/datetime.ts b/packages/syntax/src/datetime.ts index 5a11716a647..c8128c54b94 100644 --- a/packages/syntax/src/datetime.ts +++ b/packages/syntax/src/datetime.ts @@ -1,13 +1,13 @@ +/* Validates datetime string against atproto Lexicon 'datetime' format. + * Syntax is described at: https://atproto.com/specs/lexicon#datetime + */ export const ensureValidDatetime = (dtStr: string): void => { - /* - if (!isValidISODateString(dtStr)) { - throw new InvalidDatetimeError('datetime did not parse as ISO 8601') - } - */ const date = new Date(dtStr) + // must parse as ISO 8601; this also verifies semantics like month is not 13 or 00 if (isNaN(date.getTime())) { throw new InvalidDatetimeError('datetime did not parse as ISO 8601') } + // regex and other checks for RFC-3339 if ( !/^[0-9]{4}-[01][0-9]-[0-3][0-9]T[0-2][0-9]:[0-6][0-9]:[0-6][0-9](.[0-9]{1,20})?(Z|([+-][0-2][0-9]:[0-5][0-9]))$/.test( dtStr, @@ -25,31 +25,70 @@ export const ensureValidDatetime = (dtStr: string): void => { } } -// Normalize date strings to simplified ISO so that the lexical sort preserves temporal sort. -// Does *not* accept all possible strings, but will (arbitrarily) normalize no-timezone to local timezone. -export const normalizeAndEnsureValidDatetime = (dtStr: string): string => { +/* Same logic as ensureValidDatetime(), but returns a boolean instead of throwing an exception. + */ +export const isValidDatetime = (dtStr: string): boolean => { + try { + ensureValidDatetime(dtStr) + } catch (err) { + if (err instanceof InvalidDatetimeError) { + return false + } + throw err + } + + return true +} + +/* Takes a flexible datetime sting and normalizes representation. + * + * This function will work with any valid atproto datetime (eg, anything which isValidDatetime() is true for). It *additinally* is more flexible about accepting datetimes that don't comply to RFC 3339, or are missing timezone information, and normalizing them to a valid datetime. + * + * One use-case is a consistent, sortable string. Another is to work with older invalid createdAt datetimes. + * + * Successful output will be a valid atproto datetime with millisecond precision (3 sub-second digits) and UTC timezone with trailing 'Z' syntax. Throws `InvalidDatetimeError` if the input string could not be parsed as a datetime, even with permissive parsing. + * + * Expected output format: YYYY-MM-DDTHH:mm:ss.sssZ + */ +export const normalizeDatetime = (dtStr: string): string => { + if (isValidDatetime(dtStr)) { + const date = new Date(dtStr) + return date.toISOString() + } + + // check if this permissive datetime is missing a timezone + if (!/.*(([+-]\d\d:?\d\d)|[a-zA-Z])$/.test(dtStr)) { + const date = new Date(dtStr + 'Z') + if (!isNaN(date.getTime())) { + return date.toISOString() + } + } + + // finally try parsing as simple datetime const date = new Date(dtStr) if (isNaN(date.getTime())) { throw new InvalidDatetimeError( 'datetime did not parse as any timestamp format', ) } - const iso = date.toISOString() - ensureValidDatetime(iso) - return iso // YYYY-MM-DDTHH:mm:ss.sssZ + return date.toISOString() } -export const isValidDatetime = (dtStr: string): boolean => { +/* Variant of normalizeDatetime() which always returns a valid datetime strings. + * + * If a InvalidDatetimeError is encountered, returns the UNIX epoch time as a UTC datetime (1970-01-01T00:00:00.000Z). + */ +export const normalizeDatetimeAlways = (dtStr: string): string => { try { - ensureValidDatetime(dtStr) + return normalizeDatetime(dtStr) } catch (err) { if (err instanceof InvalidDatetimeError) { - return false + return new Date(0).toISOString() } throw err } - - return true } +/* Indicates a datetime string did not pass full atproto Lexicon datetime string format checks. + */ export class InvalidDatetimeError extends Error {} diff --git a/packages/syntax/tests/datetime.test.ts b/packages/syntax/tests/datetime.test.ts index 4cc9f8c0974..547a276c6a9 100644 --- a/packages/syntax/tests/datetime.test.ts +++ b/packages/syntax/tests/datetime.test.ts @@ -1,7 +1,8 @@ import { isValidDatetime, ensureValidDatetime, - normalizeAndEnsureValidDatetime, + normalizeDatetime, + normalizeDatetimeAlways, InvalidDatetimeError, } from '../src' import * as readline from 'readline' @@ -10,6 +11,8 @@ import * as fs from 'fs' describe('datetime validation', () => { const expectValid = (h: string) => { ensureValidDatetime(h) + normalizeDatetime(h) + normalizeDatetimeAlways(h) } const expectInvalid = (h: string) => { expect(() => ensureValidDatetime(h)).toThrow(InvalidDatetimeError) @@ -66,15 +69,38 @@ describe('datetime validation', () => { describe('normalization', () => { it('normalizes datetimes', () => { - const normalized = normalizeAndEnsureValidDatetime( - '1985-04-12T23:20:50.123', + expect(normalizeDatetime('1234-04-12T23:20:50Z')).toEqual( + '1234-04-12T23:20:50.000Z', + ) + expect(normalizeDatetime('1985-04-12T23:20:50Z')).toEqual( + '1985-04-12T23:20:50.000Z', + ) + expect(normalizeDatetime('1985-04-12T23:20:50.123')).toEqual( + '1985-04-12T23:20:50.123Z', + ) + expect(normalizeDatetime('1985-04-12 23:20:50.123')).toEqual( + '1985-04-12T23:20:50.123Z', + ) + expect(normalizeDatetime('1985-04-12T10:20:50.1+01:00')).toEqual( + '1985-04-12T09:20:50.100Z', + ) + expect(normalizeDatetime('Fri, 02 Jan 1999 12:34:56 GMT')).toEqual( + '1999-01-02T12:34:56.000Z', ) - expect(normalized).toMatch(/^1985-04-1[234]T[0-9:.]+Z$/) }) it('throws on invalid normalized datetimes', () => { - expect(() => normalizeAndEnsureValidDatetime('blah')).toThrow( + expect(() => normalizeDatetime('')).toThrow(InvalidDatetimeError) + expect(() => normalizeDatetime('blah')).toThrow(InvalidDatetimeError) + expect(() => normalizeDatetime('1999-19-39T23:20:50.123Z')).toThrow( InvalidDatetimeError, ) }) + + it('normalizes datetimes always', () => { + expect(normalizeDatetimeAlways('1985-04-12T23:20:50Z')).toEqual( + '1985-04-12T23:20:50.000Z', + ) + expect(normalizeDatetimeAlways('blah')).toEqual('1970-01-01T00:00:00.000Z') + }) })