Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 17 additions & 3 deletions GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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
Expand All @@ -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-record>) -> append-records-response;
```

---

### @nexus.source
Expand Down
14 changes: 12 additions & 2 deletions advanced/samples/dotnet/tests/StreamServiceChecks.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Turn>).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<Turn>);
Assert.Null(stream.GetMethod("AppendAsync"));
Assert.Null(stream.GetMethod("CloseAsync"));
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ public static Task<Stream<TBody>> CreateStreamAsync<TBody>(CreateStreamOptions<T
/// </summary>
/// <param name="request">Append one batch. The Store keeps a resent batch once, by Producer, epoch and sequence.</param>
[GeneratedCode("nexgen", null)]
internal static async Task<AppendRecordsResponse> AppendRecordsAsync<TBody>(AppendRecordsRequest<TBody> request)
private static async Task<AppendRecordsResponse> AppendRecordsAsync<TBody>(AppendRecordsRequest<TBody> request)
{
var client = Workflow.CreateNexusWorkflowClient("temporal.api.streamservice.v1.StreamService", StreamServiceEndpoint);
var result = await client.ExecuteNexusOperationAsync<AppendRecordsResponse>("AppendRecords", request).ConfigureAwait(true);
Expand All @@ -182,7 +182,7 @@ internal static Task<AppendRecordsResponse> AppendRecordsAsync<TBody>(AppendReco

/// <param name="request">Close a Stream. Readers drain what is left, then end. Later appends are refused.</param>
[GeneratedCode("nexgen", null)]
internal static async Task<CloseStreamResponse> CloseStreamAsync<TBody>(CloseStreamRequest<TBody> request)
private static async Task<CloseStreamResponse> CloseStreamAsync<TBody>(CloseStreamRequest<TBody> request)
{
var client = Workflow.CreateNexusWorkflowClient("temporal.api.streamservice.v1.StreamService", StreamServiceEndpoint);
var result = await client.ExecuteNexusOperationAsync<CloseStreamResponse>("CloseStream", request).ConfigureAwait(true);
Expand Down
14 changes: 0 additions & 14 deletions advanced/samples/dotnet/wit/stream-service/workflow/Resources.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,20 +24,6 @@ public Stream(StreamRef<TBody> @ref, bool? created)
public StreamRef<TBody> Ref { get; }
public bool? Created { get; }

[GeneratedCode("nexgen", null)]
public Task<AppendRecordsResponse> AppendAsync(IReadOnlyList<TBody> records, Producer? producer, long? sequence)
{
var request = new AppendRecordsRequest<TBody>(Ref, System.Linq.Enumerable.ToArray(System.Linq.Enumerable.Select(records, value => new AppendRecord<TBody>(value))), producer ?? Nexgen.Support.StreamWorkflowContext.Producer()) { Sequence = sequence };
return Operations.AppendRecordsAsync(request);
}

[GeneratedCode("nexgen", null)]
public Task CloseAsync(Producer? producer)
{
var request = new CloseStreamRequest<TBody>(Ref, producer ?? Nexgen.Support.StreamWorkflowContext.Producer());
return Operations.CloseStreamAsync(request);
}

[GeneratedCode("nexgen", null)]
public Task<ReadRecordsResponse<TBody>> ReadAsync(byte[]? fromOffset, int? maxRecords, System.TimeSpan? wait, long? maxBytes)
{
Expand Down
4 changes: 4 additions & 0 deletions advanced/samples/inputs/stream-service.wit
Original file line number Diff line number Diff line change
Expand Up @@ -279,9 +279,12 @@ interface stream-service {
resource %stream {
constructor(ref: stream-ref, created: option<bool>);

// 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<append-record>,
producer: option<producer>,
Expand All @@ -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<producer>);

/// @nexus.doc "Read one page of Records. The poll iterator reads them all."
Expand Down
Original file line number Diff line number Diff line change
@@ -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",
]
Original file line number Diff line number Diff line change
@@ -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<string>);
}

record rename-request { id: string, name: option<string> }
record rename-response { ok: option<bool> }

rename: func(request: rename-request) -> rename-response;
}
7 changes: 7 additions & 0 deletions advanced/samples/python/tests/test_sdk_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
104 changes: 57 additions & 47 deletions advanced/samples/python/tests/test_stream_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]] = []

Expand All @@ -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"])
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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"),
[
Expand Down
Loading
Loading