name: corvus-ctj-handler-implementation
description: >
Implement OpenAPI server handlers using Corvus.Text.Json generated types.
Covers the workspace-owned lifetime model, the Read/ReadMutable store pattern,
builder pattern for responses, From() for zero-copy cross-namespace values,
TryX out-parameter pattern for lookups, EnumerateArray() iteration, and the
two-hop cast requirement for Mutable→Source. USE FOR: implementing handler
methods for generated IApi*Handler interfaces, reading/writing blob stores
with correct document lifetime, building response bodies, avoiding memory
leaks and use-after-free. DO NOT USE FOR: code generation itself
(use corvus-codegen), mutable document manipulation details
(use corvus-mutable-documents), parsing standalone documents
(use corvus-parsed-documents-and-memory).
CTJ Handler Implementation
The Golden Rule
Any data that reaches a response body must be owned by the workspace.
The result factory (e.g., ListTodosResult.Ok(body, workspace)) calls CreateBuilder(workspace, body) which wraps the Source's backing memory — it does NOT deep-copy. If the backing memory is freed before serialization, the response is corrupted.
Document Lifetime
Workspace-Owned (correct for handlers)
var (builder, etag) = await store.ReadMutableAsync(sasToken, workspace, ct);
TodoList.Mutable list = builder.RootElement;
// builder is owned by workspace — lives until request ends
return ListTodosResult.Ok(body: (TodoList)list, workspace: workspace);
Caller-Owned (only for inspect-and-discard)
using var doc = await store.ReadAsync(sasToken, ct);
bool exists = doc is not null;
// Safe: nothing from doc reaches the response
return exists ? OkResult(...) : NotFoundResult(...);
NEVER use ParseValue
ParseValue() is being marked [Obsolete]. It creates a self-owned copy with no deterministic disposal. Always use ParsedJsonDocument<T>.Parse*() or JsonDocumentBuilder<T>.Parse(workspace, ...).
Store Pattern: Read vs ReadMutable
Provide two overloads on your store type:
public sealed class MyStore
{
/// Read-only — caller owns and must dispose. For inspect-and-discard only.
public async Task<ParsedJsonDocument<T>?> ReadAsync(
JsonString sasToken, CancellationToken ct) { ... }
/// Mutable — workspace owns lifetime. For any path where data reaches response.
public async Task<(JsonDocumentBuilder<T.Mutable> Builder, ETag ETag)?> ReadMutableAsync(
JsonString sasToken, JsonWorkspace workspace, CancellationToken ct) { ... }
}
When to use which:
| Scenario | Method |
|---|---|
| Data reaches response body (list, get, create, update) | ReadMutableAsync |
| Pure existence check, no data in response | ReadAsync with using |
| Mutation needed (add/remove/update items) | ReadMutableAsync |
Building Response Bodies
From literals/new data (Build pattern)
return CreateTodoResult.Created(
body: TodoItem.Build((ref TodoItem.Builder b) =>
{
b.Create(id: Guid.NewGuid(), title: body.Title, status: "pending"u8);
}),
workspace: workspace);
From existing workspace-owned data (pass-through)
var (builder, _) = await store.ReadMutableAsync(sasToken, workspace, ct);
// Cast needed: Mutable → T (one hop), then T → Source (implicit)
return ListTodosResult.Ok(body: (TodoList)builder.RootElement, workspace: workspace);
The Two-Hop Cast
C# does not chain implicit conversions. Mutable → T is implicit, T → Source is implicit, but Mutable → Source requires an explicit cast to bridge the first hop:
// ❌ Won't compile — no direct Mutable → Source conversion
return OkResult(body: mutableItem, workspace);
// ✅ Cast to immutable type; implicit conversion to Source handles the rest
return OkResult(body: (TodoItem)mutableItem, workspace);
Note: The CTJ002 analyzer may incorrectly flag this cast as unnecessary (see issue #775). Suppress or ignore — the cast is required.
Zero-Copy Cross-Namespace Values: From<T>()
Every generated type has a static From<T>() method that reinterprets backing memory as a different type — zero allocation:
// Cross-namespace: DirectoryStorage.JsonString → Directory.JsonString
Directory.JsonString.From(user.Value.DisplayName)
// Same pattern for UUIDs, emails, etc.
Directory.JsonUuid.From(accepted.Ticket)
Use From<T>() whenever passing values between types from different generated namespaces. Within the same namespace, implicit conversion works directly.
Array Enumeration
Generated array types do NOT implement IEnumerable<T>. You must call EnumerateArray():
// ❌ Won't compile
foreach (TodoItem item in list) { ... }
// ✅ Correct
foreach (TodoItem item in list.EnumerateArray()) { ... }
// Mutable arrays return mutable elements
foreach (TodoItem.Mutable item in list.EnumerateArray()) { ... }
TryX Pattern for Lookups
Use bool Try...(out T result) rather than nullable returns:
private static bool TryFindItem(TodoList list, JsonString todoId, out TodoItem foundItem)
{
using var utf8Id = todoId.GetUtf8String();
foreach (TodoItem item in list.EnumerateArray())
{
if (item.Id.ValueEquals(utf8Id.Span))
{
foundItem = item;
return true;
}
}
foundItem = default;
return false;
}
Conditional/Optional Values
For optional properties that may be undefined:
// Check before accessing
if (!item.DueDate.IsUndefined())
{
mutableItem.SetDueDate(update.DueDate);
}
// Ternary for Source construction
email: user.HasValue && !user.Value.Email.IsUndefined()
? Directory.JsonEmail.From(user.Value.Email)
: default
default for a Source produces an undefined value (omitted from output).
AddItem on Array Builders
Array builder AddItem() takes T.Source, not T.Mutable. Since C# won't chain two implicit conversions, cast mutable items:
// ❌ Won't compile — Mutable has no direct → Source conversion
foreach (DirectoryUser.Mutable existing in directory.Users.EnumerateArray())
ab.AddItem(existing);
// ✅ Cast to immutable (one hop), then implicit to Source (second hop)
foreach (DirectoryUser.Mutable existing in directory.Users.EnumerateArray())
ab.AddItem((DirectoryUser)existing);
Serialization
Use Corvus.Text.Json.Utf8JsonWriter (NOT System.Text.Json.Utf8JsonWriter):
using var ms = new MemoryStream();
using (var writer = new Corvus.Text.Json.Utf8JsonWriter(ms))
{
builder.WriteTo(writer);
}
ms.Position = 0;
await blob.UploadAsync(ms, options, ct);
Common Pitfalls
| Pitfall | Consequence | Fix |
|---|---|---|
using ParsedJsonDocument when data reaches response |
Use-after-free / corrupted response | Use ReadMutableAsync into workspace |
ParseValue() anywhere |
Memory leak (no disposal) | Use ParsedJsonDocument.Parse*() or JsonDocumentBuilder.Parse(workspace, ...) |
Missing cast (T)mutable when passing to Source-typed parameter |
CS1503 compile error | Add explicit cast to immutable type |
| Removing cast because CTJ002 says unnecessary | CS1503 compile error (two-hop chain broken) | Keep the cast; suppress CTJ002 |
foreach (T item in array) without .EnumerateArray() |
CS1579 compile error | Use .EnumerateArray() |
From() within same namespace |
Unnecessary — implicit conversion works | Remove From(), use value directly |
Returning T? from lookup helpers |
Forces boxing/nullable overhead on struct types | Use bool TryX(out T result) pattern |
Using System.Text.Json.Utf8JsonWriter |
Wrong writer type; won't serialize CTJ types | Use Corvus.Text.Json.Utf8JsonWriter |
Cross-References
- For workspace and builder mechanics, see
corvus-mutable-documents - For parsing and memory model, see
corvus-parsed-documents-and-memory - For code generation from OpenAPI specs, see
corvus-codegen - For analyzer diagnostics (CTJ001–CTJ006), see
corvus-analyzers