From b5197174bcaacb8f163cd197d52ab9c645d21d4c Mon Sep 17 00:00:00 2001 From: Ajay Karthik Date: Wed, 24 Nov 2021 21:23:55 +0530 Subject: [PATCH] feat: Add support for 'json_name' annotation (#408) * feat: Add support for 'json_name' annotation * fix: remove snakeToCamel flag check for json_name --- README.markdown | 1 - .../google/protobuf/timestamp.ts | 206 ++++++++++++++++++ .../simple-json-name/simple-json-name-test.ts | 25 +++ integration/simple-json-name/simple.bin | Bin 0 -> 6974 bytes integration/simple-json-name/simple.proto | 11 + integration/simple-json-name/simple.ts | 118 ++++++++++ integration/simple-snake/import_dir/thing.ts | 4 +- integration/simple-snake/simple.ts | 22 +- src/main.ts | 36 +-- src/utils.ts | 19 +- 10 files changed, 412 insertions(+), 30 deletions(-) create mode 100644 integration/simple-json-name/google/protobuf/timestamp.ts create mode 100644 integration/simple-json-name/simple-json-name-test.ts create mode 100644 integration/simple-json-name/simple.bin create mode 100644 integration/simple-json-name/simple.proto create mode 100644 integration/simple-json-name/simple.ts diff --git a/README.markdown b/README.markdown index b3adc1ec4..64db0208d 100644 --- a/README.markdown +++ b/README.markdown @@ -379,7 +379,6 @@ The test suite's proto files (i.e. `simple.proto`, `batching.proto`, etc.) curre # Todo - Support the string-based encoding of duration in `fromJSON`/`toJSON` -- Support the `json_name` annotation - Make `oneof=unions` the default behavior in 2.0 - Probably change `forceLong` default in 2.0, should default to `forceLong=long` - Make `esModuleInterop=true` the default in 2.0 diff --git a/integration/simple-json-name/google/protobuf/timestamp.ts b/integration/simple-json-name/google/protobuf/timestamp.ts new file mode 100644 index 000000000..e4600a73e --- /dev/null +++ b/integration/simple-json-name/google/protobuf/timestamp.ts @@ -0,0 +1,206 @@ +/* eslint-disable */ +import { util, configure, Writer, Reader } from 'protobufjs/minimal'; +import * as Long from '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; +} + +const baseTimestamp: object = { seconds: 0, nanos: 0 }; + +export const Timestamp = { + encode(message: Timestamp, writer: Writer = Writer.create()): 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: Reader | Uint8Array, length?: number): Timestamp { + const reader = input instanceof Reader ? input : new Reader(input); + let end = length === undefined ? reader.len : reader.pos + length; + const message = { ...baseTimestamp } as Timestamp; + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: + message.seconds = longToNumber(reader.int64() as Long); + break; + case 2: + message.nanos = reader.int32(); + break; + default: + reader.skipType(tag & 7); + break; + } + } + return message; + }, + + fromJSON(object: any): Timestamp { + const message = { ...baseTimestamp } as Timestamp; + message.seconds = object.seconds !== undefined && object.seconds !== null ? Number(object.seconds) : 0; + message.nanos = object.nanos !== undefined && object.nanos !== null ? Number(object.nanos) : 0; + return message; + }, + + toJSON(message: Timestamp): unknown { + const obj: any = {}; + message.seconds !== undefined && (obj.seconds = message.seconds); + message.nanos !== undefined && (obj.nanos = message.nanos); + return obj; + }, + + fromPartial(object: DeepPartial): Timestamp { + const message = { ...baseTimestamp } as Timestamp; + message.seconds = object.seconds ?? 0; + message.nanos = object.nanos ?? 0; + return message; + }, +}; + +declare var self: any | undefined; +declare var window: any | undefined; +declare var global: any | undefined; +var globalThis: any = (() => { + if (typeof globalThis !== 'undefined') return globalThis; + if (typeof self !== 'undefined') return self; + if (typeof window !== 'undefined') return window; + if (typeof global !== 'undefined') return global; + throw 'Unable to locate global object'; +})(); + +type Builtin = Date | Function | Uint8Array | string | number | boolean | undefined; +export type DeepPartial = T extends Builtin + ? T + : T extends Array + ? Array> + : T extends ReadonlyArray + ? ReadonlyArray> + : T extends {} + ? { [K in keyof T]?: DeepPartial } + : Partial; + +function longToNumber(long: Long): number { + if (long.gt(Number.MAX_SAFE_INTEGER)) { + throw new globalThis.Error('Value is larger than Number.MAX_SAFE_INTEGER'); + } + return long.toNumber(); +} + +// If you get a compile-error about 'Constructor and ... have no overlap', +// add '--ts_proto_opt=esModuleInterop=true' as a flag when calling 'protoc'. +if (util.Long !== Long) { + util.Long = Long as any; + configure(); +} diff --git a/integration/simple-json-name/simple-json-name-test.ts b/integration/simple-json-name/simple-json-name-test.ts new file mode 100644 index 000000000..fe0ddf0dc --- /dev/null +++ b/integration/simple-json-name/simple-json-name-test.ts @@ -0,0 +1,25 @@ +import { Simple } from './simple'; + +describe('simple', () => { + it('generates json field names correctly', () => { + const simple: Simple = Simple.fromPartial({ name: 'test' }); + const convertJsonObject: any = Simple.toJSON(simple); + // Check if the generated json field name is correct + expect(Object.prototype.hasOwnProperty.call(convertJsonObject, 'other_name')).toBe(true); + + if (typeof convertJsonObject?.other_name === 'string') { + expect(convertJsonObject.other_name).toBe('test'); + } + + // Check if field names from parsed json are correct + const jsonObject = { + other_name: 'test', + other_age: 10, + createdAt: '2020-01-01T00:00:00.000Z', + }; + const simple2: Simple = Simple.fromJSON(jsonObject); + expect(simple2.name).toBe('test'); + expect(typeof simple2.age).toBe('number'); + expect(typeof simple2.createdAt).toBe(typeof new Date()); + }); +}); diff --git a/integration/simple-json-name/simple.bin b/integration/simple-json-name/simple.bin new file mode 100644 index 0000000000000000000000000000000000000000..97d15a687e61798c463dffbdfa87e18bee4c77cd GIT binary patch literal 6974 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 y$>> 3) { + case 1: + message.name = reader.string(); + break; + case 2: + message.age = reader.int32(); + break; + case 9: + message.createdAt = fromTimestamp(Timestamp.decode(reader, reader.uint32())); + break; + default: + reader.skipType(tag & 7); + break; + } + } + return message; + }, + + fromJSON(object: any): Simple { + const message = { ...baseSimple } as Simple; + message.name = object.other_name !== undefined && object.other_name !== null ? String(object.other_name) : ''; + message.age = object.other_age !== undefined && object.other_age !== null ? Number(object.other_age) : undefined; + message.createdAt = + object.createdAt !== undefined && object.createdAt !== null ? fromJsonTimestamp(object.createdAt) : undefined; + return message; + }, + + toJSON(message: Simple): unknown { + const obj: any = {}; + message.name !== undefined && (obj.other_name = message.name); + message.age !== undefined && (obj.other_age = message.age); + message.createdAt !== undefined && (obj.createdAt = message.createdAt.toISOString()); + return obj; + }, + + fromPartial(object: DeepPartial): Simple { + const message = { ...baseSimple } as Simple; + message.name = object.name ?? ''; + message.age = object.age ?? undefined; + message.createdAt = object.createdAt ?? undefined; + return message; + }, +}; + +type Builtin = Date | Function | Uint8Array | string | number | boolean | undefined; +export type DeepPartial = T extends Builtin + ? T + : T extends Array + ? Array> + : T extends ReadonlyArray + ? ReadonlyArray> + : T extends {} + ? { [K in keyof T]?: DeepPartial } + : Partial; + +function toTimestamp(date: Date): Timestamp { + const seconds = date.getTime() / 1_000; + const nanos = (date.getTime() % 1_000) * 1_000_000; + return { seconds, nanos }; +} + +function fromTimestamp(t: Timestamp): Date { + let millis = t.seconds * 1_000; + millis += t.nanos / 1_000_000; + return new Date(millis); +} + +function fromJsonTimestamp(o: any): Date { + if (o instanceof Date) { + return o; + } else if (typeof o === 'string') { + return new Date(o); + } else { + return fromTimestamp(Timestamp.fromJSON(o)); + } +} + +// If you get a compile-error about 'Constructor and ... have no overlap', +// add '--ts_proto_opt=esModuleInterop=true' as a flag when calling 'protoc'. +if (util.Long !== Long) { + util.Long = Long as any; + configure(); +} diff --git a/integration/simple-snake/import_dir/thing.ts b/integration/simple-snake/import_dir/thing.ts index e45e55d4e..3a013304a 100644 --- a/integration/simple-snake/import_dir/thing.ts +++ b/integration/simple-snake/import_dir/thing.ts @@ -40,13 +40,13 @@ export const ImportedThing = { fromJSON(object: any): ImportedThing { const message = { ...baseImportedThing } as ImportedThing; message.created_at = - object.created_at !== undefined && object.created_at !== null ? fromJsonTimestamp(object.created_at) : undefined; + object.createdAt !== undefined && object.createdAt !== null ? fromJsonTimestamp(object.createdAt) : undefined; return message; }, toJSON(message: ImportedThing): unknown { const obj: any = {}; - message.created_at !== undefined && (obj.created_at = message.created_at.toISOString()); + message.created_at !== undefined && (obj.createdAt = message.created_at.toISOString()); return obj; }, diff --git a/integration/simple-snake/simple.ts b/integration/simple-snake/simple.ts index 4ce1a42aa..ca2844a71 100644 --- a/integration/simple-snake/simple.ts +++ b/integration/simple-snake/simple.ts @@ -344,13 +344,13 @@ export const Simple = { message.name = object.name !== undefined && object.name !== null ? String(object.name) : ''; message.age = object.age !== undefined && object.age !== null ? Number(object.age) : 0; message.created_at = - object.created_at !== undefined && object.created_at !== null ? fromJsonTimestamp(object.created_at) : undefined; + object.createdAt !== undefined && object.createdAt !== null ? fromJsonTimestamp(object.createdAt) : undefined; message.child = object.child !== undefined && object.child !== null ? Child.fromJSON(object.child) : undefined; message.state = object.state !== undefined && object.state !== null ? stateEnumFromJSON(object.state) : 0; - message.grand_children = (object.grand_children ?? []).map((e: any) => Child.fromJSON(e)); + message.grand_children = (object.grandChildren ?? []).map((e: any) => Child.fromJSON(e)); message.coins = (object.coins ?? []).map((e: any) => Number(e)); message.snacks = (object.snacks ?? []).map((e: any) => String(e)); - message.old_states = (object.old_states ?? []).map((e: any) => stateEnumFromJSON(e)); + message.old_states = (object.oldStates ?? []).map((e: any) => stateEnumFromJSON(e)); message.thing = object.thing !== undefined && object.thing !== null ? ImportedThing.fromJSON(object.thing) : undefined; return message; @@ -360,13 +360,13 @@ export const Simple = { const obj: any = {}; message.name !== undefined && (obj.name = message.name); message.age !== undefined && (obj.age = message.age); - message.created_at !== undefined && (obj.created_at = message.created_at.toISOString()); + message.created_at !== undefined && (obj.createdAt = message.created_at.toISOString()); message.child !== undefined && (obj.child = message.child ? Child.toJSON(message.child) : undefined); message.state !== undefined && (obj.state = stateEnumToJSON(message.state)); if (message.grand_children) { - obj.grand_children = message.grand_children.map((e) => (e ? Child.toJSON(e) : undefined)); + obj.grandChildren = message.grand_children.map((e) => (e ? Child.toJSON(e) : undefined)); } else { - obj.grand_children = []; + obj.grandChildren = []; } if (message.coins) { obj.coins = message.coins.map((e) => e); @@ -379,9 +379,9 @@ export const Simple = { obj.snacks = []; } if (message.old_states) { - obj.old_states = message.old_states.map((e) => stateEnumToJSON(e)); + obj.oldStates = message.old_states.map((e) => stateEnumToJSON(e)); } else { - obj.old_states = []; + obj.oldStates = []; } message.thing !== undefined && (obj.thing = message.thing ? ImportedThing.toJSON(message.thing) : undefined); return obj; @@ -1174,7 +1174,7 @@ export const SimpleWithSnakeCaseMap = { fromJSON(object: any): SimpleWithSnakeCaseMap { const message = { ...baseSimpleWithSnakeCaseMap } as SimpleWithSnakeCaseMap; - message.entities_by_id = Object.entries(object.entities_by_id ?? {}).reduce<{ [key: number]: Entity }>( + message.entities_by_id = Object.entries(object.entitiesById ?? {}).reduce<{ [key: number]: Entity }>( (acc, [key, value]) => { acc[Number(key)] = Entity.fromJSON(value); return acc; @@ -1186,10 +1186,10 @@ export const SimpleWithSnakeCaseMap = { toJSON(message: SimpleWithSnakeCaseMap): unknown { const obj: any = {}; - obj.entities_by_id = {}; + obj.entitiesById = {}; if (message.entities_by_id) { Object.entries(message.entities_by_id).forEach(([k, v]) => { - obj.entities_by_id[k] = Entity.toJSON(v); + obj.entitiesById[k] = Entity.toJSON(v); }); } return obj; diff --git a/src/main.ts b/src/main.ts index e9f196a9e..25bb44d7c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -38,7 +38,13 @@ import { isStructType, } from './types'; import SourceInfo, { Fields } from './sourceInfo'; -import { assertInstanceOf, FormattedMethodDescriptor, maybeAddComment, maybePrefixPackage } from './utils'; +import { + assertInstanceOf, + determineFieldJsonName, + FormattedMethodDescriptor, + maybeAddComment, + maybePrefixPackage, +} from './utils'; import { camelToSnake, capitalize, maybeSnakeToCamel } from './case'; import { generateNestjsGrpcServiceMethodsDecorator, @@ -994,6 +1000,7 @@ function generateFromJson(ctx: Context, fullName: string, messageDesc: Descripto // add a check for each incoming field messageDesc.field.forEach((field) => { const fieldName = maybeSnakeToCamel(field.name, options); + const jsonName = determineFieldJsonName(field, options); // get a generic 'reader.doSomething' bit that is specific to the basic type const readSnippet = (from: string): Code => { @@ -1085,26 +1092,26 @@ function generateFromJson(ctx: Context, fullName: string, messageDesc: Descripto const fieldType = toTypeName(ctx, messageDesc, field); const i = maybeCastToNumber(ctx, messageDesc, field, 'key'); chunks.push(code` - message.${fieldName} = Object.entries(object.${fieldName} ?? {}).reduce<${fieldType}>((acc, [key, value]) => { + message.${fieldName} = Object.entries(object.${jsonName} ?? {}).reduce<${fieldType}>((acc, [key, value]) => { acc[${i}] = ${readSnippet('value')}; return acc; }, {}); `); } else if (isAnyValueType(field)) { chunks.push(code` - message.${fieldName} = Array.isArray(object?.${fieldName}) ? [...object.${fieldName}] : []; + message.${fieldName} = Array.isArray(object?.${jsonName}) ? [...object.${jsonName}] : []; `); } else { // Explicit `any` type required to make TS with noImplicitAny happy. `object` is also `any` here. chunks.push(code` - message.${fieldName} = (object.${fieldName} ?? []).map((e: any) => ${readSnippet('e')}); + message.${fieldName} = (object.${jsonName} ?? []).map((e: any) => ${readSnippet('e')}); `); } } else if (isWithinOneOfThatShouldBeUnion(options, field)) { - chunks.push(code`if (object.${fieldName} !== undefined && object.${fieldName} !== null) {`); + chunks.push(code`if (object.${jsonName} !== undefined && object.${jsonName} !== null) {`); const oneofName = maybeSnakeToCamel(messageDesc.oneofDecl[field.oneofIndex].name, options); chunks.push(code` - message.${oneofName} = { $case: '${fieldName}', ${fieldName}: ${readSnippet(`object.${fieldName}`)} } + message.${oneofName} = { $case: '${fieldName}', ${fieldName}: ${readSnippet(`object.${jsonName}`)} } `); chunks.push(code`}`); } else if (isAnyValueType(field)) { @@ -1122,8 +1129,8 @@ function generateFromJson(ctx: Context, fullName: string, messageDesc: Descripto } else { const fallback = isWithinOneOf(field) ? 'undefined' : defaultValue(ctx, field); chunks.push(code` - message.${fieldName} = (object.${fieldName} !== undefined && object.${fieldName} !== null) - ? ${readSnippet(`object.${fieldName}`)} + message.${fieldName} = (object.${jsonName} !== undefined && object.${jsonName} !== null) + ? ${readSnippet(`object.${jsonName}`)} : ${fallback}; `); } @@ -1147,6 +1154,7 @@ function generateToJson(ctx: Context, fullName: string, messageDesc: DescriptorP // then add a case for each field messageDesc.field.forEach((field) => { const fieldName = maybeSnakeToCamel(field.name, options); + const jsonName = determineFieldJsonName(field, options); const readSnippet = (from: string): Code => { if (isEnum(field)) { @@ -1206,10 +1214,10 @@ function generateToJson(ctx: Context, fullName: string, messageDesc: DescriptorP if (isMapType(ctx, messageDesc, field)) { // Maps might need their values transformed, i.e. bytes --> base64 chunks.push(code` - obj.${fieldName} = {}; + obj.${jsonName} = {}; if (message.${fieldName}) { Object.entries(message.${fieldName}).forEach(([k, v]) => { - obj.${fieldName}[k] = ${readSnippet('v')}; + obj.${jsonName}[k] = ${readSnippet('v')}; }); } `); @@ -1217,19 +1225,19 @@ function generateToJson(ctx: Context, fullName: string, messageDesc: DescriptorP // Arrays might need their elements transformed chunks.push(code` if (message.${fieldName}) { - obj.${fieldName} = message.${fieldName}.map(e => ${readSnippet('e')}); + obj.${jsonName} = message.${fieldName}.map(e => ${readSnippet('e')}); } else { - obj.${fieldName} = []; + obj.${jsonName} = []; } `); } else if (isWithinOneOfThatShouldBeUnion(options, field)) { // oneofs in a union are only output as `oneof name = ...` const oneofName = maybeSnakeToCamel(messageDesc.oneofDecl[field.oneofIndex].name, options); const v = readSnippet(`message.${oneofName}?.${fieldName}`); - chunks.push(code`message.${oneofName}?.$case === '${fieldName}' && (obj.${fieldName} = ${v});`); + chunks.push(code`message.${oneofName}?.$case === '${fieldName}' && (obj.${jsonName} = ${v});`); } else { const v = readSnippet(`message.${fieldName}`); - chunks.push(code`message.${fieldName} !== undefined && (obj.${fieldName} = ${v});`); + chunks.push(code`message.${fieldName} !== undefined && (obj.${jsonName} = ${v});`); } }); chunks.push(code`return obj;`); diff --git a/src/utils.ts b/src/utils.ts index 5b3f8c669..f4fdfb993 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,9 +1,15 @@ import { code, Code } from 'ts-poet'; -import { CodeGeneratorRequest, FileDescriptorProto, MethodDescriptorProto, MethodOptions } from 'ts-proto-descriptors'; +import { + CodeGeneratorRequest, + FieldDescriptorProto, + FileDescriptorProto, + MethodDescriptorProto, + MethodOptions, +} from 'ts-proto-descriptors'; import ReadStream = NodeJS.ReadStream; import { SourceDescription } from './sourceInfo'; import { Options, ServiceOption } from './options'; -import { camelCase } from './case'; +import { camelCase, maybeSnakeToCamel } from './case'; export function protoFilesToGenerate(request: CodeGeneratorRequest): FileDescriptorProto[] { return request.protoFile.filter((f) => request.fileToGenerate.includes(f.name)); @@ -167,3 +173,12 @@ export class FormattedMethodDescriptor implements MethodDescriptorProto { return result; } } + +export function determineFieldJsonName(field: FieldDescriptorProto, options: Options): string { + // By default jsonName is camelCased by the protocol compilier unless the user has + // set a "json_name" option on this field. + if (field.jsonName.length > 0) { + return field.jsonName; + } + return maybeSnakeToCamel(field.name, options); +}