diff --git a/GUIDE.md b/GUIDE.md index bbd202ccc..c135d11a3 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -1833,7 +1833,7 @@ below, such as protobuf bindings, function signatures, and conflicting options. | `@nexus.name` | record field | | `@nexus.type` | record field, type alias, resource | | `@nexus.omit` | record field | -| `@nexus.api-omit` | record field, freestanding function | +| `@nexus.api-omit` | record field, resource method, freestanding function | | `@nexus.source` | record field | | `@nexus.default` | record field | | `@nexus.fallback` | enum case | @@ -2374,8 +2374,8 @@ Cannot be combined with `@nexus.source`, `@nexus.type`, `@nexus.function`, ### @nexus.api-omit -**Placement:** Record field or freestanding function **Syntax:** -`@nexus.api-omit [targets="workflow,client"]` +**Placement:** Record field, resource method, or freestanding function +**Syntax:** `@nexus.api-omit [targets="workflow,client"]` On a record field, keeps the field on generated models and in proto conversion, but excludes it from generated convenience operation APIs. `targets` limits the @@ -2393,6 +2393,20 @@ remains, so the operation is still reached through its resource. On a field, it cannot be combined with `@nexus.omit`, `@nexus.default`, or a `@nexus.source` for the same target. +On a resource method, `targets="workflow"` or `targets="client"` leaves the +method out of the resource class of that target, with the helpers that only it +used. The operation stays in the service and in the other target. Without its +`@nexus.close` method, the resource of that target is not a context manager. +nexgen refuses a method omission without one target, since no target would keep +the method. Remove such a method instead. Go refuses a method omitted for +`workflow`. + +```wit +/// @nexus.operation "append-records" +/// @nexus.api-omit targets="workflow" +append: func(records: list) -> append-records-response; +``` + --- ### @nexus.source diff --git a/advanced/samples/dotnet/tests/StreamServiceChecks.cs b/advanced/samples/dotnet/tests/StreamServiceChecks.cs index 5cf188ecf..2f339b05b 100644 --- a/advanced/samples/dotnet/tests/StreamServiceChecks.cs +++ b/advanced/samples/dotnet/tests/StreamServiceChecks.cs @@ -100,11 +100,21 @@ public void ApiOmitHidesTheOperationConvenienceMethods() .Distinct(); Assert.Equal(new[] { "CreateStreamAsync" }, publicOperationMethods); - // The Stream resource still binds every operation. - foreach (var method in new[] { "AppendAsync", "CloseAsync", "ReadAsync", "DescribeAsync" }) + // The Stream resource still binds the reads. + foreach (var method in new[] { "ReadAsync", "DescribeAsync" }) { Assert.NotNull(typeof(StreamServiceExample.Stream).GetMethod(method)); } } + + // `append` and `close` are api-omitted for the Workflow target, so Workflow code can't + // append to a Stream or close it. + [Fact] + public void WorkflowStreamHasNoAppendAndNoClose() + { + var stream = typeof(StreamServiceExample.Stream); + Assert.Null(stream.GetMethod("AppendAsync")); + Assert.Null(stream.GetMethod("CloseAsync")); + } } } diff --git a/advanced/samples/dotnet/wit/stream-service/workflow/Operations.cs b/advanced/samples/dotnet/wit/stream-service/workflow/Operations.cs index f9615be0d..0e1c999b8 100644 --- a/advanced/samples/dotnet/wit/stream-service/workflow/Operations.cs +++ b/advanced/samples/dotnet/wit/stream-service/workflow/Operations.cs @@ -159,7 +159,7 @@ public static Task> CreateStreamAsync(CreateStreamOptions /// Append one batch. The Store keeps a resent batch once, by Producer, epoch and sequence. [GeneratedCode("nexgen", null)] - internal static async Task AppendRecordsAsync(AppendRecordsRequest request) + private static async Task AppendRecordsAsync(AppendRecordsRequest request) { var client = Workflow.CreateNexusWorkflowClient("temporal.api.streamservice.v1.StreamService", StreamServiceEndpoint); var result = await client.ExecuteNexusOperationAsync("AppendRecords", request).ConfigureAwait(true); @@ -182,7 +182,7 @@ internal static Task AppendRecordsAsync(AppendReco /// Close a Stream. Readers drain what is left, then end. Later appends are refused. [GeneratedCode("nexgen", null)] - internal static async Task CloseStreamAsync(CloseStreamRequest request) + private static async Task CloseStreamAsync(CloseStreamRequest request) { var client = Workflow.CreateNexusWorkflowClient("temporal.api.streamservice.v1.StreamService", StreamServiceEndpoint); var result = await client.ExecuteNexusOperationAsync("CloseStream", request).ConfigureAwait(true); diff --git a/advanced/samples/dotnet/wit/stream-service/workflow/Resources.cs b/advanced/samples/dotnet/wit/stream-service/workflow/Resources.cs index 922e8f98d..f1cfa5fc1 100644 --- a/advanced/samples/dotnet/wit/stream-service/workflow/Resources.cs +++ b/advanced/samples/dotnet/wit/stream-service/workflow/Resources.cs @@ -24,20 +24,6 @@ public Stream(StreamRef @ref, bool? created) public StreamRef Ref { get; } public bool? Created { get; } - [GeneratedCode("nexgen", null)] - public Task AppendAsync(IReadOnlyList records, Producer? producer, long? sequence) - { - var request = new AppendRecordsRequest(Ref, System.Linq.Enumerable.ToArray(System.Linq.Enumerable.Select(records, value => new AppendRecord(value))), producer ?? Nexgen.Support.StreamWorkflowContext.Producer()) { Sequence = sequence }; - return Operations.AppendRecordsAsync(request); - } - - [GeneratedCode("nexgen", null)] - public Task CloseAsync(Producer? producer) - { - var request = new CloseStreamRequest(Ref, producer ?? Nexgen.Support.StreamWorkflowContext.Producer()); - return Operations.CloseStreamAsync(request); - } - [GeneratedCode("nexgen", null)] public Task> ReadAsync(byte[]? fromOffset, int? maxRecords, System.TimeSpan? wait, long? maxBytes) { diff --git a/advanced/samples/inputs/stream-service.wit b/advanced/samples/inputs/stream-service.wit index a0c0ded73..a7a390477 100644 --- a/advanced/samples/inputs/stream-service.wit +++ b/advanced/samples/inputs/stream-service.wit @@ -279,9 +279,12 @@ interface stream-service { resource %stream { constructor(ref: stream-ref, created: option); + // In this release Workflow code can't append to a Stream or close it. So the Workflow target + // omits `append` and `close`, and the client target keeps both. /// @nexus.doc "Append values, and return the offsets of the batch." /// @nexus.operation "append-records" /// @nexus.varargs "records" + /// @nexus.api-omit targets="workflow" append: func( records: list, producer: option, @@ -291,6 +294,7 @@ interface stream-service { /// @nexus.doc "Close the Stream. Readers drain what is left, then end." /// @nexus.operation "close-stream" /// @nexus.close + /// @nexus.api-omit targets="workflow" close: func(producer: option); /// @nexus.doc "Read one page of Records. The poll iterator reads them all." diff --git a/advanced/samples/invalid/api-omit/resource-method-without-targets/expected.toml b/advanced/samples/invalid/api-omit/resource-method-without-targets/expected.toml new file mode 100644 index 000000000..3ec5a4ce1 --- /dev/null +++ b/advanced/samples/invalid/api-omit/resource-method-without-targets/expected.toml @@ -0,0 +1,6 @@ +[error] +kind = "invalid-wit-directive" +contains = [ + "invalid WIT directive `@nexus.api-omit` on interface `example` resource `Item` method `Rename` in `", + "input.wit`: a resource method needs `targets=\"workflow\"` or `targets=\"client\"`. To leave the method out of every target, remove the method instead", +] diff --git a/advanced/samples/invalid/api-omit/resource-method-without-targets/input.wit b/advanced/samples/invalid/api-omit/resource-method-without-targets/input.wit new file mode 100644 index 000000000..a125ed0dd --- /dev/null +++ b/advanced/samples/invalid/api-omit/resource-method-without-targets/input.wit @@ -0,0 +1,18 @@ +package temporal:api-omit-resource-method@1.0.0; + +world service { export example; } + +/// @nexus.endpoint "example" +interface example { + resource item { + constructor(id: string); + + /// @nexus.api-omit + rename: func(name: option); + } + + record rename-request { id: string, name: option } + record rename-response { ok: option } + + rename: func(request: rename-request) -> rename-response; +} diff --git a/advanced/samples/python/tests/test_sdk_client.py b/advanced/samples/python/tests/test_sdk_client.py index 68a3404f6..9d37168eb 100644 --- a/advanced/samples/python/tests/test_sdk_client.py +++ b/advanced/samples/python/tests/test_sdk_client.py @@ -924,6 +924,13 @@ def test_only_the_client_target_has_the_read_poll() -> None: assert hasattr(Stream, "iter_read_items") +def test_only_the_client_stream_appends_and_closes() -> None: + # `append` and `close` are api-omitted for the Workflow target only. + for name in ("append", "close", "__aenter__", "__aexit__"): + assert hasattr(Stream, name) + assert not hasattr(workflow_stream_module.Stream, name) + + async def test_a_call_and_a_resource_method_pass_their_rpc_options() -> None: client, answers = connect( Client, diff --git a/advanced/samples/python/tests/test_stream_service.py b/advanced/samples/python/tests/test_stream_service.py index dd483001b..78c59c113 100644 --- a/advanced/samples/python/tests/test_stream_service.py +++ b/advanced/samples/python/tests/test_stream_service.py @@ -41,12 +41,6 @@ def _offset(n: int) -> bytes: return n.to_bytes(8, "big") -@dataclasses.dataclass -class FakeWorkflowInfo: - run_id: str - attempt: int = 1 - - @dataclasses.dataclass class Turn: role: str @@ -169,9 +163,7 @@ async def test_workflow_apis_call_the_stream_service( ref = model_types.StreamRef(id="workflow-stream") replies = { "CreateStream": model_types.CreateStreamResult(ref=ref, created=True), - "AppendRecords": model_types.AppendRecordsResponse( - first_offset=_offset(2), last_offset=_offset(3) - ), + "DescribeStream": model_types.DescribeStreamResponse(last_offset=_offset(3)), } calls: list[tuple[str, typing.Any]] = [] @@ -197,43 +189,23 @@ def create_client(*, service: str, endpoint: str) -> NexusClient: return NexusClient() monkeypatch.setattr(temporalio.workflow, "create_nexus_client", create_client) - monkeypatch.setattr(temporalio.workflow, "info", lambda: FakeWorkflowInfo("r2")) monkeypatch.setattr(temporalio.workflow, "in_workflow", lambda: True) request_id = uuid.UUID(int=7) monkeypatch.setattr(temporalio.workflow, "uuid4", lambda: request_id) stream = await api.create_stream(id=ref.id, body_type=str) - assert await stream.append("a", "b", sequence=3) == replies["AppendRecords"] - # The Workflow API fills the request id and the producer from its sources. - producer = model_types.Producer( - id="wf:r2", - kind=model_types.ProducerKindWorkflow( - model_types.WorkflowProducer(run_id="r2") - ), - ) - records = [model_types.AppendRecord(body="a"), model_types.AppendRecord(body="b")] + assert await stream.describe() == replies["DescribeStream"] + # The Workflow API fills the request id from its source. expected = [ ( "CreateStream", model_types.CreateStreamRequest(stream_ref=ref, request_id=str(request_id)), ), - ( - "AppendRecords", - model_types.AppendRecordsRequest( - ref=ref, records=records, producer=producer, sequence=3 - ), - ), + ("DescribeStream", model_types.DescribeStreamRequest(ref=ref)), ] # The sources build the models of one package, so the calls compare as data. assert [(name, dataclasses.asdict(arg)) for name, arg in calls] == [ (name, dataclasses.asdict(arg)) for name, arg in expected ] - wire = model_types._AppendRecordsRequestTransferTypeConverter().to_transfer_type( - calls[-1][1] - ) - assert [ - PayloadConverter.default.from_payload(record.body) for record in wire.records - ] == ["a", "b"] - assert wire.sequence == 3 @pytest.mark.parametrize("construction", ["create", "direct"]) @@ -415,11 +387,6 @@ def create_client(*, service: str, endpoint: str) -> NexusClient: if typing.TYPE_CHECKING: _ = workflow_only.get_stream(ref=typed, body_type=str) # pyright: ignore[reportArgumentType, reportCallIssue, reportUnknownVariableType] - records = [Turn(role="model")] - _ = await workflow_only.get_stream(ref=typed).append(*records) - _ = await workflow_only.get_stream( - ref=workflow_models.StreamRef[str](id="s1") - ).append(*records) # pyright: ignore[reportArgumentType] def test_the_ref_of_a_workflow_handle_keeps_its_type_argument() -> None: @@ -591,24 +558,67 @@ def test_api_omit_hides_the_public_operation_functions( # The resource factory and its methods still reach the operations. assert "get_stream" in api.__all__ stream = api.get_stream(ref=model_types.StreamRef(id="s-1"), body_type=str) - assert all( - callable(getattr(stream, method)) - for method in ("append", "close", "read", "describe") - ) + assert all(callable(getattr(stream, method)) for method in ("read", "describe")) # Every operation stays registered, so the service still serves all five. assert len(api.__nexus_operation_registry__) == 5 # The private helpers that the resource methods call stay in the resource module. resources = importlib.import_module(api.__name__ + "._resources.stream") - for helper in ( - "_append_records", - "_close_stream", - "_read_records", - "_describe_stream", - ): + for helper in ("_read_records", "_describe_stream"): assert hasattr(resources, helper) assert not hasattr(resources, "append_records") +@pytest.mark.parametrize( + ("api", "model_types"), + [(workflow_only, workflow_models), (both_workflow, both_models)], + ids=["workflow", "both"], +) +async def test_a_workflow_stream_can_not_append_or_close( + monkeypatch: pytest.MonkeyPatch, + api: types.ModuleType, + model_types: types.ModuleType, +) -> None: + ref = model_types.StreamRef(id="s-1") + stream = api.get_stream(ref=ref, body_type=str) + # `append` and `close` are api-omitted for the Workflow target, with their helpers. + for name in ("append", "close", "__aenter__", "__aexit__"): + assert not hasattr(stream, name) + resources = importlib.import_module(api.__name__ + "._resources.stream") + for helper in ("_append_records", "_close_stream"): + assert not hasattr(resources, helper) + + class NexusClient: + async def start_operation( + self, + operation: str, + input: typing.Any, + *, + output_type: type[typing.Any] | None = None, + ) -> collections.abc.Awaitable[typing.Any]: + del operation, input, output_type + future: asyncio.Future[typing.Any] = ( + asyncio.get_running_loop().create_future() + ) + future.set_result(model_types.CreateStreamResult(ref=ref)) + return future + + def create_client(*, service: str, endpoint: str) -> NexusClient: + del service, endpoint + return NexusClient() + + monkeypatch.setattr(temporalio.workflow, "create_nexus_client", create_client) + monkeypatch.setattr(temporalio.workflow, "uuid4", lambda: uuid.UUID(int=7)) + # With nothing to close, a create is a plain call and not a context manager. + call = api.create_stream(id=ref.id, body_type=str) + assert not hasattr(call, "__aenter__") + assert (await call).ref == ref + + if typing.TYPE_CHECKING: + typed = workflow_only.get_stream(ref=workflow_models.StreamRef[str](id="s-1")) + _ = typed.append # pyright: ignore[reportAttributeAccessIssue, reportUnknownVariableType] + _ = typed.close # pyright: ignore[reportAttributeAccessIssue, reportUnknownVariableType] + + @pytest.mark.parametrize( ("package", "forbidden"), [ diff --git a/advanced/samples/python/wit/stream_service/both/workflow/_resources/stream.py b/advanced/samples/python/wit/stream_service/both/workflow/_resources/stream.py index 71c20fcc0..d3e73c902 100644 --- a/advanced/samples/python/wit/stream_service/both/workflow/_resources/stream.py +++ b/advanced/samples/python/wit/stream_service/both/workflow/_resources/stream.py @@ -3,26 +3,17 @@ from __future__ import annotations import typing -import typing_extensions import temporalio.nexus.system import datetime import temporalio.workflow -import types from ...models import ( - AppendRecord, - AppendRecordsRequest, - AppendRecordsResponse, - CloseStreamRequest, - CloseStreamResponse, DescribeStreamRequest, DescribeStreamResponse, - Producer, ReadRecordsRequest, ReadRecordsResponse, StreamRef, ) -import temporal_support as _support BodyT = typing.TypeVar("BodyT") @@ -49,45 +40,6 @@ def body_type(self) -> type[BodyT]: arguments = typing.get_args(getattr(self.ref, "__orig_class__", None)) return typing.cast("type[BodyT]", arguments[0] if arguments else object) - async def __aenter__(self) -> typing_extensions.Self: - return self - - async def __aexit__( - self, - exc_type: type[BaseException] | None, - exc: BaseException | None, - tb: types.TracebackType | None, - ) -> None: - await self.close() - - async def append( - self, - *records: BodyT, - producer: Producer | None = None, - sequence: int | None = None, - ) -> AppendRecordsResponse: - """Append values, and return the offsets of the batch.""" - request = AppendRecordsRequest( - ref=self.ref, - producer=producer if producer is not None else _support.workflow_producer(), - sequence=sequence, - records=[AppendRecord(body=value) for value in records], - ) - handle = await _append_records(request) - return await handle - - async def close( - self, - producer: Producer | None = None, - ) -> None: - """Close the Stream. Readers drain what is left, then end.""" - request = CloseStreamRequest( - ref=self.ref, - producer=producer if producer is not None else _support.workflow_producer(), - ) - handle = await _close_stream(request) - await handle - async def read( self, from_offset: bytes | None = None, @@ -156,34 +108,6 @@ def get_stream( ) -async def _append_records( - request: AppendRecordsRequest[BodyT], -) -> temporalio.workflow.NexusOperationHandle[AppendRecordsResponse,]: - nexus_client = temporalio.workflow.create_nexus_client( - service="temporal.api.streamservice.v1.StreamService", - endpoint=temporalio.nexus.system.TEMPORAL_SYSTEM_ENDPOINT, - ) - return await nexus_client.start_operation( - operation="AppendRecords", - input=request, - output_type=AppendRecordsResponse, - ) - - -async def _close_stream( - request: CloseStreamRequest[BodyT], -) -> temporalio.workflow.NexusOperationHandle[CloseStreamResponse,]: - nexus_client = temporalio.workflow.create_nexus_client( - service="temporal.api.streamservice.v1.StreamService", - endpoint=temporalio.nexus.system.TEMPORAL_SYSTEM_ENDPOINT, - ) - return await nexus_client.start_operation( - operation="CloseStream", - input=request, - output_type=CloseStreamResponse, - ) - - async def _read_records( request: ReadRecordsRequest[BodyT], body_type: type[BodyT], diff --git a/advanced/samples/python/wit/stream_service/both/workflow/operations/create_stream.py b/advanced/samples/python/wit/stream_service/both/workflow/operations/create_stream.py index fcf39213d..e9799dc5f 100644 --- a/advanced/samples/python/wit/stream_service/both/workflow/operations/create_stream.py +++ b/advanced/samples/python/wit/stream_service/both/workflow/operations/create_stream.py @@ -14,7 +14,6 @@ ) from .._resources import Stream import temporal_support as _support -from ..._resource_context_manager import resource_context_manager BodyT = typing.TypeVar("BodyT") @@ -43,7 +42,6 @@ async def _create_stream( @typing.overload -@resource_context_manager async def create_stream( *, id: str, @@ -54,7 +52,6 @@ async def create_stream( @typing.overload -@resource_context_manager async def create_stream( *, id: str, @@ -64,7 +61,6 @@ async def create_stream( ) -> Stream[object]: ... -@resource_context_manager async def create_stream( *, id: str, diff --git a/advanced/samples/python/wit/stream_service/workflow/_resource_context_manager.py b/advanced/samples/python/wit/stream_service/workflow/_resource_context_manager.py deleted file mode 100644 index 9d2f88ce3..000000000 --- a/advanced/samples/python/wit/stream_service/workflow/_resource_context_manager.py +++ /dev/null @@ -1,93 +0,0 @@ -# Generated by nexgen latest. DO NOT EDIT! - -from __future__ import annotations - -import collections.abc -import functools -import types -import typing - -import typing_extensions - - -class _Closable(typing.Protocol): - async def __aenter__(self) -> object: ... - - async def __aexit__( - self, - exc_type: type[BaseException] | None, - exc: BaseException | None, - tb: types.TracebackType | None, - ) -> None: ... - - -_ResourceT = typing.TypeVar("_ResourceT", bound=_Closable) -_P = typing_extensions.ParamSpec("_P") - - -@typing.final -class ResourceContextManager( - collections.abc.Coroutine[typing.Any, typing.Any, _ResourceT], - typing.Generic[_ResourceT], -): - """The create call. You can use it two ways. Await it to get the resource and manage it - yourself. Or open it with ``async with`` to get the resource and close it at the end of the - block. The ``async with`` form does the create for you, so you do not add a separate await.""" - - def __init__( - self, call: collections.abc.Coroutine[typing.Any, typing.Any, _ResourceT] - ) -> None: - self._call: collections.abc.Coroutine[typing.Any, typing.Any, _ResourceT] = call - self._resource: _ResourceT | None = None - self.__name__: str = getattr(call, "__name__", type(self).__name__) - self.__qualname__: str = getattr(call, "__qualname__", self.__name__) - - @typing_extensions.override - def __await__(self) -> collections.abc.Generator[typing.Any, None, _ResourceT]: - return self._call.__await__() - - @typing_extensions.override - def send(self, value: typing.Any) -> typing.Any: - return self._call.send(value) - - @typing_extensions.override - def throw( - self, - typ: typing.Any, - val: typing.Any = None, - tb: types.TracebackType | None = None, - ) -> typing.Any: - return self._call.throw(typ, val, tb) - - @typing_extensions.override - def close(self) -> None: - self._call.close() - - async def __aenter__(self) -> _ResourceT: - resource = await self._call - _ = await resource.__aenter__() - self._resource = resource - return resource - - async def __aexit__( - self, - exc_type: type[BaseException] | None, - exc: BaseException | None, - tb: types.TracebackType | None, - ) -> None: - if self._resource is not None: - await self._resource.__aexit__(exc_type, exc, tb) - - -def resource_context_manager( - function: collections.abc.Callable[ - _P, collections.abc.Coroutine[typing.Any, typing.Any, _ResourceT] - ], -) -> collections.abc.Callable[_P, ResourceContextManager[_ResourceT]]: - """Wraps a call that returns a closable resource in a ``ResourceContextManager``.""" - - @functools.wraps(function) - def call(*args: _P.args, **kwargs: _P.kwargs) -> ResourceContextManager[_ResourceT]: - return ResourceContextManager(function(*args, **kwargs)) - - return call diff --git a/advanced/samples/python/wit/stream_service/workflow/_resources/stream.py b/advanced/samples/python/wit/stream_service/workflow/_resources/stream.py index ecc3954fe..4e7f84eb3 100644 --- a/advanced/samples/python/wit/stream_service/workflow/_resources/stream.py +++ b/advanced/samples/python/wit/stream_service/workflow/_resources/stream.py @@ -3,26 +3,17 @@ from __future__ import annotations import typing -import typing_extensions import temporalio.nexus.system import datetime import temporalio.workflow -import types from ..models import ( - AppendRecord, - AppendRecordsRequest, - AppendRecordsResponse, - CloseStreamRequest, - CloseStreamResponse, DescribeStreamRequest, DescribeStreamResponse, - Producer, ReadRecordsRequest, ReadRecordsResponse, StreamRef, ) -import temporal_support as _support BodyT = typing.TypeVar("BodyT") @@ -49,45 +40,6 @@ def body_type(self) -> type[BodyT]: arguments = typing.get_args(getattr(self.ref, "__orig_class__", None)) return typing.cast("type[BodyT]", arguments[0] if arguments else object) - async def __aenter__(self) -> typing_extensions.Self: - return self - - async def __aexit__( - self, - exc_type: type[BaseException] | None, - exc: BaseException | None, - tb: types.TracebackType | None, - ) -> None: - await self.close() - - async def append( - self, - *records: BodyT, - producer: Producer | None = None, - sequence: int | None = None, - ) -> AppendRecordsResponse: - """Append values, and return the offsets of the batch.""" - request = AppendRecordsRequest( - ref=self.ref, - producer=producer if producer is not None else _support.workflow_producer(), - sequence=sequence, - records=[AppendRecord(body=value) for value in records], - ) - handle = await _append_records(request) - return await handle - - async def close( - self, - producer: Producer | None = None, - ) -> None: - """Close the Stream. Readers drain what is left, then end.""" - request = CloseStreamRequest( - ref=self.ref, - producer=producer if producer is not None else _support.workflow_producer(), - ) - handle = await _close_stream(request) - await handle - async def read( self, from_offset: bytes | None = None, @@ -156,34 +108,6 @@ def get_stream( ) -async def _append_records( - request: AppendRecordsRequest[BodyT], -) -> temporalio.workflow.NexusOperationHandle[AppendRecordsResponse,]: - nexus_client = temporalio.workflow.create_nexus_client( - service="temporal.api.streamservice.v1.StreamService", - endpoint=temporalio.nexus.system.TEMPORAL_SYSTEM_ENDPOINT, - ) - return await nexus_client.start_operation( - operation="AppendRecords", - input=request, - output_type=AppendRecordsResponse, - ) - - -async def _close_stream( - request: CloseStreamRequest[BodyT], -) -> temporalio.workflow.NexusOperationHandle[CloseStreamResponse,]: - nexus_client = temporalio.workflow.create_nexus_client( - service="temporal.api.streamservice.v1.StreamService", - endpoint=temporalio.nexus.system.TEMPORAL_SYSTEM_ENDPOINT, - ) - return await nexus_client.start_operation( - operation="CloseStream", - input=request, - output_type=CloseStreamResponse, - ) - - async def _read_records( request: ReadRecordsRequest[BodyT], body_type: type[BodyT], diff --git a/advanced/samples/python/wit/stream_service/workflow/operations/create_stream.py b/advanced/samples/python/wit/stream_service/workflow/operations/create_stream.py index 45d0d181d..8dfe5731f 100644 --- a/advanced/samples/python/wit/stream_service/workflow/operations/create_stream.py +++ b/advanced/samples/python/wit/stream_service/workflow/operations/create_stream.py @@ -14,7 +14,6 @@ ) from .._resources import Stream import temporal_support as _support -from .._resource_context_manager import resource_context_manager BodyT = typing.TypeVar("BodyT") @@ -43,7 +42,6 @@ async def _create_stream( @typing.overload -@resource_context_manager async def create_stream( *, id: str, @@ -54,7 +52,6 @@ async def create_stream( @typing.overload -@resource_context_manager async def create_stream( *, id: str, @@ -64,7 +61,6 @@ async def create_stream( ) -> Stream[object]: ... -@resource_context_manager async def create_stream( *, id: str, diff --git a/advanced/samples/typescript/tests/stream-service.test.ts b/advanced/samples/typescript/tests/stream-service.test.ts index ca955a4e5..2ecaad76c 100644 --- a/advanced/samples/typescript/tests/stream-service.test.ts +++ b/advanced/samples/typescript/tests/stream-service.test.ts @@ -2,10 +2,8 @@ import * as common from "@temporalio/common"; import type { temporal } from "@temporalio/proto"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; -import * as support from "../support/temporal_model_converters.ts"; import { type CreateStreamRequest, - type Producer, type ReadRecordsResponse, type StreamRef, StreamStatus, @@ -17,22 +15,7 @@ import { readRecordsResponseTransferTypeConverter, } from "../wit/stream-service/workflow/models.ts"; -// The operation modules import the SDK's Nexus module, which this sample doesn't have. A -// Stream method sends its request through these functions. -const sent = vi.hoisted(() => ({ requests: [] as unknown[] })); - -vi.mock("../wit/stream-service/workflow/operations/append-records.ts", () => ({ - _appendRecords: async (request: unknown) => { - sent.requests.push(request); - return { result: async () => ({}) }; - }, -})); -vi.mock("../wit/stream-service/workflow/operations/close-stream.ts", () => ({ - _closeStream: async (request: unknown) => { - sent.requests.push(request); - return { result: async () => undefined }; - }, -})); +// The operation modules import the SDK's Nexus module, which this sample doesn't have. vi.mock("../wit/stream-service/workflow/operations/read-records.ts", () => ({})); vi.mock("../wit/stream-service/workflow/operations/describe-stream.ts", () => ({})); @@ -64,7 +47,6 @@ beforeEach(() => { afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks(); - sent.requests.length = 0; }); describe("stream-service generated output", () => { @@ -158,39 +140,17 @@ describe("stream-service generated output", () => { ).toEqual(["a", "b"]); }); - test("takes the producer of a call, or its source when the call passes none", async () => { - const own = { id: "own", kind: { tag: "client", value: {} } } as const; - const sourced = { - id: "wf:r1", - kind: { tag: "workflow", value: { runId: "r1" } }, - } as const; - const source = vi.spyOn(support, "workflowProducer").mockReturnValue(sourced); + test("gives the Workflow Stream no append and no close", async () => { // The tsconfig excludes the stream output, which doesn't typecheck here, so a test loads it // at run time. const resources = "../wit/stream-service/workflow/resources.ts"; - const { Stream } = (await import(resources)) as { - Stream: new ( - ref: StreamRef, - created: undefined, - ) => { - append(records: string[], producer?: Producer): Promise; - close(producer?: Producer): Promise; - }; - }; - const stream = new Stream({ id: "stream-1" }, undefined); + const { Stream } = (await import(resources)) as { Stream: { prototype: object } }; - await stream.append(["a"], own); - await stream.close(own); - // A call that passes a producer never evaluates the source. - expect(source).not.toHaveBeenCalled(); - - await stream.append(["b"]); - await stream.close(); - - expect(source).toHaveBeenCalledTimes(2); - expect( - sent.requests.map((request) => (request as { producer: unknown }).producer), - ).toEqual([own, own, sourced, sourced]); + // `append` and `close` are api-omitted for the Workflow target, so only the reads stay. + expect("append" in Stream.prototype).toBe(false); + expect("close" in Stream.prototype).toBe(false); + expect("read" in Stream.prototype).toBe(true); + expect("describe" in Stream.prototype).toBe(true); }); test("decodes an enum number that it doesn't know as the fallback case", () => { diff --git a/advanced/samples/typescript/wit/stream-service/workflow/operations/append-records.ts b/advanced/samples/typescript/wit/stream-service/workflow/operations/append-records.ts index bdff169fa..681e3a286 100644 --- a/advanced/samples/typescript/wit/stream-service/workflow/operations/append-records.ts +++ b/advanced/samples/typescript/wit/stream-service/workflow/operations/append-records.ts @@ -24,12 +24,6 @@ export async function appendRecords( ...requestInput, producer: support.workflowProducer(), } as AppendRecordsRequest; - return await _appendRecords(request); -} - -export async function _appendRecords( - request: AppendRecordsRequest, -): Promise> { const client = nexus.createNexusServiceClient({ service: streamService, endpoint: "__temporal_system", diff --git a/advanced/samples/typescript/wit/stream-service/workflow/operations/close-stream.ts b/advanced/samples/typescript/wit/stream-service/workflow/operations/close-stream.ts index de6bb90c1..4978c4418 100644 --- a/advanced/samples/typescript/wit/stream-service/workflow/operations/close-stream.ts +++ b/advanced/samples/typescript/wit/stream-service/workflow/operations/close-stream.ts @@ -22,12 +22,6 @@ export async function closeStream( ...requestInput, producer: support.workflowProducer(), } as CloseStreamRequest; - return await _closeStream(request); -} - -export async function _closeStream( - request: CloseStreamRequest, -): Promise> { const client = nexus.createNexusServiceClient({ service: streamService, endpoint: "__temporal_system", diff --git a/advanced/samples/typescript/wit/stream-service/workflow/resources.ts b/advanced/samples/typescript/wit/stream-service/workflow/resources.ts index c307aa5e2..4714b0df6 100644 --- a/advanced/samples/typescript/wit/stream-service/workflow/resources.ts +++ b/advanced/samples/typescript/wit/stream-service/workflow/resources.ts @@ -1,16 +1,7 @@ // Generated by nexgen latest. DO NOT EDIT! import type * as common from "@temporalio/common"; -import type { - StreamRef, - AppendRecordsResponse, - Producer, - ReadRecordsResponse, - DescribeStreamResponse, -} from "./models"; -import * as support from "../../../support/temporal_model_converters"; -import { _appendRecords } from "./operations/append-records"; -import { _closeStream } from "./operations/close-stream"; +import type { StreamRef, ReadRecordsResponse, DescribeStreamResponse } from "./models"; import { readRecords } from "./operations/read-records"; import { describeStream } from "./operations/describe-stream"; @@ -30,27 +21,6 @@ export class Stream { return this.#bodyType; } - public async append( - records: BodyT[], - producer?: Producer, - sequence?: number, - ): Promise { - const request = { - ref: this.ref, - sequence: sequence, - records: records, - producer: producer ?? support.workflowProducer(), - }; - const handle = await _appendRecords(request); - return await handle.result(); - } - - public async close(producer?: Producer): Promise { - const request = { ref: this.ref, producer: producer ?? support.workflowProducer() }; - const handle = await _closeStream(request); - await handle.result(); - } - public async read( fromOffset?: Uint8Array, maxRecords?: number, diff --git a/architecture.md b/architecture.md index b4b4693d7..a656947ee 100644 --- a/architecture.md +++ b/architecture.md @@ -196,6 +196,12 @@ records the omitted targets on `OperationSpec`, and each backend gates its own public surface (the Python `__all__` entry and function, the TypeScript barrel export, the .NET public options type and method, and the Go exported wrapper). +On a resource method, the parser records the omitted targets on +`ResourceMethodSpec`, and planning keeps them on `PlannedResourceMethod`. +`PlannedResource::methods_for` and `close_for` give each target its methods and +its close. Each backend renders a target's resource class from them, and the +shared request checks skip a method that the target omits. + Python emits shared models and service contracts at the package root, regardless of API targets. Contracts describe wire data rather than callable resource handles. Each API owns its operation wrappers and handles. The client API lives diff --git a/changelog/added/skipping-sandpiper.md b/changelog/added/skipping-sandpiper.md new file mode 100644 index 000000000..6a928efed --- /dev/null +++ b/changelog/added/skipping-sandpiper.md @@ -0,0 +1 @@ +`@nexus.api-omit targets="workflow"` or `targets="client"` on a resource method leaves the method out of the resource class of that target, in Python, TypeScript and .NET. The operation stays in the service and in the other target. Without its `@nexus.close` method, the Python resource of that target is not a context manager. nexgen refuses the directive on a method without one target, and Go refuses a method omitted for the Workflow target. diff --git a/src/generator/dotnet.rs b/src/generator/dotnet.rs index 64a223f5c..081716486 100644 --- a/src/generator/dotnet.rs +++ b/src/generator/dotnet.rs @@ -572,10 +572,14 @@ impl<'a> ApiPlanner<'a> { output.push_str(" methods require a service endpoint.\");\n"); output.push_str(" }\n"); } - if !resource.fields.is_empty() && !resource.methods.is_empty() { + // A .NET resource method calls the Workflow API, so the Workflow omissions apply. + let methods = resource + .methods_for(GenerationTarget::Workflow) + .collect::>(); + if !resource.fields.is_empty() && !methods.is_empty() { output.push('\n'); } - for method in &resource.methods { + for method in methods { self.render_resource_method(output, service, &type_name, method); } output.push_str("}\n\n"); diff --git a/src/generator/go.rs b/src/generator/go.rs index 875dcf121..54ba053a8 100644 --- a/src/generator/go.rs +++ b/src/generator/go.rs @@ -1055,6 +1055,25 @@ fn generate_in_tree( reason: "Go doesn't support `@nexus.varargs`".to_string(), }); } + if let Some((resource, method)) = api_plan + .services + .iter() + .flat_map(|service| &service.resources) + .find_map(|resource| { + resource + .methods + .iter() + .find(|method| method.api_omitted.contains(&GenerationTarget::Workflow)) + .map(|method| (resource, method)) + }) + { + return Err(Error::UnsupportedGoType { + context: format!("resource method `{}.{}`", resource.name, method.name), + reason: "Go doesn't support `@nexus.api-omit targets=\"workflow\"` on a resource \ + method" + .to_string(), + }); + } if let Some((record, field_name)) = api_plan.records().find_map(|(_, record)| { record .fields diff --git a/src/generator/python.rs b/src/generator/python.rs index 184631aab..7b80bd127 100644 --- a/src/generator/python.rs +++ b/src/generator/python.rs @@ -870,18 +870,19 @@ impl<'a> ApiPlanner<'a> { ), }, )?; - let returns_closable_resource = !targets.is_empty() - && services.iter().any(|service| { - service - .operations + let returns_closable_resource = targets.iter().any(|&target| { + services.iter().any(|service| { + service.operations.iter().any(|operation| { + operation_returns_closable_resource(service, operation, target) + }) || service + .resources .iter() - .any(|operation| operation_returns_closable_resource(service, operation)) - || service - .resources - .iter() - .flat_map(|resource| &resource.methods) - .any(|method| resource_method_returns_closable_resource(service, method)) - }); + .flat_map(|resource| resource.methods_for(target)) + .any(|method| { + resource_method_returns_closable_resource(service, method, target) + }) + }) + }); if returns_closable_resource { if let Some(operation) = services .iter() @@ -972,7 +973,8 @@ impl<'a> ApiPlanner<'a> { } for service in services { for resource in &service.resources { - let bound_operations = resource_bound_operations(service, resource); + let bound_operations = + resource_bound_operations(service, resource, GenerationTarget::Workflow); files.insert( format!("_resources/{}.py", resource_module_name(resource)), self.render_resource_module_file( @@ -1052,7 +1054,12 @@ impl<'a> ApiPlanner<'a> { files.insert(format!("{dir}/_extensions.py"), extensions, origin())?; if let Some(operation) = services .iter() - .flat_map(|service| &service.operations) + .flat_map(|service| { + service + .operations + .iter() + .filter(|operation| client_reaches_operation(service, operation)) + }) .find(|operation| { operation.poll_items.is_some() && operation.poll_next(GenerationTarget::Client).is_some() @@ -1090,7 +1097,7 @@ impl<'a> ApiPlanner<'a> { GenerationTarget::Client, service, resource, - &resource_bound_operations(service, resource), + &resource_bound_operations(service, resource, GenerationTarget::Client), model_names, resource_names, ), @@ -2014,7 +2021,9 @@ impl<'a> ApiPlanner<'a> { &type_variables, self.rendered_resource_ref(resource).as_ref(), ); - render_resource_async_with(output, resource); + } + if let Some(target) = target { + render_resource_async_with(output, resource, target); } let Some(target) = target else { // Identity DTOs have no API methods or client state. @@ -2030,7 +2039,7 @@ impl<'a> ApiPlanner<'a> { return; } - for method in &resource.methods { + for method in resource.methods_for(target) { output.push_str("\n"); self.render_resource_class_method( output, @@ -2147,7 +2156,7 @@ impl<'a> ApiPlanner<'a> { render_resource_context_manager_decorator( output, " ", - resource_method_returns_closable_resource(service, method), + resource_method_returns_closable_resource(service, method, target), ); let param_exprs = self.resource_method_param_exprs(method); let optional_params = resource_method_optional_params(method); @@ -5486,6 +5495,27 @@ fn resource_operation_owners(services: &[RenderedService<'_>]) -> BTreeMap, + operation: &RenderedOperation<'_>, +) -> bool { + let mut methods = service + .resources + .iter() + .flat_map(|resource| &resource.methods) + .filter(|method| { + matches!( + &method.binding, + PlannedResourceMethodBindingSpec::Operation { operation_name, .. } + if operation_name == operation.name + ) + }) + .peekable(); + methods.peek().is_none() || methods.any(|method| !method.omitted_for(GenerationTarget::Client)) +} + fn standalone_operations<'a, 'spec>( service: &'a RenderedService<'spec>, ) -> impl Iterator> { @@ -6186,24 +6216,32 @@ fn render_resources_package_init( output } +/// The operations that the target's resource module calls. The Workflow module also holds the +/// public function of each operation that its resource binds, so that function and its helper +/// stay when the target omits the method. fn resource_bound_operations<'a>( service: &'a RenderedService<'a>, resource: &'a PlannedResource, + target: GenerationTarget, ) -> Vec> { resource .methods .iter() - .filter_map(|method| match &method.binding { - PlannedResourceMethodBindingSpec::Operation { operation_name, .. } => { - Some(ResourceBoundOperation { - operation: service - .operations - .iter() - .find(|operation| operation.name == operation_name) - .expect("bound resource operation should exist on the service"), - }) - } - PlannedResourceMethodBindingSpec::Stub => None, + .filter_map(|method| { + let PlannedResourceMethodBindingSpec::Operation { operation_name, .. } = + &method.binding + else { + return None; + }; + let operation = service + .operations + .iter() + .find(|operation| operation.name == operation_name) + .expect("bound resource operation should exist on the service"); + let public_function = target == GenerationTarget::Workflow + && !operation.api_omitted_for(GenerationTarget::Workflow); + (!method.omitted_for(target) || public_function) + .then_some(ResourceBoundOperation { operation }) }) .collect() } @@ -7154,7 +7192,7 @@ fn render_endpoint_service_object_operation_method( render_resource_context_manager_decorator( output, " ", - operation_returns_closable_resource(service, operation), + operation_returns_closable_resource(service, operation, GenerationTarget::Workflow), ); output.push_str(" async def "); output.push_str(&operation.attr_name); @@ -7192,7 +7230,7 @@ fn render_endpoint_service_object_unpacked_method( render_resource_context_manager_decorator( output, " ", - operation_returns_closable_resource(service, operation), + operation_returns_closable_resource(service, operation, GenerationTarget::Workflow), ); output.push_str(" async def "); output.push_str(&operation.attr_name); @@ -10278,8 +10316,12 @@ fn render_resource_context_manager_import(output: &mut String, body: &str, modul } /// The end of an `async with` block calls the `@nexus.close` method. -fn render_resource_async_with(output: &mut String, resource: &PlannedResource) { - let Some(name) = &resource.close else { +fn render_resource_async_with( + output: &mut String, + resource: &PlannedResource, + target: GenerationTarget, +) { + let Some(name) = resource.close_for(target) else { return; }; output.push_str("\n async def __aenter__(self) -> typing_extensions.Self:\n"); @@ -10300,15 +10342,16 @@ fn render_resource_async_with(output: &mut String, resource: &PlannedResource) { fn operation_returns_closable_resource( service: &RenderedService<'_>, operation: &RenderedOperation<'_>, + target: GenerationTarget, ) -> bool { match &operation.output_resource_return { Some(resource_return) if resource_return.holder.is_none() => { - closable_resource(service, &resource_return.resource_type_name) + closable_resource(service, &resource_return.resource_type_name, target) } Some(_) => false, None => { operation.output_direct_result - && closable_resource(service, &operation.output_annotation) + && closable_resource(service, &operation.output_annotation, target) } } } @@ -10316,21 +10359,26 @@ fn operation_returns_closable_resource( fn resource_method_returns_closable_resource( service: &RenderedService<'_>, method: &PlannedResourceMethod, + target: GenerationTarget, ) -> bool { match &method.result { Some(PlannedResourceMethodResult { kind: PlannedResourceMethodResultKind::Resource { type_name, .. }, optional: false, - }) => closable_resource(service, type_name), + }) => closable_resource(service, type_name, target), _ => false, } } -fn closable_resource(service: &RenderedService<'_>, type_name: &str) -> bool { +fn closable_resource( + service: &RenderedService<'_>, + type_name: &str, + target: GenerationTarget, +) -> bool { service .resources .iter() - .any(|resource| resource.type_name == type_name && resource.close.is_some()) + .any(|resource| resource.type_name == type_name && resource.close_for(target).is_some()) } /// The decorated call returns a `ResourceContextManager`, so a caller can also enter it with `async with`. @@ -10350,7 +10398,7 @@ fn render_public_operation_signature_start( render_resource_context_manager_decorator( output, target_indent(target), - operation_returns_closable_resource(service, operation), + operation_returns_closable_resource(service, operation, target), ); render_operation_signature_start(output, target, &operation.attr_name); } @@ -10618,7 +10666,7 @@ fn render_request_only_operation_function( render_resource_context_manager_decorator( output, "", - operation_returns_closable_resource(service, operation), + operation_returns_closable_resource(service, operation, GenerationTarget::Workflow), ); } output.push_str("async def "); diff --git a/src/generator/resource_plan.rs b/src/generator/resource_plan.rs index 5aee37e2c..6d49c5ec2 100644 --- a/src/generator/resource_plan.rs +++ b/src/generator/resource_plan.rs @@ -96,8 +96,8 @@ pub(crate) fn request_plan_fills_source( }) } -/// Whether a resource method of the service binds the operation with a plan that fills one of -/// the operation's sourced fields. +/// Whether a resource method of the service's `target` binds the operation with a plan that +/// fills one of the operation's sourced fields. pub(crate) fn resource_method_fills_source( api_plan: &PlannedSpec, service_name: &str, @@ -109,7 +109,7 @@ pub(crate) fn resource_method_fills_source( .iter() .filter(|service| service.name == service_name) .flat_map(|service| service.resources.iter().map(|resource| &resource.data)) - .flat_map(|resource| &resource.methods) + .flat_map(|resource| resource.methods_for(target)) .any(|method| { matches!( &method.binding, @@ -130,7 +130,7 @@ pub(crate) fn validate_resource_origins( ) -> Result<()> { for service in &api_plan.services { for resource in service.resources.iter().map(|resource| &resource.data) { - for method in &resource.methods { + for method in resource.methods_for(target) { let PlannedResourceMethodBindingSpec::Operation { request_plan, .. } = &method.binding else { diff --git a/src/generator/typescript.rs b/src/generator/typescript.rs index 3a1332dda..196b51483 100644 --- a/src/generator/typescript.rs +++ b/src/generator/typescript.rs @@ -6362,12 +6362,14 @@ fn render_resource( output.push_str(" ) {}\n"); } - if resource.methods.is_empty() { + // A TypeScript resource method calls the Workflow API, so the Workflow omissions apply. + let mut methods = resource.methods_for(GenerationTarget::Workflow).peekable(); + if methods.peek().is_none() { output.push_str("}\n"); return; } - for method in &resource.methods { + for method in methods { let result_annotation = typescript_resource_method_result_annotation(method, api_plan); output.push('\n'); output.push_str(" public async "); diff --git a/src/parser/wit.rs b/src/parser/wit.rs index 4766d00dc..11bf578f7 100644 --- a/src/parser/wit.rs +++ b/src/parser/wit.rs @@ -3044,6 +3044,7 @@ fn build_resource_method( .transpose()?; let close = build_resource_method_close(¶ms, &directives, path, &context)?; let varargs = build_resource_method_varargs(¶ms, &directives, path, &context)?; + let api_omitted = build_resource_method_api_omit(&directives, path, &context)?; let method = ResourceMethodSpec { name: method_name, @@ -3054,10 +3055,37 @@ fn build_resource_method( result, operation_name: build_resource_method_operation_name(&directives, path, &context)?, varargs, + api_omitted, }; Ok((method, close)) } +/// A method that no target keeps has no use, so the directive must leave one target its method. +fn build_resource_method_api_omit( + directives: &[Directive], + path: &Path, + context: &str, +) -> Result> { + let Some(api_omit) = directive(directives, "api-omit", path, context)? else { + return Ok(BTreeSet::new()); + }; + let targets = build_origin_targets(api_omit, path, context, "api-omit")? + .into_iter() + .collect::>(); + if targets.contains(&GenerationTarget::Workflow) && targets.contains(&GenerationTarget::Client) + { + return Err(Error::InvalidWitDirective { + path: path.to_path_buf(), + context: context.to_string(), + directive: "@nexus.api-omit".to_string(), + reason: "a resource method needs `targets=\"workflow\"` or `targets=\"client\"`. To \ + leave the method out of every target, remove the method instead" + .to_string(), + }); + } + Ok(targets) +} + /// A scope's end calls the close method with no arguments, so its parameters must be optional. fn build_resource_method_close( params: &[ResourceFieldSpec], diff --git a/src/parser/wit/annotations.rs b/src/parser/wit/annotations.rs index 6ac8e8a6a..37365306e 100644 --- a/src/parser/wit/annotations.rs +++ b/src/parser/wit/annotations.rs @@ -184,7 +184,7 @@ const CATALOG: &[Annotation] = { }, Annotation { name: "api-omit", - sites: &[Field, Function], + sites: &[Field, Method, Function], keys: &["targets"], language_keys: &[], repeatable: false, diff --git a/src/planning/mod.rs b/src/planning/mod.rs index 623a9438e..4439f3c21 100644 --- a/src/planning/mod.rs +++ b/src/planning/mod.rs @@ -6,6 +6,7 @@ use prost_types::FileOptions; use crate::descriptors::DescriptorIndex; use crate::error::{Error, Result}; +use crate::nexgen_config::GenerationTarget; use crate::spec::{ AliasTypeSpec, ApiSpec, ApiSpecTransform, ExternalTypeSourceSpec, ExternalTypeSpec, ExternalVariantSourceSpec, FunctionArgSpec, FunctionArgsSpec, FunctionFieldSpec, @@ -190,9 +191,38 @@ pub(crate) struct PlannedResourceMethod { pub(crate) params: Vec, pub(crate) result: Option, pub(crate) varargs: Option, + /// Targets whose resource class leaves this method out, from `@nexus.api-omit`. + pub(crate) api_omitted: BTreeSet, pub(crate) binding: PlannedResourceMethodBindingSpec, } +impl PlannedResource { + /// The methods of the resource class of `target`. + pub(crate) fn methods_for( + &self, + target: GenerationTarget, + ) -> impl Iterator { + self.methods + .iter() + .filter(move |method| !method.omitted_for(target)) + } + + /// A target without the close method has nothing to call at the end of a scope, so its + /// resource is not a context manager. + pub(crate) fn close_for(&self, target: GenerationTarget) -> Option<&str> { + let close = self.close.as_deref()?; + self.methods_for(target) + .any(|method| method.name == close) + .then_some(close) + } +} + +impl PlannedResourceMethod { + pub(crate) fn omitted_for(&self, target: GenerationTarget) -> bool { + self.api_omitted.contains(&target) + } +} + #[derive(Debug, Clone, PartialEq)] pub(crate) struct PlannedResourceMethodResult { pub(crate) kind: PlannedResourceMethodResultKind, @@ -371,3 +401,55 @@ impl PlannedProtoType { } } } + +#[cfg(test)] +mod tests { + use super::*; + + fn method(name: &str, api_omitted: &[GenerationTarget]) -> PlannedResourceMethod { + PlannedResourceMethod { + name: name.to_string(), + doc: None, + params: Vec::new(), + result: None, + varargs: None, + api_omitted: api_omitted.iter().copied().collect(), + binding: PlannedResourceMethodBindingSpec::Stub, + } + } + + fn method_names(resource: &PlannedResource, target: GenerationTarget) -> Vec<&str> { + resource + .methods_for(target) + .map(|method| method.name.as_str()) + .collect() + } + + #[test] + fn a_target_drops_the_methods_and_the_close_that_it_omits() { + let resource = PlannedResource { + name: "stream".to_string(), + doc: None, + type_name: "Stream".to_string(), + type_parameters: Vec::new(), + fields: Vec::new(), + methods: vec![ + method("append", &[GenerationTarget::Workflow]), + method("close", &[GenerationTarget::Workflow]), + method("read", &[]), + ], + close: Some("close".to_string()), + }; + + assert_eq!( + method_names(&resource, GenerationTarget::Workflow), + ["read"] + ); + assert_eq!(resource.close_for(GenerationTarget::Workflow), None); + assert_eq!( + method_names(&resource, GenerationTarget::Client), + ["append", "close", "read"] + ); + assert_eq!(resource.close_for(GenerationTarget::Client), Some("close")); + } +} diff --git a/src/planning/resource_resolution.rs b/src/planning/resource_resolution.rs index ad5b6775d..2306fa365 100644 --- a/src/planning/resource_resolution.rs +++ b/src/planning/resource_resolution.rs @@ -9,6 +9,7 @@ use prost_types::field_descriptor_proto::Type; use crate::descriptors::{DescriptorIndex, real_oneof_groups}; use crate::error::{Error, Result}; +use crate::nexgen_config::GenerationTarget; use crate::spec::{ ApiSpec, ApiSpecTransform, ExternalTypeSpec, FieldPolicy, IntSpec, JsonModelSpec, OperationSpec, RecordFieldSpec, RecordSpec, ResourceFieldSpec, ResourceMethodSpec, @@ -126,6 +127,7 @@ pub(crate) struct ResolvedResourceMethodSpec { pub(crate) params: Vec>, pub(crate) result: Option>, pub(crate) varargs: Option, + pub(crate) api_omitted: BTreeSet, pub(crate) binding: ResolvedResourceMethodBinding, } @@ -435,6 +437,7 @@ fn resolve_resource_method( params: bytes_bound_params(spec, &method.params, &request_plan), result: method.result.clone(), varargs: method.varargs.clone(), + api_omitted: method.api_omitted.clone(), binding: ResolvedResourceMethodBinding::Operation { operation_name: operation.name.clone(), request_plan, @@ -506,6 +509,7 @@ fn resolve_resource_method( params, result: method.result.clone(), varargs: method.varargs.clone(), + api_omitted: method.api_omitted.clone(), binding, }) } diff --git a/src/planning/type_planning.rs b/src/planning/type_planning.rs index 4619dae2c..b381ff056 100644 --- a/src/planning/type_planning.rs +++ b/src/planning/type_planning.rs @@ -863,6 +863,7 @@ impl<'a> TypePlanningContext<'a> { .as_ref() .map(|result| self.planned_resource_method_result(service, result)), varargs: method.varargs.clone(), + api_omitted: method.api_omitted.clone(), binding, }) }) diff --git a/src/spec.rs b/src/spec.rs index 5e677424d..96bbb799f 100644 --- a/src/spec.rs +++ b/src/spec.rs @@ -766,6 +766,9 @@ pub struct ResourceMethodSpec { pub operation_name: Option, /// Set by `@nexus.varargs` to the name of its list parameter. pub varargs: Option, + /// Targets whose resource class leaves this method out, from `@nexus.api-omit`. The bound + /// operation stays in the service and in every other target. + pub api_omitted: BTreeSet, } #[derive(Debug, Clone, PartialEq)] @@ -831,6 +834,7 @@ impl ResourceMethodSpec { result: self.result.map(|result| result.map_names_with(map)), operation_name: self.operation_name, varargs: self.varargs, + api_omitted: self.api_omitted, } } }