Skip to content
Open
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
77 changes: 77 additions & 0 deletions packages/http-client-csharp/.tspd/docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,80 @@
### Customizing Generated Code

For detailed instructions on how to customize the generated C# code, see the [Customization Guide](https://github.com/microsoft/typespec/blob/main/packages/http-client-csharp/.tspd/docs/customization.md).

### Experimental types and members

Use `@TypeSpec.HttpClient.experimental` to assign a public diagnostic ID to a generated
type or member and identify experiments used by its implementation:

```typespec
import "@typespec/http-client";

@TypeSpec.HttpClient.experimental(#{
emitterScope: "@typespec/http-client-csharp",
diagnosticId: "C",
dependsOn: #["A", "B"],
})
op bar(): void;
```

The C# emitter adds `[Experimental("C")]` to the corresponding generated declaration:

| TypeSpec target | Generated C# target |
| ----------------------------------------------------- | ------------------------------------------------------------- |
| Model | Model class or struct |
| Model property | Property |
| Enum or union emitted as an enum | Enum or extensible-enum struct |
| Enum member or union variant emitted as an enum value | Enum field or extensible-enum property |
| Operation | Synchronous and asynchronous protocol and convenience methods |
| Namespace or interface emitted as a client | Client class |

For example, models and their properties can be separate experiments:

```typespec
@TypeSpec.HttpClient.experimental(#{ diagnosticId: "MODEL001" })
model Preview {
@TypeSpec.HttpClient.experimental(#{ diagnosticId: "PROPERTY001" })
value?: string;
}
```

Model factory methods expose the model's diagnostic. Partial serialization declarations
do not repeat the model attribute. Generated code suppresses diagnostics when referring
to source-annotated experimental types and members, so serialization, factories, and client
implementations can compile without changing the experimental status of other public APIs.

For operation dependencies, suppressions surround each method declaration and body.
For type/member dependencies and generated references to experimental declarations,
suppressions are scoped to the generated file. Both include parameter and return types,
generic arguments, and implementation references; neither suppresses diagnostics in
consumer code.

Dependencies identify diagnostics rather than individual types: one diagnostic can apply
to multiple types or members, including those defined in external libraries. For externally
mapped types, source `@experimental` metadata is retained for generated-reference suppressions;
the emitter does not generate the external declaration or add attributes to its library.
Other external experiments require explicit `dependsOn` entries. They are not discovered by reflection.

Both metadata fields are optional. Without `diagnosticId`, no public experimental attribute
Comment thread
JoshLove-msft marked this conversation as resolved.
is added. Without `dependsOn`, no additional dependency diagnostics are requested; generated
references to source-annotated experiments are still handled. Emitter scopes apply to both fields.

Diagnostic IDs must be single C# warning identifiers (ASCII letters, digits, and underscores,
not starting with a digit) or decimal warning numbers. Whitespace, punctuation, comments, and
line breaks are rejected with `invalid-experimental-diagnostic-id` before generating C#.
An `ExperimentalAttribute` on a customized partial client or method takes precedence over
the generated attribute.

Some TypeSpec declarations have no corresponding C# declaration, such as scalars or unions
erased to built-in C# types without an explicit external mapping. C# also does not allow `ExperimentalAttribute` on parameters.
The emitter reports `experimental-target-not-supported` for these annotations instead of
silently dropping them or assigning their diagnostic to an unrelated API.

Models referenced by a union retain their own experimental metadata. Annotate the model
declaration, not the union variant that references it: a model variant has no separate C#
declaration on which to place an attribute.

Graduation is an explicit source change: dependencies becoming generally available, or
removing entries from `dependsOn`, does not remove `[Experimental("C")]`. Remove the
declaration's `@experimental` decorator when the public API is ready to graduate.
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import type {
InputParameter,
InputType,
} from "../type/input-type.js";
import { getExperimentalDetails } from "./experimental.js";
import { createDiagnostic } from "./lib.js";
import {
fromMethodParameter,
Expand Down Expand Up @@ -101,6 +102,7 @@ function fromSdkClient(
parent: undefined,
children: undefined,
isMultiServiceClient: isMultiService,
experimental: diagnostics.pipe(getExperimentalDetails(sdkContext, client.__raw.type)),
};

sdkContext.__typeCache.updateSdkClientReferences(client, inputClient);
Expand Down
58 changes: 58 additions & 0 deletions packages/http-client-csharp/emitter/src/lib/experimental.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

import type { SdkContext } from "@azure-tools/typespec-client-generator-core";
import type { Diagnostic, Type } from "@typespec/compiler";
import { createDiagnosticCollector } from "@typespec/compiler";
import { $ } from "@typespec/compiler/typekit";
import "@typespec/http-client/typekit";
import type { InputExperimentalDetails } from "../type/input-type.js";
import { createDiagnostic } from "./lib.js";

export function getExperimentalDetails(
context: SdkContext,
target: Type | undefined,
supportsExperimentalMetadata = true,
): [InputExperimentalDetails | undefined, readonly Diagnostic[]] {
const diagnostics = createDiagnosticCollector();
if (!target) {
return diagnostics.wrap(undefined);
}

const lifecycle = diagnostics.pipe(
$(context.program).client.getFeatureLifecycleDetails.withDiagnostics(target, {
emitterName: "@typespec/http-client-csharp",
}),
);
if (lifecycle && !supportsExperimentalMetadata) {
diagnostics.add(createDiagnostic({ code: "experimental-target-not-supported", target }));
return diagnostics.wrap(undefined);
}

if (lifecycle) {
const ids = [
...(lifecycle.diagnosticId === undefined ? [] : [lifecycle.diagnosticId]),
...lifecycle.dependsOn,
];
for (const id of new Set(ids)) {
if (id.match(/^(?:[A-Za-z_][A-Za-z0-9_]*|[0-9]+)/u)?.[0] !== id) {
diagnostics.add(
createDiagnostic({
code: "invalid-experimental-diagnostic-id",
target,
format: { diagnosticId: JSON.stringify(id) },
}),
);
}
}
if (diagnostics.diagnostics.length > 0) {
return diagnostics.wrap(undefined);
}
}

return diagnostics.wrap(
lifecycle
? { diagnosticId: lifecycle.diagnosticId, dependsOn: [...lifecycle.dependsOn] }
: undefined,
);
}
13 changes: 13 additions & 0 deletions packages/http-client-csharp/emitter/src/lib/lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,19 @@ export type DiagnosticMessagesMap = {
};

const diags: { [code: string]: DiagnosticDefinition<DiagnosticMessages> } = {
"invalid-experimental-diagnostic-id": {
severity: "error",
messages: {
default: paramMessage`Experimental diagnostic ID ${"diagnosticId"} must be a single C# warning identifier (ASCII letters, digits, or underscores, not starting with a digit) or a decimal warning number.`,
},
},
"experimental-target-not-supported": {
severity: "error",
messages: {
default:
"This experimental declaration does not produce a C# type or member that supports ExperimentalAttribute. Apply @experimental to an emitted model, enum, property, enum member, operation, or client instead.",
},
},
"no-apiVersion": {
severity: "error",
messages: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ import { parseHttpRequestMethod } from "../type/request-method.js";
import { ResponseLocation } from "../type/response-location.js";
import { getExternalDocs, getOperationId } 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 { getClientNamespaceString, isReadOnly } from "./utils.js";
Expand Down Expand Up @@ -252,6 +253,9 @@ export function fromSdkServiceMethodOperation(
namespace: method.__raw?.namespace
? getClientNamespace(sdkContext, method.__raw.namespace)
: undefined,
experimental: diagnostics.pipe(
getExperimentalDetails(sdkContext, method.operation.__raw.operation),
),
};

sdkContext.__typeCache.updateSdkOperationReferences(method.operation, operation);
Expand Down Expand Up @@ -686,6 +690,8 @@ export function fromMethodParameter(
return diagnostics.wrap(retVar as InputMethodParameter);
}

// C# cannot apply ExperimentalAttribute directly to a parameter.
diagnostics.pipe(getExperimentalDetails(sdkContext, p.__raw, false));
const parameterType = diagnostics.pipe(fromSdkType(sdkContext, p.type, p, namespace));

const paramAlias = p.__raw ? getParamAlias(sdkContext, p.__raw) : undefined;
Expand Down
19 changes: 19 additions & 0 deletions packages/http-client-csharp/emitter/src/lib/type-converter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import type {
InputType,
InputUnionType,
} from "../type/input-type.js";
import { getExperimentalDetails } from "./experimental.js";
import { createDiagnostic } from "./lib.js";
import { isReadOnly } from "./utils.js";

Expand Down Expand Up @@ -189,6 +190,22 @@ export function fromSdkType<T extends SdkType>(
break;
}

// External declarations are not emitted, but their known diagnostics apply at generated reference sites.
retVar.experimental = diagnostics.pipe(
getExperimentalDetails(
sdkContext,
sdkType.__raw,
retVar.external !== undefined ||
(retVar.kind === "model" && !retVar.isFileType) ||
retVar.kind === "enum" ||
retVar.kind === "enumvalue",
),
);
if (sdkType.__raw?.kind === "Union" && retVar.kind !== "enum") {
for (const variant of sdkType.__raw.variants.values()) {
diagnostics.pipe(getExperimentalDetails(sdkContext, variant, false));
Comment thread
JoshLove-msft marked this conversation as resolved.
}
}
sdkContext.__typeCache.updateSdkTypeReferences(sdkType, retVar);
// we have to cast to any because TypeScript's type narrowing does not automatically infer the return type for conditional types
return diagnostics.wrap(retVar as any);
Expand Down Expand Up @@ -300,6 +317,7 @@ function fromSdkModelProperty(
isHttpMetadata: isHttpMetadata(sdkContext, sdkProperty),
encode: sdkProperty.encode,
isExactName: sdkProperty.isExactName,
experimental: diagnostics.pipe(getExperimentalDetails(sdkContext, sdkProperty.__raw)),
} as InputModelProperty;

if (sdkProperty.serializationOptions?.multipart?.isFilePart === true) {
Expand Down Expand Up @@ -520,6 +538,7 @@ function createEnumValueType(
doc: sdkType.doc,
decorators: sdkType.decorators,
isExactName: sdkType.isExactName,
experimental: diagnostics.pipe(getExperimentalDetails(sdkContext, sdkType.__raw)),
});
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

import type { DecoratorInfo } from "@azure-tools/typespec-client-generator-core";
import type { InputHttpOperationExample } from "./input-examples.js";
import type { InputHttpParameter } from "./input-type.js";
import type { InputExperimentalDetails, InputHttpParameter } from "./input-type.js";
import type { OperationResponse } from "./operation-response.js";
import type { RequestMethod } from "./request-method.js";

Expand All @@ -29,4 +29,5 @@ export interface InputOperation {
crossLanguageDefinitionId: string;
decorators?: DecoratorInfo[];
namespace?: string;
experimental?: InputExperimentalDetails;
}
6 changes: 6 additions & 0 deletions packages/http-client-csharp/emitter/src/type/input-type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,12 @@ export interface InputNamespace extends DecoratedType {

interface DecoratedType {
decorators?: DecoratorInfo[];
experimental?: InputExperimentalDetails;
}

export interface InputExperimentalDetails {
diagnosticId?: string;
dependsOn: string[];
}

interface InputTypeBase extends DecoratedType {
Expand Down
Loading
Loading