Skip to content
Draft
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
10 changes: 10 additions & 0 deletions packages/http-client-csharp/docs/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,3 +99,13 @@ Notes:
- Plugins loaded via the `plugins` option are applied in addition to any plugins discovered through `node_modules`.

After the assemblies are loaded, every discovered `GeneratorPlugin` has its `Apply` method invoked on the selected generator before generation runs, so all of your registered visitors, rewriters, and references take effect.

## Long-running operation metadata

Custom generators can read TCGC `0.74.0` selections through `InputLongRunningServiceMetadata.StatusMetadata` and `OperationId`. The shared emitter transports these selections; it does not generate poller accessors or change polling behavior.

Each `InputLroStatusMetadata` contains the selected `InputModelProperty` in `Property` and an ordered, typed `PropertySegments` path relative to `pollingInfo.responseModel`, not the final result. Segments preserve client names, wire serialization names, types, and optionality. Status selections retain TCGC's direct-then-inherited order. Consumers must read values from the latest monitor response, including initial monitor, failed, and canceled responses, and clear absent values rather than retain stale ones. Values are not continuation-token state; the next poll repopulates them after rehydration.

`OperationId` preserves TCGC's availability order: `InputLroOperationIdPathMetadata` first, then `InputLroOperationIdPropertyMetadata` when both are selected. The path entry carries `Source` (the polling link's `Location` and `Property`), `PathParameter`, and the modeled monitor route in `PollingPath`. Use the parameter's wire name and route to locate the ID; it need not be the last URI segment. The property entry carries `Property` and typed `PropertySegments`, rooted at the same monitor model as status selections. Keep the URI-derived ID available as a fallback and after rehydration, but continue polling with the complete polling URL, never the extracted ID.

Both collections are empty when selections are absent. Existing LRO constructors and final-response metadata remain unchanged. A custom generator using these fields must consume matching builds of the shared npm emitter and `Microsoft.TypeSpec.Generator.Input` assembly.
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import {
} from "@typespec/compiler";
import { unsafe_getEventDefinitions } from "@typespec/events/experimental";
import type { HttpStatusCodeRange } from "@typespec/http";
import { getHeaderFieldName } from "@typespec/http";
import { isTerminalEvent } from "@typespec/sse";
import type { CSharpEmitterContext } from "../sdk-context.js";
import { collectionFormatToDelimMap } from "../type/collection-format.js";
Expand Down Expand Up @@ -79,7 +80,7 @@ import { getExternalDocs } from "./decorators.js";
import { fromSdkHttpExamples } from "./example-converter.js";
import { getExperimentalDetails } from "./experimental.js";
import { createDiagnostic } from "./lib.js";
import { fromSdkType } from "./type-converter.js";
import { fromSdkModelProperty, fromSdkType } from "./type-converter.js";
import { getClientNamespaceString, isReadOnly } from "./utils.js";

export function fromSdkServiceMethod(
Expand Down Expand Up @@ -726,6 +727,15 @@ function loadLongRunningMetadata(
): [InputLongRunningServiceMetadata, readonly Diagnostic[]] {
const diagnostics = createDiagnosticCollector();

const metadata = method.lroMetadata;
const convertProperty = (property: SdkModelPropertyType) => {
const converted = diagnostics.pipe(
fromSdkModelProperty(sdkContext, property, metadata.pollingInfo.responseModel),
);
compilerAssert(converted, "LRO metadata must reference a supported model property.");
return converted;
};

return diagnostics.wrap({
finalStateVia: convertLroFinalStateVia(method.lroMetadata.finalStateVia),
finalResponse: {
Expand All @@ -740,6 +750,48 @@ function loadLongRunningMetadata(
: undefined,
} as OperationResponse,
resultPath: method.lroMetadata.finalResultPath,
statusMetadata: metadata.statusMetadata?.map((entry) => ({
property: convertProperty(entry.property),
propertySegments: entry.propertySegments.map(convertProperty),
})),
operationId: metadata.operationId?.map((entry) => {
if (entry.kind === "property") {
return {
kind: "property",
property: convertProperty(entry.property),
propertySegments: entry.propertySegments.map(convertProperty),
};
}

const step = metadata.statusMonitorStep;
const pollingOperation =
step?.kind === "nextOperationLink" ? step.operation : step?.target.operation;
compilerAssert(pollingOperation, "A path operation ID requires a linked polling operation.");
const pathParameter = diagnostics.pipe(
fromParameter(sdkContext, entry.pathParameter, method.apiVersions),
);
compilerAssert(pathParameter?.kind === "path", "An operation ID requires a path parameter.");
const sourceProperty = convertProperty(entry.source.property);
if (entry.source.location === "ResponseHeader") {
compilerAssert(
entry.source.property.__raw,
"A polling header must have a source property.",
);
sourceProperty.serializedName = getHeaderFieldName(
sdkContext.program,
entry.source.property.__raw,
);
}
return {
kind: "path",
source: {
location: entry.source.location,
property: sourceProperty,
},
pathParameter,
pollingPath: pollingOperation.path,
};
}),
});
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -286,7 +286,7 @@ function fromSdkModelType(
return diagnostics.wrap(inputModelType);
}

function fromSdkModelProperty(
export function fromSdkModelProperty(
sdkContext: CSharpEmitterContext,
sdkProperty: SdkModelPropertyType,
sdkModel: SdkModelType,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,13 @@
// Licensed under the MIT License. See License.txt in the project root for license information.

import type { InputOperation } from "./input-operation.js";
import type { InputMethodParameter, InputParameter, InputType } from "./input-type.js";
import type {
InputMethodParameter,
InputModelProperty,
InputParameter,
InputPathParameter,
InputType,
} from "./input-type.js";
import type { OperationFinalStateVia } from "./operation-final-state-via.js";
import type { OperationResponse } from "./operation-response.js";
import type { ResponseLocation } from "./response-location.js";
Expand Down Expand Up @@ -75,8 +81,34 @@ export interface InputLongRunningServiceMetadata {
finalStateVia: OperationFinalStateVia;
finalResponse: OperationResponse;
resultPath?: string;
statusMetadata?: InputLroStatusMetadata[];
operationId?: InputLroOperationIdMetadata[];
}

export interface InputLroStatusMetadata {
property: InputModelProperty;
/** Typed path relative to the polling response model, not the final result. */
propertySegments: InputModelProperty[];
}

export interface InputOperationLink {
location: "ResponseHeader" | "ResponseBody" | "Self";
property: InputModelProperty;
}

export type InputLroOperationIdMetadata =
| {
kind: "path";
source: InputOperationLink;
pathParameter: InputPathParameter;
pollingPath: string;
}
| {
kind: "property";
property: InputModelProperty;
propertySegments: InputModelProperty[];
};

export interface InputLongRunningPagingServiceMethod extends InputServiceMethodBase {
kind: "lropaging";
lroMetadata: InputLongRunningServiceMetadata;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,127 @@ describe("Operation Converter", () => {
runner = await createEmitterTestHost();
});

it.each(["both", "body", "path", "none"])(
"preserves selected LRO metadata and ordered operation ID sources: %s",
async (selection) => {
const selected = selection !== "none";
const program = await typeSpecCompile(
`
model Statistics { completed: int32; }
model BaseMonitor {
${selected ? "@lroStatusMetadata" : ""}
@clientName("updatedAt")
@encodedName("application/json", "last_updated")
updated?: utcDateTime;
}
model Monitor extends BaseMonitor {
@lroStatus status: "Running" | "Succeeded" | "Failed" | "Canceled";
@lroResult result?: string;
${["both", "body"].includes(selection) ? "@lroOperationId" : ""}
@clientName("jobId")
@encodedName("application/json", "job_id")
id: string;
${selected ? "@lroStatusMetadata" : ""}
@clientName("progress")
@encodedName("application/json", "percent_complete")
percent?: int32;
${selected ? "@lroStatusMetadata" : ""}
statistics?: Statistics;
}
@get @route("/jobs/{jobId}/status")
op getStatus(
${["both", "path"].includes(selection) ? "@lroOperationId" : ""}
@path("jobId") id: string
): Monitor;
@post @route("/jobs")
@pollingOperation(getStatus)
op begin(): {
@statusCode code: 202;
@pollingLocation @header("Operation-Location") location: ResourceLocation<Monitor>;
};
`,
runner,
{ IsAzureCoreNeeded: true, IsTCGCNeeded: true },
);
const sdkContext = await createCSharpSdkContext(createEmitterContext(program));
const [root, diagnostics] = createModel(sdkContext);
deepStrictEqual(diagnostics, []);
const method = root.clients[0].methods.find((m) => m.name === "begin");
ok(method?.kind === "lro");
const metadata = method.lroMetadata;
if (!selected) {
strictEqual(metadata.statusMetadata, undefined);
strictEqual(metadata.operationId, undefined);
const json = serializeCodeModel(sdkContext, root);
strictEqual(json.includes('"statusMetadata"'), false);
strictEqual(json.includes('"operationId"'), false);
return;
}
deepStrictEqual(
metadata.statusMetadata?.map((entry) => [
entry.property.name,
entry.property.optional,
entry.propertySegments.map((p) => p.serializedName),
]),
[
["progress", true, ["percent_complete"]],
["statistics", true, ["statistics"]],
["updatedAt", true, ["last_updated"]],
],
);
for (const entry of metadata.statusMetadata!) {
strictEqual(entry.propertySegments[0], entry.property);
}
const statistics = metadata.statusMetadata![1].property.type;
strictEqual(
statistics,
root.models.find((m) => m.name === "Statistics"),
);
ok(statistics.kind === "model");
strictEqual(statistics.properties[0].name, "completed");
deepStrictEqual(
metadata.operationId?.map((entry) => entry.kind),
selection === "both" ? ["path", "property"] : [selection === "body" ? "property" : "path"],
);
for (const entry of metadata.operationId!) {
if (entry.kind === "path") {
strictEqual(entry.pollingPath, "/jobs/{jobId}/status");
strictEqual(entry.pathParameter.serializedName, "jobId");
deepStrictEqual(
entry.pathParameter,
root.clients[0].methods
.find((m) => m.name === "getStatus")!
.operation.parameters.find((p) => p.kind === "path"),
);
strictEqual(entry.source.location, "ResponseHeader");
strictEqual(entry.source.property.serializedName, "Operation-Location");
} else {
strictEqual(entry.property.name, "jobId");
strictEqual(entry.propertySegments[0], entry.property);
strictEqual(entry.property.serializedName, "job_id");
}
}
const serialized = JSON.parse(serializeCodeModel(sdkContext, root));
const serializedMetadata = serialized.clients[0].methods.find(
(m: { name: string }) => m.name === "begin",
).lroMetadata;
deepStrictEqual(
serializedMetadata.statusMetadata.map(
(entry: { property: object; propertySegments: object[] }) => entry.propertySegments[0],
),
serializedMetadata.statusMetadata.map(
(entry: { property: { $ref: string; $id: string } }) => ({
$ref: entry.property.$ref ?? entry.property.$id,
}),
),
);
deepStrictEqual(
serializedMetadata.operationId.map((entry: { kind: string }) => entry.kind),
metadata.operationId!.map((entry) => entry.kind),
);
},
);

it.each([undefined, "", 'Use "read" instead.\nSee C:\\sdk.'])(
"preserves operation deprecation metadata: %s",
async (message) => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,24 +1,42 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

using System.Collections.Generic;
using System.Linq;

namespace Microsoft.TypeSpec.Generator.Input
{
public sealed class InputLongRunningServiceMetadata
{
public InputLongRunningServiceMetadata(int finalStateVia, InputOperationResponse finalResponse, string? resultPath)
: this(finalStateVia, finalResponse, resultPath, [], [])
{
}

public InputLongRunningServiceMetadata(int finalStateVia, InputOperationResponse finalResponse, string? resultPath,
IReadOnlyList<InputLroStatusMetadata> statusMetadata, IReadOnlyList<InputLroOperationIdMetadata> operationId)
{
FinalStateVia = finalStateVia;
FinalResponse = finalResponse;
ResultPath = resultPath;
StatusMetadata = statusMetadata;
OperationId = operationId;
}

public InputLongRunningServiceMetadata() : this(1, new InputOperationResponse(), null) { }

public int FinalStateVia { get; }
public InputOperationResponse FinalResponse { get; }
public string? ResultPath { get; }
/// <summary>
/// Auxiliary values from the latest polling response, in selection order.
/// </summary>
public IReadOnlyList<InputLroStatusMetadata> StatusMetadata { get; }
/// <summary>
/// Server-issued ID sources in availability order: polling URL, then polling response body.
/// These selections do not replace the complete polling URL.
/// </summary>
public IReadOnlyList<InputLroOperationIdMetadata> OperationId { get; }

/// <summary>
/// Meaningful return type of the long running operation.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

namespace Microsoft.TypeSpec.Generator.Input
{
public abstract class InputLroOperationIdMetadata
{
private protected InputLroOperationIdMetadata() { }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

namespace Microsoft.TypeSpec.Generator.Input
{
public sealed class InputLroOperationIdPathMetadata : InputLroOperationIdMetadata
{
public InputLroOperationIdPathMetadata(InputOperationLink source, InputPathParameter pathParameter, string pollingPath)
{
Source = source;
PathParameter = pathParameter;
PollingPath = pollingPath;
}

public InputOperationLink Source { get; }
public InputPathParameter PathParameter { get; }
/// <summary>
/// Modeled polling route used to locate the selected parameter in the complete polling URL.
/// </summary>
public string PollingPath { get; }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

using System.Collections.Generic;

namespace Microsoft.TypeSpec.Generator.Input
{
public sealed class InputLroOperationIdPropertyMetadata : InputLroOperationIdMetadata
{
public InputLroOperationIdPropertyMetadata(InputModelProperty property, IReadOnlyList<InputModelProperty> propertySegments)
{
Property = property;
PropertySegments = propertySegments;
}

public InputModelProperty Property { get; }
public IReadOnlyList<InputModelProperty> PropertySegments { get; }
}
}
Loading
Loading