diff --git a/GUIDE.md b/GUIDE.md index 6c1b164be..12894e35c 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -825,7 +825,9 @@ by the target SDK or generated operation helpers. An enum annotated with `@nexus.proto` likewise remains a generated public enum unless a type replacement selects an external model. Fields use the authored enum's name and members, even when its name differs from the protobuf enum's. -Converters bridge the model and protobuf enum by number. +Converters bridge the model and protobuf enum by number. A case with +[`@nexus.fallback`](#nexusfallback) takes a protobuf number that the enum +doesn't know. ### Transfer-Type Conversion @@ -1791,6 +1793,33 @@ A default cannot be combined with `@nexus.source`. --- +### @nexus.fallback + +**Placement:** Case of an enum with `@nexus.proto` **Syntax:** `@nexus.fallback` + +A protobuf enum is open, so a newer peer can send a number that the generated +enum doesn't know. When a protobuf value converts to the model, such a number +becomes the marked case. An enum has at most one fallback case, and the +directive takes no arguments. + +```wit +/// @nexus.proto "temporal.api.enums.v1.StreamStatus" +enum stream-status { + /// @nexus.fallback + stream-status-unspecified, + stream-status-open, + stream-status-closed, + stream-status-expired, +} +``` + +Python, TypeScript, and .NET decode an unknown number as the case. Without the +directive, Python raises `ValueError`, and TypeScript and .NET keep the number. +Go and Java ignore the directive. nexgen refuses it on an enum without +`@nexus.proto`, and anywhere other than an enum case. + +--- + ### @nexus.experimental **Placement:** Interface, record, variant, or operation (function) **Syntax:** diff --git a/advanced/samples/dotnet/tests/StreamServiceChecks.cs b/advanced/samples/dotnet/tests/StreamServiceChecks.cs index a7f3ef5c7..c17a41144 100644 --- a/advanced/samples/dotnet/tests/StreamServiceChecks.cs +++ b/advanced/samples/dotnet/tests/StreamServiceChecks.cs @@ -5,6 +5,7 @@ using Xunit; using static Nexgen.DotNetExamples.Tests.TransferTypeTestSupport; using StreamServiceExample = Streams::Temporalio.Workflows; +using WireDescribeStreamResponse = Streams::Temporalio.Api.StreamService.V1.DescribeStreamResponse; using WireReadRecordsResponse = Streams::Temporalio.Api.StreamService.V1.ReadRecordsResponse; using WireStreamRecord = Streams::Temporalio.Api.Stream.V1.StreamRecord; @@ -71,5 +72,20 @@ public void ReadItemsRoundTripAsTheirRecords() close.Value.Status); Assert.Equal(3, decoded.Records[1].Offset); } + + // A newer server can send a Stream status that this code doesn't know. + [Fact] + public void UnknownEnumNumberDecodesAsTheFallbackCase() + { + var converter = CreateSdkConverter(typeof(StreamServiceExample.DescribeStreamResponse)); + var wire = new WireDescribeStreamResponse + { + Status = (Streams::Temporalio.Api.Enums.V1.StreamStatus)9, + }; + + var decoded = Assert.IsType( + converter.FromTransferType(wire)); + Assert.Equal(StreamServiceExample.StreamStatus.StreamStatusUnspecified, decoded.Status); + } } } diff --git a/advanced/samples/dotnet/wit/stream-service/Models.cs b/advanced/samples/dotnet/wit/stream-service/Models.cs index f07253c58..07b3f0603 100644 --- a/advanced/samples/dotnet/wit/stream-service/Models.cs +++ b/advanced/samples/dotnet/wit/stream-service/Models.cs @@ -522,7 +522,7 @@ internal static DescribeStreamResponse FromTransferType(Temporalio.Api.StreamSer { return new DescribeStreamResponse() { - Status = (int)wire.Status == 0 ? null : (StreamStatus)wire.Status, + Status = (int)wire.Status == 0 ? null : (System.Enum.IsDefined(typeof(StreamStatus), (int)wire.Status) ? (StreamStatus)wire.Status : StreamStatus.StreamStatusUnspecified), LastOffset = wire.LastOffset == 0 ? null : wire.LastOffset, Retention = wire.Retention == null ? null : Nexgen.Support.ProtoExtensions.FromDurationProto(wire.Retention), }; @@ -584,7 +584,7 @@ internal static Execution FromTransferType(Temporalio.Api.Common.V1.Execution wi { return new Execution() { - Type = (int)wire.Type == 0 ? null : (ExecutionType)wire.Type, + Type = (int)wire.Type == 0 ? null : (System.Enum.IsDefined(typeof(ExecutionType), (int)wire.Type) ? (ExecutionType)wire.Type : ExecutionType.ExecutionTypeUnspecified), BusinessId = string.IsNullOrEmpty(wire.BusinessId) ? null : wire.BusinessId, RunId = string.IsNullOrEmpty(wire.RunId) ? null : wire.RunId, }; @@ -655,7 +655,7 @@ internal static Producer FromTransferType(Temporalio.Api.Stream.V1.Producer wire { return new Producer(wire.Id) { - Kind = (int)wire.Kind == 0 ? null : (StreamProducerKind)wire.Kind, + Kind = (int)wire.Kind == 0 ? null : (System.Enum.IsDefined(typeof(StreamProducerKind), (int)wire.Kind) ? (StreamProducerKind)wire.Kind : StreamProducerKind.StreamProducerKindUnspecified), Epoch = wire.Epoch == null ? null : ProducerEpoch.FromTransferType(wire.Epoch), RunId = string.IsNullOrEmpty(wire.RunId) ? null : wire.RunId, }; @@ -979,7 +979,7 @@ internal static StreamClose FromTransferType(Temporalio.Api.Stream.V1.StreamClos Producer = wire.Producer == null ? null : Producer.FromTransferType(wire.Producer), Result = wire.Result == null ? null : Nexgen.Support.ProtoExtensions.FromPayload(wire.Result), Failure = wire.Failure == null ? null : Nexgen.Support.ProtoExtensions.FromFailureProto(wire.Failure), - Status = (int)wire.Status == 0 ? null : (StreamCloseStatus)wire.Status, + Status = (int)wire.Status == 0 ? null : (System.Enum.IsDefined(typeof(StreamCloseStatus), (int)wire.Status) ? (StreamCloseStatus)wire.Status : StreamCloseStatus.StreamCloseStatusUnspecified), }; } @@ -1266,7 +1266,7 @@ internal static Supersession FromTransferType(Temporalio.Api.Stream.V1.Supersess { return new Supersession(wire.ProducerId, System.Linq.Enumerable.ToArray(System.Linq.Enumerable.Select(wire.FailedTasks, item => item))) { - ProducerKind = (int)wire.ProducerKind == 0 ? null : (StreamProducerKind)wire.ProducerKind, + ProducerKind = (int)wire.ProducerKind == 0 ? null : (System.Enum.IsDefined(typeof(StreamProducerKind), (int)wire.ProducerKind) ? (StreamProducerKind)wire.ProducerKind : StreamProducerKind.StreamProducerKindUnspecified), Epoch = wire.Epoch == null ? null : ProducerEpoch.FromTransferType(wire.Epoch), PreviousCompletedTask = wire.PreviousCompletedTask == null ? null : WorkflowTaskEpoch.FromTransferType(wire.PreviousCompletedTask), WithdrawRun = string.IsNullOrEmpty(wire.WithdrawRun) ? null : wire.WithdrawRun, diff --git a/advanced/samples/inputs/stream-service.wit b/advanced/samples/inputs/stream-service.wit index 71fa1df89..85ebb41e4 100644 --- a/advanced/samples/inputs/stream-service.wit +++ b/advanced/samples/inputs/stream-service.wit @@ -43,6 +43,7 @@ interface stream-service { /// @nexus.proto "temporal.api.enums.v1.ExecutionType" enum execution-type { + /// @nexus.fallback execution-type-unspecified, execution-type-workflow, execution-type-activity, @@ -61,6 +62,7 @@ interface stream-service { /// @nexus.proto "temporal.api.enums.v1.StreamProducerKind" enum stream-producer-kind { + /// @nexus.fallback stream-producer-kind-unspecified, stream-producer-kind-workflow, stream-producer-kind-local-activity, @@ -140,6 +142,7 @@ interface stream-service { /// @nexus.doc "How a Stream closed. Only the owner's end on the server terminates or times out." /// @nexus.proto "temporal.api.enums.v1.StreamCloseStatus" enum stream-close-status { + /// @nexus.fallback stream-close-status-unspecified, stream-close-status-completed, stream-close-status-failed, @@ -214,6 +217,7 @@ interface stream-service { /// @nexus.proto "temporal.api.enums.v1.StreamStatus" enum stream-status { + /// @nexus.fallback stream-status-unspecified, stream-status-open, stream-status-closed, @@ -422,6 +426,7 @@ interface stream-service { /// typescript-extends="ApplicationFailure" typescript-extends-import="@temporalio/common" /// dotnet-extends="Temporalio.Exceptions.ApplicationFailureException" enum stream-error-reason { + /// @nexus.fallback stream-error-reason-unspecified, /// @nexus.error stream-error-reason-not-found, diff --git a/advanced/samples/invalid/wit-parser/fallback-arguments/expected.toml b/advanced/samples/invalid/wit-parser/fallback-arguments/expected.toml new file mode 100644 index 000000000..7bd633f0d --- /dev/null +++ b/advanced/samples/invalid/wit-parser/fallback-arguments/expected.toml @@ -0,0 +1,8 @@ +descriptors = "temporal" + +[error] +kind = "invalid-wit-directive" +contains = [ + "invalid WIT directive `@nexus.fallback` on type `models.alert-level` case `severity-unspecified`", + "does not take arguments", +] diff --git a/advanced/samples/invalid/wit-parser/fallback-arguments/input.wit b/advanced/samples/invalid/wit-parser/fallback-arguments/input.wit new file mode 100644 index 000000000..f8c895f16 --- /dev/null +++ b/advanced/samples/invalid/wit-parser/fallback-arguments/input.wit @@ -0,0 +1,14 @@ +package test:fallback-arguments; + +world system { export models; } + +interface models { + /// @nexus.proto "temporal.api.enums.v1.Severity" + enum alert-level { + /// @nexus.fallback "severity-low" + severity-unspecified, + severity-high, + severity-medium, + severity-low, + } +} diff --git a/advanced/samples/invalid/wit-parser/fallback-on-two-cases/expected.toml b/advanced/samples/invalid/wit-parser/fallback-on-two-cases/expected.toml new file mode 100644 index 000000000..8bcb34198 --- /dev/null +++ b/advanced/samples/invalid/wit-parser/fallback-on-two-cases/expected.toml @@ -0,0 +1,8 @@ +descriptors = "temporal" + +[error] +kind = "invalid-wit-directive" +contains = [ + "invalid WIT directive `@nexus.fallback` on type `models.alert-level`", + "is on `severity-unspecified` and `severity-medium`, but an enum has one fallback case", +] diff --git a/advanced/samples/invalid/wit-parser/fallback-on-two-cases/input.wit b/advanced/samples/invalid/wit-parser/fallback-on-two-cases/input.wit new file mode 100644 index 000000000..b2b84e0c1 --- /dev/null +++ b/advanced/samples/invalid/wit-parser/fallback-on-two-cases/input.wit @@ -0,0 +1,15 @@ +package test:fallback-on-two-cases; + +world system { export models; } + +interface models { + /// @nexus.proto "temporal.api.enums.v1.Severity" + enum alert-level { + /// @nexus.fallback + severity-unspecified, + severity-high, + /// @nexus.fallback + severity-medium, + severity-low, + } +} diff --git a/advanced/samples/invalid/wit-parser/fallback-without-proto/expected.toml b/advanced/samples/invalid/wit-parser/fallback-without-proto/expected.toml new file mode 100644 index 000000000..cd32a79c5 --- /dev/null +++ b/advanced/samples/invalid/wit-parser/fallback-without-proto/expected.toml @@ -0,0 +1,6 @@ +[error] +kind = "invalid-wit-directive" +contains = [ + "invalid WIT directive `@nexus.fallback` on type `models.level`", + "is only supported on an enum with `@nexus.proto`", +] diff --git a/advanced/samples/invalid/wit-parser/fallback-without-proto/input.wit b/advanced/samples/invalid/wit-parser/fallback-without-proto/input.wit new file mode 100644 index 000000000..74291ead3 --- /dev/null +++ b/advanced/samples/invalid/wit-parser/fallback-without-proto/input.wit @@ -0,0 +1,12 @@ +package test:fallback-without-proto; + +world system { export models; } + +interface models { + enum level { + /// @nexus.fallback + unknown, + low, + high, + } +} diff --git a/advanced/samples/python/tests/test_stream_service.py b/advanced/samples/python/tests/test_stream_service.py index 06a8f5f95..f16b90081 100644 --- a/advanced/samples/python/tests/test_stream_service.py +++ b/advanced/samples/python/tests/test_stream_service.py @@ -1,6 +1,7 @@ from __future__ import annotations import pytest +from temporalio.api.enums.v1 import stream_pb2 from temporalio.api.stream.v1 import message_pb2 from temporalio.api.streamservice.v1 import request_response_pb2 from temporalio.converter import PayloadConverter @@ -90,3 +91,26 @@ def test_a_request_without_its_stream_is_refused() -> None: ValueError, match="missing required field AppendRecordsRequest.stream" ): _ = converter.from_transfer_type(wire, AppendRecordsRequest) + + +def test_an_unknown_enum_number_decodes_as_the_fallback_case() -> None: + converter = models._DescribeStreamResponseTransferTypeConverter() + # A newer server can send a Stream status that this code doesn't know. + wire = request_response_pb2.DescribeStreamResponse( + status=stream_pb2.StreamStatus.ValueType(9) + ) + + decoded = converter.from_transfer_type(wire, models.DescribeStreamResponse) + + assert decoded.status is models.StreamStatus.StreamStatusUnspecified + + +def test_an_unknown_number_of_an_enum_with_no_fallback_fails_the_message() -> None: + converter = models._ReadRecordsRequestTransferTypeConverter() + wire = request_response_pb2.ReadRecordsRequest( + stream=message_pb2.StreamRef(id="s-1"), + start=stream_pb2.StreamReadStart.ValueType(9), + ) + + with pytest.raises(ValueError, match="9 is not a valid StreamReadStart"): + _ = converter.from_transfer_type(wire, models.ReadRecordsRequest) diff --git a/advanced/samples/python/wit/stream_service/models.py b/advanced/samples/python/wit/stream_service/models.py index 4afe7aa2d..4cd9dd557 100644 --- a/advanced/samples/python/wit/stream_service/models.py +++ b/advanced/samples/python/wit/stream_service/models.py @@ -27,6 +27,14 @@ class StreamProducerKind(enum.IntEnum): StreamProducerKindStandaloneActivity = 5 +def _stream_producer_kind_from_proto(value: int) -> StreamProducerKind: + try: + return StreamProducerKind(value) + except ValueError: + # A newer peer can send a case that this enum doesn't know. + return StreamProducerKind.StreamProducerKindUnspecified + + class ExecutionType(enum.IntEnum): ExecutionTypeUnspecified = 0 ExecutionTypeWorkflow = 1 @@ -34,6 +42,14 @@ class ExecutionType(enum.IntEnum): ExecutionTypeNexusOperation = 3 +def _execution_type_from_proto(value: int) -> ExecutionType: + try: + return ExecutionType(value) + except ValueError: + # A newer peer can send a case that this enum doesn't know. + return ExecutionType.ExecutionTypeUnspecified + + class StreamReadStart(enum.IntEnum): StreamReadStartUnspecified = 0 StreamReadStartBeginning = 1 @@ -49,6 +65,14 @@ class StreamCloseStatus(enum.IntEnum): StreamCloseStatusTimedOut = 5 +def _stream_close_status_from_proto(value: int) -> StreamCloseStatus: + try: + return StreamCloseStatus(value) + except ValueError: + # A newer peer can send a case that this enum doesn't know. + return StreamCloseStatus.StreamCloseStatusUnspecified + + class StreamStatus(enum.IntEnum): StreamStatusUnspecified = 0 StreamStatusOpen = 1 @@ -56,6 +80,14 @@ class StreamStatus(enum.IntEnum): StreamStatusExpired = 3 +def _stream_status_from_proto(value: int) -> StreamStatus: + try: + return StreamStatus(value) + except ValueError: + # A newer peer can send a case that this enum doesn't know. + return StreamStatus.StreamStatusUnspecified + + @dataclasses.dataclass(slots=True) class CreateStreamRequest: """Create a Stream, or get the open one. A closed, expired or another owner's Stream @@ -166,7 +198,9 @@ def from_transfer_type( id = value.id return Producer( id=id, - kind=StreamProducerKind(value.kind) if value.kind != 0 else None, + kind=_stream_producer_kind_from_proto(value.kind) + if value.kind != 0 + else None, epoch=_ProducerEpochTransferTypeConverter().from_transfer_type( value.epoch, ProducerEpoch ) @@ -403,7 +437,7 @@ def from_transfer_type( type_hint: type[Execution], ) -> Execution: return Execution( - type=ExecutionType(value.type) if value.type != 0 else None, + type=_execution_type_from_proto(value.type) if value.type != 0 else None, business_id=value.business_id if bool(value.business_id) else None, run_id=value.run_id if bool(value.run_id) else None, ) @@ -1090,7 +1124,7 @@ def from_transfer_type( return Supersession( producer_id=producer_id, failed_tasks=list(value.failed_tasks), - producer_kind=StreamProducerKind(value.producer_kind) + producer_kind=_stream_producer_kind_from_proto(value.producer_kind) if value.producer_kind != 0 else None, epoch=_ProducerEpochTransferTypeConverter().from_transfer_type( @@ -1239,7 +1273,9 @@ def from_transfer_type( failure=_support.failure_from_proto(value.failure) if value.HasField("failure") else None, - status=StreamCloseStatus(value.status) if value.status != 0 else None, + status=_stream_close_status_from_proto(value.status) + if value.status != 0 + else None, ) @typing_extensions.override @@ -1347,7 +1383,9 @@ def from_transfer_type( type_hint: type[DescribeStreamResponse], ) -> DescribeStreamResponse: return DescribeStreamResponse( - status=StreamStatus(value.status) if value.status != 0 else None, + status=_stream_status_from_proto(value.status) + if value.status != 0 + else None, last_offset=value.last_offset if value.last_offset != 0 else None, retention=_support.duration_from_proto(value.retention) if value.HasField("retention") diff --git a/advanced/samples/typescript/tests/stream-service.test.ts b/advanced/samples/typescript/tests/stream-service.test.ts index 680ca8050..f201f513d 100644 --- a/advanced/samples/typescript/tests/stream-service.test.ts +++ b/advanced/samples/typescript/tests/stream-service.test.ts @@ -6,6 +6,8 @@ import { describe, expect, test } from "vitest"; import { type ReadRecordsResponse, StreamCloseStatus, + StreamStatus, + describeStreamResponseTransferTypeConverter, readRecordsRequestTransferTypeConverter, readRecordsResponseTransferTypeConverter, } from "../wit/stream-service/models.ts"; @@ -86,4 +88,15 @@ describe("stream-service generated output", () => { readRecordsRequestTransferTypeConverter.toTransferType(model).stream?.id, ).toBe("stream-1"); }); + + test("decodes an enum number that it doesn't know as the fallback case", () => { + // A newer server can send a Stream status that this code doesn't know. + const proto: temporal.api.streamservice.v1.IDescribeStreamResponse = { + status: 9 as temporal.api.enums.v1.StreamStatus, + }; + + const model = describeStreamResponseTransferTypeConverter.fromTransferType(proto); + + expect(model.status).toBe(StreamStatus.StreamStatusUnspecified); + }); }); diff --git a/advanced/samples/typescript/wit/stream-service/models.ts b/advanced/samples/typescript/wit/stream-service/models.ts index 826a3fd50..be0014fe8 100644 --- a/advanced/samples/typescript/wit/stream-service/models.ts +++ b/advanced/samples/typescript/wit/stream-service/models.ts @@ -23,6 +23,14 @@ export enum ExecutionType { ExecutionTypeNexusOperation = 3, } +function executionTypeFromNumber(value: number): ExecutionType { + if (value in ExecutionType) { + return value as ExecutionType; + } + // A newer peer can send a case that this enum doesn't know. + return ExecutionType.ExecutionTypeUnspecified; +} + export enum StreamProducerKind { StreamProducerKindUnspecified = 0, StreamProducerKindWorkflow = 1, @@ -32,6 +40,14 @@ export enum StreamProducerKind { StreamProducerKindStandaloneActivity = 5, } +function streamProducerKindFromNumber(value: number): StreamProducerKind { + if (value in StreamProducerKind) { + return value as StreamProducerKind; + } + // A newer peer can send a case that this enum doesn't know. + return StreamProducerKind.StreamProducerKindUnspecified; +} + export enum StreamCloseStatus { StreamCloseStatusUnspecified = 0, StreamCloseStatusCompleted = 1, @@ -41,6 +57,14 @@ export enum StreamCloseStatus { StreamCloseStatusTimedOut = 5, } +function streamCloseStatusFromNumber(value: number): StreamCloseStatus { + if (value in StreamCloseStatus) { + return value as StreamCloseStatus; + } + // A newer peer can send a case that this enum doesn't know. + return StreamCloseStatus.StreamCloseStatusUnspecified; +} + export enum StreamReadStart { StreamReadStartUnspecified = 0, StreamReadStartBeginning = 1, @@ -54,6 +78,14 @@ export enum StreamStatus { StreamStatusExpired = 3, } +function streamStatusFromNumber(value: number): StreamStatus { + if (value in StreamStatus) { + return value as StreamStatus; + } + // A newer peer can send a case that this enum doesn't know. + return StreamStatus.StreamStatusUnspecified; +} + /** * A read item: a Record, a Superseded item or the close. */ @@ -152,7 +184,8 @@ export interface Execution { function executionFromProto(proto: temporal.api.common.v1.IExecution): Execution { return { - type: proto.type == null ? undefined : (proto.type as number as ExecutionType), + type: + proto.type == null ? undefined : executionTypeFromNumber(proto.type as number), businessId: proto.businessId ?? undefined, runId: proto.runId ?? undefined, }; @@ -260,7 +293,10 @@ export interface Producer { function producerFromProto(proto: temporal.api.stream.v1.IProducer): Producer { return { id: requiredField(proto.id === "" ? undefined : proto.id, "Producer", "id"), - kind: proto.kind == null ? undefined : (proto.kind as number as StreamProducerKind), + kind: + proto.kind == null + ? undefined + : streamProducerKindFromNumber(proto.kind as number), epoch: proto.epoch == null ? undefined : producerEpochFromProto(proto.epoch), runId: proto.runId ?? undefined, }; @@ -775,7 +811,7 @@ function supersessionFromProto( producerKind: proto.producerKind == null ? undefined - : (proto.producerKind as number as StreamProducerKind), + : streamProducerKindFromNumber(proto.producerKind as number), epoch: proto.epoch == null ? undefined : producerEpochFromProto(proto.epoch), previousCompletedTask: proto.previousCompletedTask == null @@ -874,7 +910,9 @@ function streamCloseFromProto(proto: temporal.api.stream.v1.IStreamClose): Strea failure: proto.failure == null ? undefined : support.errorFromFailure(proto.failure), status: - proto.status == null ? undefined : (proto.status as number as StreamCloseStatus), + proto.status == null + ? undefined + : streamCloseStatusFromNumber(proto.status as number), }; } @@ -998,7 +1036,8 @@ function describeStreamResponseFromProto( proto: temporal.api.streamservice.v1.IDescribeStreamResponse, ): DescribeStreamResponse { return { - status: proto.status == null ? undefined : (proto.status as number as StreamStatus), + status: + proto.status == null ? undefined : streamStatusFromNumber(proto.status as number), lastOffset: proto.lastOffset ?? undefined, retention: proto.retention == null ? undefined : support.durationFromProto(proto.retention), diff --git a/changelog/added/catching-cormorant.md b/changelog/added/catching-cormorant.md new file mode 100644 index 000000000..d1fa1391b --- /dev/null +++ b/changelog/added/catching-cormorant.md @@ -0,0 +1 @@ +`@nexus.fallback` on a case of an enum with `@nexus.proto` decodes a protobuf number that the enum doesn't know as that case, in Python, TypeScript and .NET. Without it, Python still raises on such a number. diff --git a/src/generator/dotnet.rs b/src/generator/dotnet.rs index ef39da3b0..f1e70973e 100644 --- a/src/generator/dotnet.rs +++ b/src/generator/dotnet.rs @@ -4359,7 +4359,7 @@ pub(in crate::generator) fn field_property_name(field: &RecordFieldSpec String { +pub(in crate::generator) fn enum_value_name(name: &str) -> String { name.trim_start_matches(|ch: char| ch == '_' || ch.is_ascii_digit()) .trim_start_matches("UNSPECIFIED") .trim_start_matches('_') diff --git a/src/generator/proto/dotnet.rs b/src/generator/proto/dotnet.rs index 1baa3a7eb..5466e2c2d 100644 --- a/src/generator/proto/dotnet.rs +++ b/src/generator/proto/dotnet.rs @@ -3,7 +3,8 @@ use heck::ToLowerCamelCase; use crate::error::{Error, Result}; use crate::generator::dotnet::{ WireValueConversion, csharp_parameter_name, csharp_type_name, csharp_type_parameter_name, - field_property_name, function_args_parameter_type, qualify_dotnet_support_reference, + enum_value_name, field_property_name, function_args_parameter_type, + qualify_dotnet_support_reference, }; use crate::planning::{ PlannedFamily, PlannedProtoMessageType, PlannedProtoType, PlannedProtoTypeInfo, PlannedSpec, @@ -1106,7 +1107,18 @@ fn value_from_wire_expr( PlannedType::Enum(enumeration) if enum_proto(api_plan, &enumeration.full_name).is_some() => { - format!("({}){source_expr}", csharp_type_name(&enumeration.name)) + let model = csharp_type_name(&enumeration.name); + let fallback = api_plan + .enum_decl(&enumeration.full_name) + .and_then(|decl| decl.values.iter().find(|value| value.fallback)); + match fallback { + // A newer peer can send a case that the enum doesn't know. + Some(fallback) => format!( + "(System.Enum.IsDefined(typeof({model}), (int){source_expr}) ? ({model}){source_expr} : {model}.{})", + csharp_type_name(&enum_value_name(&fallback.name)) + ), + None => format!("({model}){source_expr}"), + } } _ => source_expr.to_string(), }; diff --git a/src/generator/proto/python.rs b/src/generator/proto/python.rs index 27411a1d9..a2a233b7d 100644 --- a/src/generator/proto/python.rs +++ b/src/generator/proto/python.rs @@ -580,13 +580,18 @@ fn generated_enum_field_type(model: &EnumSpec) -> Option) -> Option String { + format!("_{}_from_proto", enum_name.to_snake_case()) +} + pub(in crate::generator) fn enum_value(value_type: &PlannedType) -> Option { let PlannedType::External(ExternalTypeSpec::Proto(PlannedProtoType::Enum(enum_type))) = value_type diff --git a/src/generator/proto/typescript.rs b/src/generator/proto/typescript.rs index c365af2cb..477d7af0e 100644 --- a/src/generator/proto/typescript.rs +++ b/src/generator/proto/typescript.rs @@ -841,14 +841,27 @@ pub(crate) fn field_name(field: &FieldDescriptorProto, explicit_name: Option<&st } } +/// The models module function that decodes a proto number as an enum with a fallback case. +pub(crate) fn enum_from_number_function_name(enum_name: &str) -> String { + format!("{}FromNumber", enum_name.to_lower_camel_case()) +} + fn generated_enum_wire_conversion(model: &EnumSpec) -> Option { let source = model.source.as_ref()?.proto_type()?; let PlannedProtoType::Enum(proto) = source else { return None; }; + let from_wire = if model.values.iter().any(|value| value.fallback) { + format!( + "{}({{wire}} as number)", + enum_from_number_function_name(&model.name) + ) + } else { + format!("{{wire}} as number as {}", model.name) + }; Some(WireValueConversion { annotation: model.name.clone(), - from_wire: format!("{{wire}} as number as {}", model.name), + from_wire, to_wire: format!("{{value}} as number as {}", proto.proto.full_name), function_name_to_wire: None, wire_function_names: None, diff --git a/src/generator/python.rs b/src/generator/python.rs index 628a551b0..1e3ac74a3 100644 --- a/src/generator/python.rs +++ b/src/generator/python.rs @@ -1684,6 +1684,11 @@ impl<'a> ApiPlanner<'a> { number: value.number, }) .collect(), + fallback: enum_spec + .values + .iter() + .find(|value| value.fallback) + .map(|value| value.name.clone()), }); } @@ -2691,6 +2696,8 @@ struct ResourceBoundOperation<'a> { struct RenderedEnum { name: String, values: Vec, + /// The member that an unknown proto number decodes as. + fallback: Option, } #[derive(Debug)] @@ -5362,6 +5369,19 @@ fn render_enum(output: &mut String, enumeration: &RenderedEnum) { output.push_str(&value.number.to_string()); output.push('\n'); } + + if let Some(fallback) = &enumeration.fallback { + let name = &enumeration.name; + output.push_str(&format!( + "\n\ndef {}(value: int) -> {name}:\n", + python_proto::enum_from_proto_function_name(name) + )); + output.push_str(" try:\n"); + output.push_str(&format!(" return {name}(value)\n")); + output.push_str(" except ValueError:\n"); + output.push_str(" # A newer peer can send a case that this enum doesn't know.\n"); + output.push_str(&format!(" return {name}.{fallback}\n")); + } } fn render_flags(output: &mut String, flags: &RenderedFlags) { diff --git a/src/generator/typescript.rs b/src/generator/typescript.rs index f6e3d7bda..c9c94cc2e 100644 --- a/src/generator/typescript.rs +++ b/src/generator/typescript.rs @@ -780,6 +780,11 @@ impl<'a> ApiPlanner<'a> { number: value.number, }) .collect(), + fallback: planned_enum + .values + .iter() + .find(|value| value.fallback) + .map(|value| value.name.clone()), }); } @@ -2947,6 +2952,8 @@ struct RenderedOperationInput { struct RenderedEnum { name: String, values: Vec, + /// The member that an unknown proto number decodes as. + fallback: Option, } #[derive(Debug)] @@ -4788,6 +4795,19 @@ fn render_enum(output: &mut String, enumeration: &RenderedEnum) { output.push_str(",\n"); } output.push_str("}\n"); + if let Some(fallback) = &enumeration.fallback { + let name = &enumeration.name; + output.push_str(&format!( + "\nfunction {}(value: number): {name} {{\n", + typescript_proto::enum_from_number_function_name(name) + )); + output.push_str(&format!(" if (value in {name}) {{\n")); + output.push_str(&format!(" return value as {name};\n")); + output.push_str(" }\n"); + output.push_str(" // A newer peer can send a case that this enum doesn't know.\n"); + output.push_str(&format!(" return {name}.{fallback};\n")); + output.push_str("}\n"); + } } fn render_flags(output: &mut String, flags: &RenderedFlags) { diff --git a/src/parser/wit.rs b/src/parser/wit.rs index 893753749..3b4863f23 100644 --- a/src/parser/wit.rs +++ b/src/parser/wit.rs @@ -6,8 +6,8 @@ use std::path::{Path, PathBuf}; use heck::{ToSnakeCase, ToUpperCamelCase}; use tempfile::TempDir; use wit_parser_crate::{ - Function, FunctionKind, Handle, Interface, PackageId, PackageSourceMap, Record, Resolve, Type, - TypeDef, TypeDefKind, TypeId, TypeOwner, WorldId, WorldItem, WorldKey, + Enum, Function, FunctionKind, Handle, Interface, PackageId, PackageSourceMap, Record, Resolve, + Type, TypeDef, TypeDefKind, TypeId, TypeOwner, WorldId, WorldItem, WorldKey, }; use crate::error::{Error, Result}; @@ -565,6 +565,7 @@ fn collect_interface_types( for type_id in interface.types.values() { let type_def = &resolve.types[*type_id]; validate_type_parameter_directive(resolve, *type_id, type_def, path)?; + reject_misplaced_fallback_in_type(resolve, *type_id, type_def, path)?; if let Some(record) = build_wit_record_spec(resolve, *type_id, type_def, path, &interface_name, language)? { @@ -723,6 +724,8 @@ fn build_wit_enum_spec( }; let type_name = type_def.name.as_deref().unwrap_or("unnamed-type"); + let context = format!("type `{}`", wit_type_full_name(resolve, type_id)); + let fallback = enum_fallback_case(enumeration, type_def, path, &context)?; Ok(Some(EnumSpec { name: type_name.to_upper_camel_case(), full_name: wit_type_full_name(resolve, type_id), @@ -734,12 +737,111 @@ fn build_wit_enum_spec( wire_name: value.name.clone(), name: value.name.to_upper_camel_case(), number: i32::try_from(index).expect("WIT enum case index should fit in i32"), + fallback: fallback == Some(index), }) .collect(), source: build_type_source(type_def, path, interface_name)?, })) } +/// The index of the case with `@nexus.fallback`, if the enum has one. +fn enum_fallback_case( + enumeration: &Enum, + type_def: &TypeDef, + path: &Path, + context: &str, +) -> Result> { + let mut fallback = None::<(usize, &str)>; + for (index, case) in enumeration.cases.iter().enumerate() { + let case_context = format!("{context} case `{}`", case.name); + let directives = parse_directives(case.docs.contents.as_deref(), path, &case_context)?; + let Some(directive) = directive(&directives, "fallback", path, &case_context)? else { + continue; + }; + if !directive.args.is_empty() { + return Err(fallback_error( + path, + &case_context, + "does not take arguments", + )); + } + if let Some((_, first)) = fallback { + return Err(fallback_error( + path, + context, + &format!( + "is on `{first}` and `{}`, but an enum has one fallback case", + case.name + ), + )); + } + fallback = Some((index, case.name.as_str())); + } + // Only a proto number can be one that the enum doesn't know. + if fallback.is_some() && find_proto_name_for_type_def(type_def, path, context)?.is_none() { + return Err(fallback_error( + path, + context, + "is only supported on an enum with `@nexus.proto`", + )); + } + Ok(fallback.map(|(index, _)| index)) +} + +/// Refuses `@nexus.fallback` on a type, and on the cases of a variant or flags. +/// +/// A misplaced fallback would have no effect, so the enum would still fail on a case that a newer +/// peer sends. The author learns that at run time, not at generation time. +fn reject_misplaced_fallback_in_type( + resolve: &Resolve, + type_id: TypeId, + type_def: &TypeDef, + path: &Path, +) -> Result<()> { + let context = format!("type `{}`", wit_type_full_name(resolve, type_id)); + let directives = parse_directives(type_def.docs.contents.as_deref(), path, &context)?; + reject_misplaced_fallback(&directives, path, &context)?; + let cases = match &type_def.kind { + TypeDefKind::Variant(variant) => variant + .cases + .iter() + .map(|case| (case.name.as_str(), case.docs.contents.as_deref())) + .collect(), + TypeDefKind::Flags(flags) => flags + .flags + .iter() + .map(|flag| (flag.name.as_str(), flag.docs.contents.as_deref())) + .collect(), + _ => Vec::new(), + }; + for (name, docs) in cases { + let case_context = format!("{context} case `{name}`"); + let directives = parse_directives(docs, path, &case_context)?; + reject_misplaced_fallback(&directives, path, &case_context)?; + } + Ok(()) +} + +fn reject_misplaced_fallback(directives: &[Directive], path: &Path, context: &str) -> Result<()> { + if directive(directives, "fallback", path, context)?.is_some() { + return Err(fallback_error( + path, + context, + "is only supported on enum cases", + )); + } + Ok(()) +} + +fn fallback_error(path: &Path, context: &str, reason: &str) -> Error { + Error::InvalidWitDirective { + path: path.to_path_buf(), + context: context.to_string(), + directive: "@nexus.fallback".to_string(), + reason: reason.to_string(), + } +} + fn build_wit_flags_spec( resolve: &Resolve, type_id: TypeId, @@ -1112,6 +1214,7 @@ fn build_fields_from_record( let field_context = format!("{context} field `{}`", field.name); let directives = parse_directives(field.docs.contents.as_deref(), path, &field_context)?; reject_misplaced_type_parameter(&directives, path, &field_context)?; + reject_misplaced_fallback(&directives, path, &field_context)?; let generated_field_name = directive(&directives, "name", path, &field_context)? .and_then(|directive| directive_language_value(directive, language)) .unwrap_or(&field.name) @@ -2557,6 +2660,7 @@ fn build_service( let context = format!("interface `{interface_name}`"); let directives = parse_directives(interface.docs.contents.as_deref(), path, &context)?; reject_misplaced_type_parameter(&directives, path, &context)?; + reject_misplaced_fallback(&directives, path, &context)?; let endpoint = directive_value_for_language(&directives, "endpoint", path, &context, language)?; let service_name = interface_name.to_upper_camel_case(); let wire_service_name = build_wire_service_name(&directives, path, &context, &service_name)?; @@ -2696,6 +2800,7 @@ fn build_resource( &constructor_context, )?; reject_misplaced_type_parameter(&directives, path, &constructor_context)?; + reject_misplaced_fallback(&directives, path, &constructor_context)?; } let fields = match constructor { Some(constructor) => constructor @@ -2771,6 +2876,7 @@ fn build_resource_method( ); let directives = parse_directives(function.docs.contents.as_deref(), path, &context)?; reject_misplaced_type_parameter(&directives, path, &context)?; + reject_misplaced_fallback(&directives, path, &context)?; let params = function .params .iter() @@ -2880,6 +2986,7 @@ fn build_operation( let context = format!("{service_context} operation `{operation_name}`"); let directives = parse_directives(function.docs.contents.as_deref(), path, &context)?; reject_misplaced_type_parameter(&directives, path, &context)?; + reject_misplaced_fallback(&directives, path, &context)?; let wire_operation_name = build_wire_operation_name(&directives, path, &context, &operation_name)?; let experimental = experimental_directive(&directives, path, &context)?; diff --git a/src/spec.rs b/src/spec.rs index 4ec5a3d58..e980f0b9a 100644 --- a/src/spec.rs +++ b/src/spec.rs @@ -864,6 +864,8 @@ pub struct EnumValueSpec { pub wire_name: String, pub name: String, pub number: i32, + /// Marked with `@nexus.fallback`, so an unknown proto number decodes as this case. + pub fallback: bool, } #[derive(Debug, Clone, PartialEq)]