diff --git a/MANUAL.md b/MANUAL.md index 15a95d23..f4122322 100644 --- a/MANUAL.md +++ b/MANUAL.md @@ -22,8 +22,9 @@ protobuf-ts manual - [google.type.DateTime and google.type.Date](#googletypedatetime-and-googletypedate) - [Reflection](#reflection) - [Field information](#field-information) - - [Custom options](#custom-options) - - [Excluding custom options](#excluding-custom-options) +- [Custom options](#custom-options) + - [Creating your own custom options](#creating-your-own-custom-options) + - [Excluding options from generated code](#excluding-options-from-generated-code) - [Binary format](#binary-format) - [Conformance](#conformance) - [Unknown field handling](#unknown-field-handling) @@ -915,12 +916,13 @@ operations. > `runtime-rpc/src/reflection-info.ts`. -#### Custom options +## Custom options -`protobuf-ts` supports custom message, field, service and method options -and will add them to the reflection information. +`protobuf-ts` supports custom options for messages, fields, services +and methods and will add them to the reflection information. -For example, consider the following service definition in .proto: +For example, consider the following service definition in +[service-annotated.proto](./packages/test-fixtures/service-annotated.proto): ```proto // import the proto that extends google.protobuf.MethodOptions @@ -928,7 +930,7 @@ import "google/api/annotations.proto"; service AnnotatedService { rpc Get (Request) returns (Reply) { - // now we can use the new options on the method + // add an option on the method option (google.api.http) = { get: "/v1/{name=messages/*}" additional_bindings { @@ -942,35 +944,68 @@ service AnnotatedService { } ``` -In TypeScript generated code, those options look very similar: +In TypeScript, the service options are available in the "options" +property as JSON: ```typescript -/** - * @generated from protobuf service spec.AnnotatedService - */ -export class AnnotatedServiceClient implements IAnnotatedServiceClient { - readonly typeName = "spec.AnnotatedService"; - readonly methods: MethodInfo[] = [{ - service: this, - name: "Get", - localName: "get", - I: AnnoGetRequest, - O: AnnoGetResponse, - options: { - // here are the options, in JSON format - "google.api.http": { - additionalBindings: [{ - get: "xxx" - }, { - get: "yyy" - }], - get: "/v1/{name=messages/*}" - } - } - }]; - // ... +import {AnnotatedService} from "./service-annotated"; +console.log(AnnotatedService.options); +``` + +```json +{ + "google.api.http": { + additionalBindings: [{ + get: "xxx" + }, { + get: "yyy" + }], + get: "/v1/{name=messages/*}" + } +} +``` + +Because the option "google.api.http" is actually a message +(see [annotations.proto](./packages/test-fixtures/google/api/annotations.proto)), +you can parse the message with this convenience method: + + +```typescript +import {AnnotatedService} from "./service-annotated"; +import {HttpRule} from "./google/api/annotations"; +import {readMethodOption} from "@protobuf-ts/runtime-rpc"; + +let rule = readMethodOption(AnnotatedService, "get", "google.api.http", HttpRule); +if (rule) { + let selector: string = rule.selector; + let bindings: HttpRule[] = rule.additionalBindings; +} +``` + +If you omit the last parameter to the function, you get the JSON value. +This is a convenient way to get scalar option values. + +```typescript +import {AnnotatedService} from "./service-annotated"; +import {readMethodOption} from "@protobuf-ts/runtime-rpc"; +import {JsonValue} from "@protobuf-ts/runtime"; + +let rule: JsonValue | undefined = readMethodOption(AnnotatedService, "get", "google.api.http"); ``` + + +| Options for | stored in | access with | +|-------------|---------------------------------------|-----------------------------------------------------| +| Messages | `AnnotatedMessage.options` | `readMessageOption()` from @protobuf-ts/runtime | +| Fields | `AnnotatedMessage.field[0].options` | `readFieldOption()` from @protobuf-ts/runtime | +| Services | `AnnotatedService.options` | `readServiceOption()` from @protobuf-ts/runtime-rpc | +| Methods | `AnnotatedService.methods[0].options` | `readMethodOption()` from @protobuf-ts/runtime-rpc | + + + +#### Creating your own custom options + It is very easy to create custom options. This is the source code for the "google.api.http" option: @@ -990,45 +1025,10 @@ As you can see, the option is a standard protobuf field. It can be a message field like in the example above, or it can be a scalar, enum or repeated field. In .proto, you set options in [text format](https://stackoverflow.com/questions/18873924/what-does-the-protobuf-text-format-look-like/18877167), -and `protobuf-ts` provides them in the canonical JSON format. This means that -you can parse message option fields using the appropriate message type: - - -```typescript -let client: AnnotatedServiceClient = ... - -let opt = client.methods[0].options; - -if (opt && "google.api.http" in opt) { - let rule = HttpRule.fromJson(opt["google.api.http"]); - if (rule) { - // now we have successfully read a - // google.api.HttpRule from the option - let selector: string = rule.selector; - let bindings: HttpRule[] = rule.additionalBindings; - } -} -``` - -To save you some typing, there is a convenience method available: - -```typescript -import {readMethodOptions} from "@protobuf-ts/runtime-rpc"; - -let rule = readMethodOptions(client, "get", "google.api.http", HttpRule); -if (rule) { - let selector: string = rule.selector; - let bindings: HttpRule[] = rule.additionalBindings; -} -``` - -Field options work the same way. In .proto, you create an extension for -`google.protobuf.FieldOptions`, and in TypeScript, you can read -`IMessageType.fields[].options`. If it is a message field, use -`readFieldOptions()` from `@protobuf-ts/runtime`. +and `protobuf-ts` provides them in the canonical JSON format. -#### Excluding custom options +#### Excluding options from generated code If you need custom options for some protobuf implementation, but do not want to have them included in the TypeScript generated code, use the file diff --git a/packages/runtime-rpc/src/index.ts b/packages/runtime-rpc/src/index.ts index 72a672d3..eb67385c 100644 --- a/packages/runtime-rpc/src/index.ts +++ b/packages/runtime-rpc/src/index.ts @@ -4,7 +4,7 @@ export {ServiceType} from './service-type'; -export {MethodInfo, PartialMethodInfo, ServiceInfo, readMethodOptions} from './reflection-info'; +export {MethodInfo, PartialMethodInfo, ServiceInfo, readMethodOptions, readMethodOption, readServiceOption} from './reflection-info'; export {RpcError} from './rpc-error'; export {RpcMetadata} from './rpc-metadata'; export {RpcOptions, mergeRpcOptions} from './rpc-options'; diff --git a/packages/runtime-rpc/src/reflection-info.ts b/packages/runtime-rpc/src/reflection-info.ts index 13c7d5a2..1b5cdd0f 100644 --- a/packages/runtime-rpc/src/reflection-info.ts +++ b/packages/runtime-rpc/src/reflection-info.ts @@ -118,7 +118,6 @@ export type PartialMethodInfo = type PartialPartial = Partial> & Omit; - /** * Turns PartialMethodInfo into MethodInfo. */ @@ -138,10 +137,66 @@ export function normalizeMethodInfo(service: ServiceInfo, methodName: string | number, extensionName: string, extensionType: IMessageType): T | undefined { - let info = service.methods.find((m, i) => m.localName === methodName || i === methodName); - return info && info.options && info.options[extensionName] - ? extensionType.fromJson(info.options[extensionName]) - : undefined; + const options = service.methods.find((m, i) => m.localName === methodName || i === methodName)?.options; + return options && options[extensionName] ? extensionType.fromJson(options[extensionName]) : undefined; +} + +/** + * Read a custom method option. + * + * ```proto + * service MyService { + * rpc Get (Req) returns (Res) { + * option (acme.rpc_opt) = true; + * }; + * } + * ``` + * + * ```typescript + * let val = readMethodOption(MyService, 'get', 'acme.rpc_opt') + * ``` + */ +export function readMethodOption(service: ServiceInfo, methodName: string | number, extensionName: string): JsonValue | undefined; +export function readMethodOption(service: ServiceInfo, methodName: string | number, extensionName: string, extensionType: IMessageType): T | undefined; +export function readMethodOption(service: ServiceInfo, methodName: string | number, extensionName: string, extensionType?: IMessageType): T | JsonValue | undefined { + const options = service.methods.find((m, i) => m.localName === methodName || i === methodName)?.options; + if (!options) { + return undefined; + } + const optionVal = options[extensionName]; + if (optionVal === undefined) { + return optionVal; + } + return extensionType ? extensionType.fromJson(optionVal) : optionVal; +} + +/** + * Read a custom service option. + * + * ```proto + * service MyService { + * option (acme.service_opt) = true; + * } + * ``` + * + * ```typescript + * let val = readServiceOption(MyService, 'acme.service_opt') + * ``` + */ +export function readServiceOption(service: ServiceInfo, extensionName: string): JsonValue | undefined; +export function readServiceOption(service: ServiceInfo, extensionName: string, extensionType: IMessageType): T | undefined; +export function readServiceOption(service: ServiceInfo, extensionName: string, extensionType?: IMessageType): T | JsonValue | undefined { + const options = service.options; + if (!options) { + return undefined; + } + const optionVal = options[extensionName]; + if (optionVal === undefined) { + return optionVal; + } + return extensionType ? extensionType.fromJson(optionVal) : optionVal; } diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 26a6e478..1f1bab4f 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -54,7 +54,9 @@ export { FieldInfo, PartialFieldInfo, normalizeFieldInfo, - readFieldOptions + readFieldOptions, + readFieldOption, + readMessageOption } from './reflection-info'; // Types for messsage objects type at runtime, when concrete type is unknown. diff --git a/packages/runtime/src/reflection-info.ts b/packages/runtime/src/reflection-info.ts index c182560d..3f7c8769 100644 --- a/packages/runtime/src/reflection-info.ts +++ b/packages/runtime/src/reflection-info.ts @@ -435,10 +435,62 @@ export function normalizeFieldInfo(field: PartialFieldInfo): FieldInfo { /** * Read custom field options from a generated message type. + * + * @deprecated use readFieldOption() + */ +export function readFieldOptions(messageType: MessageInfo, fieldName: string | number, extensionName: string, extensionType: IMessageType): T | undefined { + const options = messageType.fields.find((m, i) => m.localName == fieldName || i == fieldName)?.options; + return options && options[extensionName] ? extensionType.fromJson(options[extensionName]) : undefined; +} + + +/** + * Read a custom field option. + * + * ```proto + * message MyMessage { + * int32 my_field = 1 [(acme.field_opt) = true]; + * } + * ``` + * + * ```typescript + * let val = readFieldOption(MyMessage, 'myField', 'acme.field_opt') + * ``` + */ +export function readFieldOption(messageType: MessageInfo, fieldName: string | number, extensionName: string): JsonValue | undefined; +export function readFieldOption(messageType: MessageInfo, fieldName: string | number, extensionName: string, extensionType: IMessageType): T | undefined; +export function readFieldOption(messageType: MessageInfo, fieldName: string | number, extensionName: string, extensionType?: IMessageType): T | JsonValue | undefined { + const options = messageType.fields.find((m, i) => m.localName == fieldName || i == fieldName)?.options; + if (!options) { + return undefined; + } + const optionVal = options[extensionName]; + if (optionVal === undefined) { + return optionVal; + } + return extensionType ? extensionType.fromJson(optionVal) : optionVal; +} + +/** + * Read a custom message option. + * + * ```proto + * message MyMessage { + * option acme.message_opt = true; + * } + * ``` + * + * ```typescript + * let val = readMessageOption(MyMessage, 'acme.message_opt') + * ``` */ -export function readFieldOptions(messageType: MessageInfo | IMessageType, fieldName: string | number, extensionName: string, extensionType: IMessageType): T | undefined { - let info = messageType.fields.find((m, i) => m.localName == fieldName || i == fieldName); - return info && info.options && info.options[extensionName] - ? extensionType.fromJson(info.options[extensionName]) - : undefined; +export function readMessageOption(messageType: MessageInfo, extensionName: string): JsonValue | undefined; +export function readMessageOption(messageType: MessageInfo, extensionName: string, extensionType: IMessageType): T | undefined +export function readMessageOption(messageType: MessageInfo, extensionName: string, extensionType?: IMessageType): T | JsonValue | undefined { + const options = messageType.options; + const optionVal = options[extensionName]; + if (optionVal === undefined) { + return optionVal; + } + return extensionType ? extensionType.fromJson(optionVal) : optionVal; } diff --git a/packages/test-generated/spec/msg-annotated.spec.ts b/packages/test-generated/spec/msg-annotated.spec.ts index 706f1de4..1e4fc9ee 100644 --- a/packages/test-generated/spec/msg-annotated.spec.ts +++ b/packages/test-generated/spec/msg-annotated.spec.ts @@ -1,11 +1,52 @@ import {AnnotatedMessage, FieldUiBehaviour} from "../ts-out/msg-annotated"; -import {readFieldOptions} from "@protobuf-ts/runtime"; +import {readFieldOption, readMessageOption} from "@protobuf-ts/runtime"; +describe('readFieldOption', function () { + it('should read scalar opt', function () { + let act = readFieldOption(AnnotatedMessage, "annScalar", "spec.opt_string"); + expect(act).toBe('my string'); + }); + it('should read message opt', function () { + let act = readFieldOption(AnnotatedMessage, "userName", "spec.field_ui"); + expect(act).toEqual({ + label: "User name", + required: true, + autocomplete: { + serviceName: "example.SomeService", + methodName: "autocompleteUsername", + requestFieldName: "entered_text" + } + }); + }); + it('should read message opt with type', function () { + let act = readFieldOption(AnnotatedMessage, "userName", "spec.field_ui", FieldUiBehaviour); + expect(act).toEqual({ + label: "User name", + required: true, + autocomplete: { + serviceName: "example.SomeService", + methodName: "autocompleteUsername", + requestFieldName: "entered_text" + } + }); + }); +}); + +describe('readMessageOption', function () { + it('should read scalar opt', function () { + let act = readMessageOption(AnnotatedMessage, "spec.opt_example"); + expect(act).toBe(true); + }); +}); + describe('spec.AnnotatedMessage', function () { - it ('should have message options', function() { - expect(AnnotatedMessage.options).toEqual({ "spec.opt_example": true }); + it('should have message option "spec.opt_example"', function () { + expect(AnnotatedMessage.options).toBeDefined(); + if (AnnotatedMessage.options) { + expect(AnnotatedMessage.options["spec.opt_example"]).toBeTrue(); + } }); it('field ann_scalar should have scalar options', function () { @@ -88,7 +129,7 @@ describe('spec.AnnotatedMessage', function () { requestFieldName: "entered_text" } }; - let act = readFieldOptions(AnnotatedMessage, "userName", "spec.field_ui", FieldUiBehaviour); + let act = AnnotatedMessage.fields.find(fi => fi.name === 'user_name')?.options?.['spec.field_ui']; expect(act).toEqual(exp); }); diff --git a/packages/test-generated/spec/service-annotated.spec.ts b/packages/test-generated/spec/service-annotated.spec.ts index f0b93e0b..da5d6184 100644 --- a/packages/test-generated/spec/service-annotated.spec.ts +++ b/packages/test-generated/spec/service-annotated.spec.ts @@ -1,13 +1,43 @@ import {assert} from "@protobuf-ts/runtime"; import {AnnotatedService} from "../ts-out/service-annotated"; import type {RpcTransport} from "@protobuf-ts/runtime-rpc"; +import {readServiceOption} from "@protobuf-ts/runtime-rpc"; import {HttpRule} from "../ts-out/google/api/http"; import {AnnotatedServiceClient} from "../ts-out/service-annotated.client"; +import {readMethodOption} from "@protobuf-ts/runtime-rpc/"; +describe('readMethodOption', function () { + it('should read scalar opt', function () { + let act = readMethodOption(AnnotatedService, "get", "spec.rpc_bar"); + expect(act).toBe('hello'); + }); +}); + +describe('readServiceOption', function () { + it('should read scalar opt', function () { + let act = readServiceOption(AnnotatedService, "spec.service_foo"); + expect(act).toBe(true); + }); +}); + describe('spec.AnnotatedService', function () { + describe('example in MANUAL.md', function () { + it('should work for method option', function () { + let rule = readMethodOption(AnnotatedService, "get", "google.api.http", HttpRule); + expect(rule).toBeDefined(); + if (rule) { + let selector: string = rule.selector; + let bindings: HttpRule[] = rule.additionalBindings; + expect(selector).toBeDefined(); + expect(bindings).toBeDefined(); + } + }); + }); + + describe('ServiceType', function () { it('should have "Get" method', function () {