diff --git a/README.markdown b/README.markdown index e995f6d3d..e8a1bdbe8 100644 --- a/README.markdown +++ b/README.markdown @@ -891,11 +891,14 @@ ExampleMessage.encode({ anything: true }); ## Timestamp The representation of `google.protobuf.Timestamp` is configurable by the `useDate` flag. +The `useJsonTimestamp` flag controls precision when `useDate` is `false`. | Protobuf well-known type | Default/`useDate=true` | `useDate=false` | `useDate=string` | | --------------------------- | ---------------------- | ------------------------------------ | ---------------- | | `google.protobuf.Timestamp` | `Date` | `{ seconds: number, nanos: number }` | `string` | +When using `useDate=false` and `useJsonTimestamp=raw` timestamp is represented as `{ seconds: number, nanos: number }`, but has nanosecond precision. + # Number Types Numbers are by default assumed to be plain JavaScript `number`s. diff --git a/integration/grpc-js-use-date-false/grpc-js-use-date-false.ts b/integration/grpc-js-use-date-false/grpc-js-use-date-false.ts index 441ecbc3b..6191ec2fd 100644 --- a/integration/grpc-js-use-date-false/grpc-js-use-date-false.ts +++ b/integration/grpc-js-use-date-false/grpc-js-use-date-false.ts @@ -59,7 +59,7 @@ export const TimestampMessage = { toJSON(message: TimestampMessage): unknown { const obj: any = {}; if (message.timestamp !== undefined) { - obj.timestamp = message.timestamp; + obj.timestamp = fromTimestamp(message.timestamp).toISOString(); } return obj; }, @@ -156,6 +156,12 @@ function toTimestamp(date: Date): Timestamp { return { seconds, nanos }; } +function fromTimestamp(t: Timestamp): Date { + let millis = (t.seconds || 0) * 1_000; + millis += (t.nanos || 0) / 1_000_000; + return new globalThis.Date(millis); +} + function fromJsonTimestamp(o: any): Timestamp { if (o instanceof globalThis.Date) { return toTimestamp(o); diff --git a/integration/use-date-false/metadata.ts b/integration/use-date-false/metadata.ts index 614e33d5b..fd39acde6 100644 --- a/integration/use-date-false/metadata.ts +++ b/integration/use-date-false/metadata.ts @@ -50,7 +50,7 @@ export const Metadata = { toJSON(message: Metadata): unknown { const obj: any = {}; if (message.lastEdited !== undefined) { - obj.lastEdited = message.lastEdited; + obj.lastEdited = fromTimestamp(message.lastEdited).toISOString(); } return obj; }, @@ -85,6 +85,12 @@ function toTimestamp(date: Date): Timestamp { return { seconds, nanos }; } +function fromTimestamp(t: Timestamp): Date { + let millis = (t.seconds || 0) * 1_000; + millis += (t.nanos || 0) / 1_000_000; + return new globalThis.Date(millis); +} + function fromJsonTimestamp(o: any): Timestamp { if (o instanceof globalThis.Date) { return toTimestamp(o); diff --git a/integration/use-date-false/use-date-test.ts b/integration/use-date-false/use-date-test.ts index c350b16e8..e81c22ceb 100644 --- a/integration/use-date-false/use-date-test.ts +++ b/integration/use-date-false/use-date-test.ts @@ -19,23 +19,14 @@ describe("useDate=false", () => { const json = Metadata.toJSON({ lastEdited: nov29 }); expect(json).toMatchInlineSnapshot(` { - "lastEdited": { - "nanos": 234567890, - "seconds": 123456789, - }, + "lastEdited": "1973-11-29T21:33:09.234Z", } `); expect(Metadata.fromJSON(json).lastEdited).toMatchInlineSnapshot(` { - "nanos": 234567890, - "seconds": 123456789, + "nanos": 234000000, + "seconds": 123456789.234, } `); }); - - it("doesn't lose precision in encoding/decoding", () => { - const d = Metadata.fromJSON(Metadata.toJSON({ lastEdited: nov29 })); - expect(d.lastEdited?.seconds).toStrictEqual(nov29.seconds); - expect(d.lastEdited?.nanos).toStrictEqual(nov29.nanos); - }); }); diff --git a/integration/use-json-timestamp-raw/google/protobuf/timestamp.ts b/integration/use-json-timestamp-raw/google/protobuf/timestamp.ts new file mode 100644 index 000000000..0cf501b86 --- /dev/null +++ b/integration/use-json-timestamp-raw/google/protobuf/timestamp.ts @@ -0,0 +1,214 @@ +/* eslint-disable */ +import * as _m0 from "protobufjs/minimal"; +import Long = require("long"); + +export const protobufPackage = "google.protobuf"; + +/** + * A Timestamp represents a point in time independent of any time zone or local + * calendar, encoded as a count of seconds and fractions of seconds at + * nanosecond resolution. The count is relative to an epoch at UTC midnight on + * January 1, 1970, in the proleptic Gregorian calendar which extends the + * Gregorian calendar backwards to year one. + * + * All minutes are 60 seconds long. Leap seconds are "smeared" so that no leap + * second table is needed for interpretation, using a [24-hour linear + * smear](https://developers.google.com/time/smear). + * + * The range is from 0001-01-01T00:00:00Z to 9999-12-31T23:59:59.999999999Z. By + * restricting to that range, we ensure that we can convert to and from [RFC + * 3339](https://www.ietf.org/rfc/rfc3339.txt) date strings. + * + * # Examples + * + * Example 1: Compute Timestamp from POSIX `time()`. + * + * Timestamp timestamp; + * timestamp.set_seconds(time(NULL)); + * timestamp.set_nanos(0); + * + * Example 2: Compute Timestamp from POSIX `gettimeofday()`. + * + * struct timeval tv; + * gettimeofday(&tv, NULL); + * + * Timestamp timestamp; + * timestamp.set_seconds(tv.tv_sec); + * timestamp.set_nanos(tv.tv_usec * 1000); + * + * Example 3: Compute Timestamp from Win32 `GetSystemTimeAsFileTime()`. + * + * FILETIME ft; + * GetSystemTimeAsFileTime(&ft); + * UINT64 ticks = (((UINT64)ft.dwHighDateTime) << 32) | ft.dwLowDateTime; + * + * // A Windows tick is 100 nanoseconds. Windows epoch 1601-01-01T00:00:00Z + * // is 11644473600 seconds before Unix epoch 1970-01-01T00:00:00Z. + * Timestamp timestamp; + * timestamp.set_seconds((INT64) ((ticks / 10000000) - 11644473600LL)); + * timestamp.set_nanos((INT32) ((ticks % 10000000) * 100)); + * + * Example 4: Compute Timestamp from Java `System.currentTimeMillis()`. + * + * long millis = System.currentTimeMillis(); + * + * Timestamp timestamp = Timestamp.newBuilder().setSeconds(millis / 1000) + * .setNanos((int) ((millis % 1000) * 1000000)).build(); + * + * Example 5: Compute Timestamp from Java `Instant.now()`. + * + * Instant now = Instant.now(); + * + * Timestamp timestamp = + * Timestamp.newBuilder().setSeconds(now.getEpochSecond()) + * .setNanos(now.getNano()).build(); + * + * Example 6: Compute Timestamp from current time in Python. + * + * timestamp = Timestamp() + * timestamp.GetCurrentTime() + * + * # JSON Mapping + * + * In JSON format, the Timestamp type is encoded as a string in the + * [RFC 3339](https://www.ietf.org/rfc/rfc3339.txt) format. That is, the + * format is "{year}-{month}-{day}T{hour}:{min}:{sec}[.{frac_sec}]Z" + * where {year} is always expressed using four digits while {month}, {day}, + * {hour}, {min}, and {sec} are zero-padded to two digits each. The fractional + * seconds, which can go up to 9 digits (i.e. up to 1 nanosecond resolution), + * are optional. The "Z" suffix indicates the timezone ("UTC"); the timezone + * is required. A proto3 JSON serializer should always use UTC (as indicated by + * "Z") when printing the Timestamp type and a proto3 JSON parser should be + * able to accept both UTC and other timezones (as indicated by an offset). + * + * For example, "2017-01-15T01:30:15.01Z" encodes 15.01 seconds past + * 01:30 UTC on January 15, 2017. + * + * In JavaScript, one can convert a Date object to this format using the + * standard + * [toISOString()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toISOString) + * method. In Python, a standard `datetime.datetime` object can be converted + * to this format using + * [`strftime`](https://docs.python.org/2/library/time.html#time.strftime) with + * the time format spec '%Y-%m-%dT%H:%M:%S.%fZ'. Likewise, in Java, one can use + * the Joda Time's [`ISODateTimeFormat.dateTime()`]( + * http://www.joda.org/joda-time/apidocs/org/joda/time/format/ISODateTimeFormat.html#dateTime%2D%2D + * ) to obtain a formatter capable of generating timestamps in this format. + */ +export interface Timestamp { + /** + * Represents seconds of UTC time since Unix epoch + * 1970-01-01T00:00:00Z. Must be from 0001-01-01T00:00:00Z to + * 9999-12-31T23:59:59Z inclusive. + */ + seconds: number; + /** + * Non-negative fractions of a second at nanosecond resolution. Negative + * second values with fractions must still have non-negative nanos values + * that count forward in time. Must be from 0 to 999,999,999 + * inclusive. + */ + nanos: number; +} + +function createBaseTimestamp(): Timestamp { + return { seconds: 0, nanos: 0 }; +} + +export const Timestamp = { + encode(message: Timestamp, writer: _m0.Writer = _m0.Writer.create()): _m0.Writer { + if (message.seconds !== 0) { + writer.uint32(8).int64(message.seconds); + } + if (message.nanos !== 0) { + writer.uint32(16).int32(message.nanos); + } + return writer; + }, + + decode(input: _m0.Reader | Uint8Array, length?: number): Timestamp { + const reader = input instanceof _m0.Reader ? input : _m0.Reader.create(input); + let end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseTimestamp(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: + if (tag !== 8) { + break; + } + + message.seconds = longToNumber(reader.int64() as Long); + continue; + case 2: + if (tag !== 16) { + break; + } + + message.nanos = reader.int32(); + continue; + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skipType(tag & 7); + } + return message; + }, + + fromJSON(object: any): Timestamp { + return { + seconds: isSet(object.seconds) ? globalThis.Number(object.seconds) : 0, + nanos: isSet(object.nanos) ? globalThis.Number(object.nanos) : 0, + }; + }, + + toJSON(message: Timestamp): unknown { + const obj: any = {}; + if (message.seconds !== 0) { + obj.seconds = Math.round(message.seconds); + } + if (message.nanos !== 0) { + obj.nanos = Math.round(message.nanos); + } + return obj; + }, + + create, I>>(base?: I): Timestamp { + return Timestamp.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): Timestamp { + const message = createBaseTimestamp(); + message.seconds = object.seconds ?? 0; + message.nanos = object.nanos ?? 0; + return message; + }, +}; + +type Builtin = Date | Function | Uint8Array | string | number | boolean | undefined; + +export type DeepPartial = T extends Builtin ? T + : T extends globalThis.Array ? globalThis.Array> + : T extends ReadonlyArray ? ReadonlyArray> + : T extends {} ? { [K in keyof T]?: DeepPartial } + : Partial; + +type KeysOfUnion = T extends T ? keyof T : never; +export type Exact = P extends Builtin ? P + : P & { [K in keyof P]: Exact } & { [K in Exclude>]: never }; + +function longToNumber(long: Long): number { + if (long.gt(globalThis.Number.MAX_SAFE_INTEGER)) { + throw new globalThis.Error("Value is larger than Number.MAX_SAFE_INTEGER"); + } + return long.toNumber(); +} + +if (_m0.util.Long !== Long) { + _m0.util.Long = Long as any; + _m0.configure(); +} + +function isSet(value: any): boolean { + return value !== null && value !== undefined; +} diff --git a/integration/use-json-timestamp-raw/metadata.bin b/integration/use-json-timestamp-raw/metadata.bin new file mode 100644 index 000000000..c4c8c7256 Binary files /dev/null and b/integration/use-json-timestamp-raw/metadata.bin differ diff --git a/integration/use-json-timestamp-raw/metadata.proto b/integration/use-json-timestamp-raw/metadata.proto new file mode 100644 index 000000000..96ba61b71 --- /dev/null +++ b/integration/use-json-timestamp-raw/metadata.proto @@ -0,0 +1,7 @@ +syntax = "proto3"; + +import "google/protobuf/timestamp.proto"; + +message Metadata { + google.protobuf.Timestamp last_edited = 1; +} \ No newline at end of file diff --git a/integration/use-json-timestamp-raw/metadata.ts b/integration/use-json-timestamp-raw/metadata.ts new file mode 100644 index 000000000..4fc03ae4e --- /dev/null +++ b/integration/use-json-timestamp-raw/metadata.ts @@ -0,0 +1,84 @@ +/* eslint-disable */ +import * as _m0 from "protobufjs/minimal"; +import { Timestamp } from "./google/protobuf/timestamp"; + +export const protobufPackage = ""; + +export interface Metadata { + lastEdited: Timestamp | undefined; +} + +function createBaseMetadata(): Metadata { + return { lastEdited: undefined }; +} + +export const Metadata = { + encode(message: Metadata, writer: _m0.Writer = _m0.Writer.create()): _m0.Writer { + if (message.lastEdited !== undefined) { + Timestamp.encode(message.lastEdited, writer.uint32(10).fork()).ldelim(); + } + return writer; + }, + + decode(input: _m0.Reader | Uint8Array, length?: number): Metadata { + const reader = input instanceof _m0.Reader ? input : _m0.Reader.create(input); + let end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseMetadata(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: + if (tag !== 10) { + break; + } + + message.lastEdited = Timestamp.decode(reader, reader.uint32()); + continue; + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skipType(tag & 7); + } + return message; + }, + + fromJSON(object: any): Metadata { + return { lastEdited: isSet(object.lastEdited) ? Timestamp.fromJSON(object.lastEdited) : undefined }; + }, + + toJSON(message: Metadata): unknown { + const obj: any = {}; + if (message.lastEdited !== undefined) { + obj.lastEdited = Timestamp.toJSON(message.lastEdited); + } + return obj; + }, + + create, I>>(base?: I): Metadata { + return Metadata.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): Metadata { + const message = createBaseMetadata(); + message.lastEdited = (object.lastEdited !== undefined && object.lastEdited !== null) + ? Timestamp.fromPartial(object.lastEdited) + : undefined; + return message; + }, +}; + +type Builtin = Date | Function | Uint8Array | string | number | boolean | undefined; + +export type DeepPartial = T extends Builtin ? T + : T extends globalThis.Array ? globalThis.Array> + : T extends ReadonlyArray ? ReadonlyArray> + : T extends {} ? { [K in keyof T]?: DeepPartial } + : Partial; + +type KeysOfUnion = T extends T ? keyof T : never; +export type Exact = P extends Builtin ? P + : P & { [K in keyof P]: Exact } & { [K in Exclude>]: never }; + +function isSet(value: any): boolean { + return value !== null && value !== undefined; +} diff --git a/integration/use-json-timestamp-raw/parameters.txt b/integration/use-json-timestamp-raw/parameters.txt new file mode 100644 index 000000000..6d4c0e779 --- /dev/null +++ b/integration/use-json-timestamp-raw/parameters.txt @@ -0,0 +1,2 @@ +useDate=false +useJsonTimestamp=raw diff --git a/integration/use-json-timestamp-raw/use-json-timestamp-test.ts b/integration/use-json-timestamp-raw/use-json-timestamp-test.ts new file mode 100644 index 000000000..d74e613f0 --- /dev/null +++ b/integration/use-json-timestamp-raw/use-json-timestamp-test.ts @@ -0,0 +1,41 @@ +import { Metadata } from "./metadata"; +import { Timestamp } from "./google/protobuf/timestamp"; + +const nov29: Timestamp = { seconds: 123456789, nanos: 234567890 }; + +describe("useJsonTimestamp=raw", () => { + it("can encode/decode binary", () => { + const output = Metadata.encode({ lastEdited: nov29 }).finish(); + expect(output.length).toBeGreaterThan(8); + expect(Metadata.decode(output).lastEdited).toMatchInlineSnapshot(` + { + "nanos": 234567890, + "seconds": 123456789, + } + `); + }); + + it("can encode/decode json", () => { + const json = Metadata.toJSON({ lastEdited: nov29 }); + expect(json).toMatchInlineSnapshot(` + { + "lastEdited": { + "nanos": 234567890, + "seconds": 123456789, + }, + } + `); + expect(Metadata.fromJSON(json).lastEdited).toMatchInlineSnapshot(` + { + "nanos": 234567890, + "seconds": 123456789, + } + `); + }); + + it("doesn't lose precision in encoding/decoding", () => { + const d = Metadata.fromJSON(Metadata.toJSON({ lastEdited: nov29 })); + expect(d.lastEdited?.seconds).toStrictEqual(nov29.seconds); + expect(d.lastEdited?.nanos).toStrictEqual(nov29.nanos); + }); +}); diff --git a/src/main.ts b/src/main.ts index 00f59eb73..1b5108a7f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,4 +1,5 @@ import { code, Code, conditionalOutput, def, imp, joinCode } from "ts-poet"; +import { ConditionalOutput } from "ts-poet/build/ConditionalOutput"; import { DescriptorProto, FieldDescriptorProto, @@ -6,6 +7,50 @@ import { FieldDescriptorProto_Type, FileDescriptorProto, } from "ts-proto-descriptors"; +import { camelToSnake, capitalize, maybeSnakeToCamel } from "./case"; +import { Context } from "./context"; +import { generateEnum } from "./enums"; +import { generateDecodeTransform, generateEncodeTransform } from "./generate-async-iterable"; +import { generateGenericServiceDefinition } from "./generate-generic-service-definition"; +import { generateGrpcJsService } from "./generate-grpc-js"; +import { + addGrpcWebMisc, + generateGrpcClientImpl, + generateGrpcMethodDesc, + generateGrpcServiceDesc, +} from "./generate-grpc-web"; +import { + generateNestjsGrpcServiceMethodsDecorator, + generateNestjsServiceClient, + generateNestjsServiceController, +} from "./generate-nestjs"; +import { generateNiceGrpcService } from "./generate-nice-grpc"; +import { + generateDataLoaderOptionsType, + generateDataLoadersType, + generateRpcType, + generateService, + generateServiceClientImpl, +} from "./generate-services"; +import { + generateUnwrapDeep, + generateUnwrapShallow, + generateWrapDeep, + generateWrapShallow, + isWrapperType, +} from "./generate-struct-wrappers"; +import { + addTypeToMessages, + DateOption, + EnvOption, + JsonTimestampOption, + LongOption, + OneofOption, + Options, + ServiceOption, +} from "./options"; +import { generateSchema } from "./schema"; +import SourceInfo, { Fields } from "./sourceInfo"; import { basicLongWireType, basicTypeName, @@ -42,55 +87,19 @@ import { toTypeName, valueTypeName, } from "./types"; -import SourceInfo, { Fields } from "./sourceInfo"; import { assertInstanceOf, FormattedMethodDescriptor, + getFieldJsonName, + getFieldName, getPropertyAccessor, impFile, impProto, maybeAddComment, maybePrefixPackage, - getFieldJsonName, - getFieldName, safeAccessor, } from "./utils"; -import { camelToSnake, capitalize, maybeSnakeToCamel } from "./case"; -import { - generateNestjsGrpcServiceMethodsDecorator, - generateNestjsServiceClient, - generateNestjsServiceController, -} from "./generate-nestjs"; -import { - generateDataLoaderOptionsType, - generateDataLoadersType, - generateRpcType, - generateService, - generateServiceClientImpl, -} from "./generate-services"; -import { - addGrpcWebMisc, - generateGrpcClientImpl, - generateGrpcMethodDesc, - generateGrpcServiceDesc, -} from "./generate-grpc-web"; -import { generateDecodeTransform, generateEncodeTransform } from "./generate-async-iterable"; -import { generateEnum } from "./enums"; import { visit, visitServices } from "./visit"; -import { addTypeToMessages, DateOption, EnvOption, LongOption, OneofOption, Options, ServiceOption } from "./options"; -import { Context } from "./context"; -import { generateSchema } from "./schema"; -import { ConditionalOutput } from "ts-poet/build/ConditionalOutput"; -import { generateGrpcJsService } from "./generate-grpc-js"; -import { generateGenericServiceDefinition } from "./generate-generic-service-definition"; -import { generateNiceGrpcService } from "./generate-nice-grpc"; -import { - generateUnwrapDeep, - generateUnwrapShallow, - generateWrapDeep, - generateWrapShallow, - isWrapperType, -} from "./generate-struct-wrappers"; export function generateFile(ctx: Context, fileDesc: FileDescriptorProto): [string, Code] { const { options, utils } = ctx; @@ -2052,7 +2061,10 @@ function generateToJson( } else if (isTimestamp(field) && options.useDate === DateOption.STRING) { return code`${from}`; } else if (isTimestamp(field) && options.useDate === DateOption.TIMESTAMP) { - return code`${from}`; + if (options.useJsonTimestamp === JsonTimestampOption.RAW) { + return code`${from}`; + } + return code`${utils.fromTimestamp}(${from}).toISOString()`; } else if (isMapType(ctx, messageDesc, field)) { // For map types, drill-in and then admittedly re-hard-code our per-value-type logic const valueType = (typeMap.get(field.typeName)![2] as DescriptorProto).field[1]; diff --git a/src/options.ts b/src/options.ts index 88fab496e..72a77687f 100644 --- a/src/options.ts +++ b/src/options.ts @@ -15,6 +15,11 @@ export enum DateOption { TIMESTAMP = "timestamp", } +export enum JsonTimestampOption { + RFC3339 = "rfc3339", + RAW = "raw", +} + export enum EnvOption { NODE = "node", BROWSER = "browser", @@ -42,6 +47,7 @@ export type Options = { useOptionals: boolean | "none" | "messages" | "all"; // boolean is deprecated emitDefaultValues: Array<"json-methods">; useDate: DateOption; + useJsonTimestamp: JsonTimestampOption; useMongoObjectId: boolean; oneof: OneofOption; esModuleInterop: boolean; @@ -100,6 +106,7 @@ export function defaultOptions(): Options { forceLong: LongOption.NUMBER, useOptionals: "none", useDate: DateOption.DATE, + useJsonTimestamp: JsonTimestampOption.RFC3339, useMongoObjectId: false, oneof: OneofOption.PROPERTIES, esModuleInterop: false, diff --git a/tests/options-test.ts b/tests/options-test.ts index 91df889c3..d6783dbd2 100644 --- a/tests/options-test.ts +++ b/tests/options-test.ts @@ -56,6 +56,7 @@ describe("options", () => { "useDate": "timestamp", "useExactTypes": true, "useJsonName": false, + "useJsonTimestamp": "rfc3339", "useJsonWireFormat": false, "useMapType": false, "useMongoObjectId": false,