diff --git a/packages/protobuf-test/src/generate-code.test.ts b/packages/protobuf-test/src/generate-code.test.ts index 975970b5a..8c12acf2b 100644 --- a/packages/protobuf-test/src/generate-code.test.ts +++ b/packages/protobuf-test/src/generate-code.test.ts @@ -14,6 +14,7 @@ import { describe, test, expect } from "@jest/globals"; import type { Int32Value, StringValue } from "@bufbuild/protobuf/wkt"; +import { hasExtension } from "@bufbuild/protobuf"; import type { GenDescService } from "@bufbuild/protobuf/codegenv1"; import * as proto2_ts from "./gen/ts/extra/proto2_pb.js"; import * as proto2_js from "./gen/js/extra/proto2_pb.js"; @@ -31,151 +32,62 @@ import * as test_messages_proto3_ts from "./gen/ts/google/protobuf/test_messages import * as test_messages_proto3_js from "./gen/js/google/protobuf/test_messages_proto3_pb.js"; import * as options_ts from "./gen/ts/extra/options_pb.js"; import * as option_usage_ts from "./gen/ts/extra/option-usage_pb.js"; -import assert from "node:assert"; -import { getExtension, hasExtension } from "@bufbuild/protobuf"; -describe("custom options from generated code", () => { - test("can be read via extensions", () => { - const fileOptions = - option_usage_ts.fileDesc_extra_option_usage.proto.options; - assert(fileOptions); - expect( - getExtension(fileOptions, options_ts.file_option_retention_unknown), - ).toBe("file option retention unknown"); - expect( - getExtension(fileOptions, options_ts.file_option_retention_runtime), - ).toBe("file option retention runtime"); - - const messageOptions = option_usage_ts.MessageWithOptionsDesc.proto.options; - assert(messageOptions); - expect( - getExtension(messageOptions, options_ts.message_option_retention_unknown), - ).toBe("message option retention unknown"); - expect( - getExtension(messageOptions, options_ts.message_option_retention_runtime), - ).toBe("message option retention runtime"); - - const fieldOptions = - option_usage_ts.MessageWithOptionsDesc.fields[0].proto.options; - assert(fieldOptions); - expect( - getExtension(fieldOptions, options_ts.field_option_retention_unknown), - ).toBe("field option retention unknown"); - expect( - getExtension(fieldOptions, options_ts.field_option_retention_runtime), - ).toBe("field option retention runtime"); - - const oneofOptions = - option_usage_ts.MessageWithOptionsDesc.oneofs[0].proto.options; - assert(oneofOptions); - expect( - getExtension(oneofOptions, options_ts.oneof_option_retention_unknown), - ).toBe("oneof option retention unknown"); - expect( - getExtension(oneofOptions, options_ts.oneof_option_retention_runtime), - ).toBe("oneof option retention runtime"); - - const enumOptions = option_usage_ts.EnumWithOptionsDesc.proto.options; - assert(enumOptions); - expect( - getExtension(enumOptions, options_ts.enum_option_retention_unknown), - ).toBe("enum option retention unknown"); - expect( - getExtension(enumOptions, options_ts.enum_option_retention_runtime), - ).toBe("enum option retention runtime"); - - const enumValueOptions = - option_usage_ts.EnumWithOptionsDesc.values[0].proto.options; - assert(enumValueOptions); - expect( - getExtension( - enumValueOptions, - options_ts.enum_value_option_retention_unknown, - ), - ).toBe("enum value option retention unknown"); - expect( - getExtension( - enumValueOptions, - options_ts.enum_value_option_retention_runtime, - ), - ).toBe("enum value option retention runtime"); - - const serviceOptions = option_usage_ts.ServiceWithOptions.proto.options; - assert(serviceOptions); - expect( - getExtension(serviceOptions, options_ts.service_option_retention_unknown), - ).toBe("service option retention unknown"); - expect( - getExtension(serviceOptions, options_ts.service_option_retention_runtime), - ).toBe("service option retention runtime"); - - const methodOptions = - option_usage_ts.ServiceWithOptions.methods[0].proto.options; - assert(methodOptions); - expect( - getExtension(methodOptions, options_ts.method_option_retention_unknown), - ).toBe("method option retention unknown"); - expect( - getExtension(methodOptions, options_ts.method_option_retention_runtime), - ).toBe("method option retention runtime"); - }); - test("are unavailable for source retention options", () => { - const fileOptions = - option_usage_ts.fileDesc_extra_option_usage.proto.options; - assert(fileOptions); - expect( +test("source retention options are unavailable in generated code", () => { + const fileOptions = option_usage_ts.fileDesc_extra_option_usage.proto.options; + expect( + !!fileOptions && hasExtension(fileOptions, options_ts.file_option_retention_source), - ).toBe(false); + ).toBe(false); - const messageOptions = option_usage_ts.MessageWithOptionsDesc.proto.options; - assert(messageOptions); - expect( + const messageOptions = option_usage_ts.MessageWithOptionsDesc.proto.options; + expect( + !!messageOptions && hasExtension(messageOptions, options_ts.message_option_retention_source), - ).toBe(false); + ).toBe(false); - const fieldOptions = - option_usage_ts.MessageWithOptionsDesc.fields[0].proto.options; - assert(fieldOptions); - expect( + const fieldOptions = + option_usage_ts.MessageWithOptionsDesc.fields[0].proto.options; + expect( + !!fieldOptions && hasExtension(fieldOptions, options_ts.field_option_retention_source), - ).toBe(false); + ).toBe(false); - const oneofOptions = - option_usage_ts.MessageWithOptionsDesc.oneofs[0].proto.options; - assert(oneofOptions); - expect( + const oneofOptions = + option_usage_ts.MessageWithOptionsDesc.oneofs[0].proto.options; + expect( + !!oneofOptions && hasExtension(oneofOptions, options_ts.oneof_option_retention_source), - ).toBe(false); + ).toBe(false); - const enumOptions = option_usage_ts.EnumWithOptionsDesc.proto.options; - assert(enumOptions); - expect( + const enumOptions = option_usage_ts.EnumWithOptionsDesc.proto.options; + expect( + !!enumOptions && hasExtension(enumOptions, options_ts.enum_option_retention_source), - ).toBe(false); + ).toBe(false); - const enumValueOptions = - option_usage_ts.EnumWithOptionsDesc.values[0].proto.options; - assert(enumValueOptions); - expect( + const enumValueOptions = + option_usage_ts.EnumWithOptionsDesc.values[0].proto.options; + expect( + !!enumValueOptions && hasExtension( enumValueOptions, options_ts.enum_value_option_retention_source, ), - ).toBe(false); + ).toBe(false); - const serviceOptions = option_usage_ts.ServiceWithOptions.proto.options; - assert(serviceOptions); - expect( + const serviceOptions = option_usage_ts.ServiceWithOptions.proto.options; + expect( + !!serviceOptions && hasExtension(serviceOptions, options_ts.service_option_retention_source), - ).toBe(false); + ).toBe(false); - const methodOptions = - option_usage_ts.ServiceWithOptions.methods[0].proto.options; - assert(methodOptions); - expect( + const methodOptions = + option_usage_ts.ServiceWithOptions.methods[0].proto.options; + expect( + !!methodOptions && hasExtension(methodOptions, options_ts.method_option_retention_source), - ).toBe(false); - }); + ).toBe(false); }); test("ts generated code is assignable to js", () => { diff --git a/packages/protobuf-test/src/options.test.ts b/packages/protobuf-test/src/options.test.ts new file mode 100644 index 000000000..df7b66597 --- /dev/null +++ b/packages/protobuf-test/src/options.test.ts @@ -0,0 +1,147 @@ +// Copyright 2021-2024 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { describe, test, expect } from "@jest/globals"; +import { + type AnyDesc, + type DescExtension, + getOption, + hasOption, +} from "@bufbuild/protobuf"; +import { + enum_option_retention_runtime, + enum_option_retention_unknown, + enum_value_option_retention_runtime, + enum_value_option_retention_unknown, + field_option_retention_runtime, + field_option_retention_unknown, + file_option_retention_runtime, + file_option_retention_unknown, + message_option_retention_runtime, + message_option_retention_unknown, + method_option_retention_runtime, + method_option_retention_unknown, + oneof_option_retention_runtime, + oneof_option_retention_unknown, + service_option_retention_runtime, + service_option_retention_unknown, +} from "./gen/ts/extra/options_pb.js"; +import { + EnumWithOptionsDesc, + fileDesc_extra_option_usage, + MessageWithOptionsDesc, + ServiceWithOptions, +} from "./gen/ts/extra/option-usage_pb.js"; +import { Proto3MessageDesc } from "./gen/ts/extra/proto3_pb.js"; + +describe("hasOption()", () => { + test("supports anonymous descriptor and extension", () => { + const file: AnyDesc = fileDesc_extra_option_usage; + const ext: DescExtension = file_option_retention_runtime; + const has = hasOption(file, ext); + expect(has).toBe(true); + }); + test("returns false if extendee does not match", () => { + const file = fileDesc_extra_option_usage; + const ext = oneof_option_retention_runtime; + // @ts-expect-error TS2345 + const has = hasOption(file, ext); + expect(has).toBe(false); + }); + test("returns false if descriptor has no options", () => { + expect(Proto3MessageDesc.proto.options).toBeUndefined(); + const has = hasOption(Proto3MessageDesc, message_option_retention_runtime); + expect(has).toBe(false); + }); +}); + +describe("getOption()", () => { + test("supports anonymous descriptor and extension", () => { + const file: AnyDesc = fileDesc_extra_option_usage; + const ext: DescExtension = file_option_retention_runtime; + const val = getOption(file, ext); + expect(val).toBe("file option retention runtime"); + }); + test("returns zero value if descriptor has no options", () => { + const field = Proto3MessageDesc.fields[0]; + expect(field.proto.options).toBeUndefined(); + const val = getOption(field, field_option_retention_runtime); + expect(val).toBe(""); + }); + test("returns option", () => { + const file = fileDesc_extra_option_usage; + expect(getOption(file, file_option_retention_unknown)).toBe( + "file option retention unknown", + ); + expect(getOption(file, file_option_retention_runtime)).toBe( + "file option retention runtime", + ); + + const message = MessageWithOptionsDesc; + expect(getOption(message, message_option_retention_unknown)).toBe( + "message option retention unknown", + ); + expect(getOption(message, message_option_retention_runtime)).toBe( + "message option retention runtime", + ); + + const field = MessageWithOptionsDesc.fields[0]; + expect(getOption(field, field_option_retention_unknown)).toBe( + "field option retention unknown", + ); + expect(getOption(field, field_option_retention_runtime)).toBe( + "field option retention runtime", + ); + + const oneof = MessageWithOptionsDesc.oneofs[0]; + expect(getOption(oneof, oneof_option_retention_unknown)).toBe( + "oneof option retention unknown", + ); + expect(getOption(oneof, oneof_option_retention_runtime)).toBe( + "oneof option retention runtime", + ); + + const enumeration = EnumWithOptionsDesc; + expect(getOption(enumeration, enum_option_retention_unknown)).toBe( + "enum option retention unknown", + ); + expect(getOption(enumeration, enum_option_retention_runtime)).toBe( + "enum option retention runtime", + ); + + const enumValue = EnumWithOptionsDesc.values[0]; + expect(getOption(enumValue, enum_value_option_retention_unknown)).toBe( + "enum value option retention unknown", + ); + expect(getOption(enumValue, enum_value_option_retention_runtime)).toBe( + "enum value option retention runtime", + ); + + const service = ServiceWithOptions; + expect(getOption(service, service_option_retention_unknown)).toBe( + "service option retention unknown", + ); + expect(getOption(service, service_option_retention_runtime)).toBe( + "service option retention runtime", + ); + + const method = ServiceWithOptions.methods[0]; + expect(getOption(method, method_option_retention_unknown)).toBe( + "method option retention unknown", + ); + expect(getOption(method, method_option_retention_runtime)).toBe( + "method option retention runtime", + ); + }); +}); diff --git a/packages/protobuf/src/extensions.ts b/packages/protobuf/src/extensions.ts index 786efc7dc..3f290394d 100644 --- a/packages/protobuf/src/extensions.ts +++ b/packages/protobuf/src/extensions.ts @@ -12,7 +12,18 @@ // See the License for the specific language governing permissions and // limitations under the License. -import type { DescExtension, DescField } from "./desc-types.js"; +import type { + AnyDesc, + DescEnum, + DescEnumValue, + DescExtension, + DescField, + DescFile, + DescMessage, + DescMethod, + DescOneof, + DescService, +} from "./desc-types.js"; import { assert } from "./reflect/assert.js"; import { create } from "./create.js"; import { readField } from "./from-binary.js"; @@ -29,6 +40,16 @@ import type { } from "./types.js"; import { BinaryReader, BinaryWriter } from "./wire/binary-encoding.js"; import { isWrapperDesc } from "./wkt/wrappers.js"; +import type { + EnumOptions, + EnumValueOptions, + FieldOptions, + FileOptions, + MessageOptions, + MethodOptions, + OneofOptions, + ServiceOptions, +} from "./wkt/gen/google/protobuf/descriptor_pb.js"; /** * Retrieve an extension value from a message. @@ -117,6 +138,58 @@ export function hasExtension( ); } +/** + * Check whether an option is set on a descriptor. + * + * Options are extensions to the `google.protobuf.*Options` messages defined in + * google/protobuf/descriptor.proto. This function gets the option message from + * the descriptor, and calls hasExtension(). + */ +export function hasOption< + Ext extends DescExtension, + Desc extends DescForOptionExtension, +>(element: Desc, option: Ext): boolean { + const message = element.proto.options as Extendee | undefined; + if (!message) { + return false; + } + return hasExtension(message, option); +} + +/** + * Retrieve an option value from a descriptor. + * + * Options are extensions to the `google.protobuf.*Options` messages defined in + * google/protobuf/descriptor.proto. This function gets the option message from + * the descriptor, and calls getExtension(). Same as getExtension(), this + * function never returns undefined. + */ +export function getOption< + Ext extends DescExtension, + Desc extends DescForOptionExtension, +>(element: Desc, option: Ext): ExtensionValueShape { + const message = element.proto.options as Extendee | undefined; + if (!message) { + const [, , get] = createExtensionContainer(option); + return get(); + } + return getExtension(message, option); +} + +// prettier-ignore +type DescForOptionExtension = + Extendee extends FileOptions ? DescFile + : Extendee extends EnumOptions ? DescEnum + : Extendee extends EnumValueOptions ? DescEnumValue + : Extendee extends MessageOptions ? DescMessage + : Extendee extends MessageOptions ? DescEnum + : Extendee extends FieldOptions ? DescField | DescExtension + : Extendee extends OneofOptions ? DescOneof + : Extendee extends ServiceOptions ? DescService + : Extendee extends EnumOptions ? DescEnum + : Extendee extends MethodOptions ? DescMethod + : AnyDesc; + function filterUnknownFields( unknownFields: UnknownField[] | undefined, extension: DescExtension, diff --git a/packages/protobuf/src/index.ts b/packages/protobuf/src/index.ts index 36a90634a..652dc21ff 100644 --- a/packages/protobuf/src/index.ts +++ b/packages/protobuf/src/index.ts @@ -31,5 +31,7 @@ export { getExtension, setExtension, clearExtension, + hasOption, + getOption, } from "./extensions.js"; export * from "./proto-int64.js"; diff --git a/packages/protoplugin-example/src/protoc-gen-twirp-es.ts b/packages/protoplugin-example/src/protoc-gen-twirp-es.ts index a08af5ed8..846c21d59 100755 --- a/packages/protoplugin-example/src/protoc-gen-twirp-es.ts +++ b/packages/protoplugin-example/src/protoc-gen-twirp-es.ts @@ -15,7 +15,7 @@ // limitations under the License. import { createEcmaScriptPlugin, runNodeJs } from "@bufbuild/protoplugin"; -import { getExtension, hasExtension } from "@bufbuild/protobuf"; +import { getOption, hasOption } from "@bufbuild/protobuf"; import { localName } from "@bufbuild/protobuf/reflect"; import { type Schema, safeIdentifier } from "@bufbuild/protoplugin/ecmascript"; import { default_host } from "./gen/customoptions/default_host_pb.js"; @@ -39,12 +39,12 @@ function generateTs(schema: Schema) { // To support the custom option we defined in customoptions/default_host.proto, // we need to generate code for this proto file first. This will generate the - // file customoptions/default_host_pb.ts, which contains the generated extension - // `default_host`. - // Then we use the functions hasExtension() and getExtension() to see whether - // the option is set, and set the value as the default for the constructor argument. - if (service.proto.options && hasExtension(service.proto.options, default_host)) { - const defaultHost = getExtension(service.proto.options, default_host); + // file customoptions/default_host_pb.ts, which contains the generated option + // extension `default_host`. + // Then we use the functions hasOption() and getOption() to see whether the + // option is set, and set the value as the default for the constructor argument. + if (hasOption(service, default_host)) { + const defaultHost = getOption(service, default_host); f.print(" constructor(private readonly baseUrl = ", f.string(defaultHost), ") {"); f.print(" }"); } else { diff --git a/packages/protoplugin-test/src/custom-options.test.ts b/packages/protoplugin-test/src/custom-options.test.ts index 02fe256ac..9cc48aeba 100644 --- a/packages/protoplugin-test/src/custom-options.test.ts +++ b/packages/protoplugin-test/src/custom-options.test.ts @@ -18,6 +18,8 @@ import { fromBinary, hasExtension, getExtension, + hasOption, + getOption, } from "@bufbuild/protobuf"; import { type CodeGeneratorRequest, @@ -30,13 +32,13 @@ import assert from "node:assert"; describe("custom options", () => { const proto = ` - syntax = "proto3"; - import "google/protobuf/descriptor.proto"; - option (opt) = 123; - extend google.protobuf.FileOptions { - optional uint32 opt = 60123; - } - `; + syntax = "proto3"; + import "google/protobuf/descriptor.proto"; + option (opt) = 123; + extend google.protobuf.FileOptions { + optional uint32 opt = 60123; + } + `; test("can be read via extension", async () => { const opt = (await compileFile(proto)).extensions[0]; assert(opt); @@ -54,6 +56,26 @@ describe("custom options", () => { }); expect(value).toBe(123); }); + test("can be read via getOptions", async () => { + const opt = (await compileFile(proto)).extensions[0]; + assert(opt); + let has = false; + let value: unknown; + await createTestPluginAndRun({ + proto: { + "input.proto": proto, + }, + generateAny(_, schema) { + const file = schema.files.find((f) => f.proto.name == "input.proto"); + if (file) { + has = hasOption(file, opt); + value = getOption(file, opt); + } + }, + }); + expect(has).toBe(true); + expect(value).toBe(123); + }); }); describe("option retention", () => {