diff --git a/.github/workflows/python-integration.yml b/.github/workflows/python-integration.yml index 0a455af182f..2786d6221af 100644 --- a/.github/workflows/python-integration.yml +++ b/.github/workflows/python-integration.yml @@ -44,6 +44,12 @@ jobs: - uses: ./.github/actions/setup-python + - name: Install pnpm workspace dependencies + run: pnpm install --no-frozen-lockfile + + - name: Build http-client-generator-core dependency + run: pnpm -r --filter "@typespec/http-client-generator-core..." build + - name: Build and pack http-client-python from PR run: | cd core/packages/http-client-python diff --git a/packages/http-client-generator-core/README.md b/packages/http-client-generator-core/README.md new file mode 100644 index 00000000000..a3127c907ac --- /dev/null +++ b/packages/http-client-generator-core/README.md @@ -0,0 +1,57 @@ +# @typespec/http-client-generator-core + +Protocol-agnostic core library for TypeSpec client SDK generation. + +## Overview + +This package provides the foundational types, interfaces, and infrastructure for generating client SDKs from TypeSpec definitions. It is designed to be **cloud-provider agnostic** — containing no Azure-specific concepts. + +Azure-specific functionality (LRO, ARM, Azure Core utilities) is provided by `@azure-tools/typespec-client-generator-core`, which extends this package. + +## Architecture + +``` +@typespec/http-client-generator-core (this package) +├── Core type graph (SdkType, SdkModelType, SdkEnumType, etc.) +├── Client/operation interfaces (SdkClient, SdkClientType, SdkServiceMethod) +├── HTTP operation mapping (SdkHttpOperation, SdkHttpParameter) +├── Paging support (SdkPagingServiceMethod, SdkPagingServiceMetadata) +├── Decorator infrastructure (@clientName, @client, @access, @usage, etc.) +├── Context management (TCGCContext, SdkContext) +└── Example types (SdkHttpOperationExample, SdkExampleValue) + +@azure-tools/typespec-client-generator-core (Azure extension) +├── LRO support (SdkLroServiceMethod, SdkLroPagingServiceMethod, SdkLroServiceMetadata) +├── ARM detection and subscription ID handling +├── Azure Core utilities (getUnionAsEnum, isPreviewVersion) +├── Azure-specific defaults and configurations +└── Re-exports everything from core for backward compatibility +``` + +## Key Design Decisions + +### Extensible Method Types + +The `SdkServiceMethod` union in core includes only `basic` and `paging` variants: + +```typescript +type SdkCoreServiceMethod = SdkBasicServiceMethod | SdkPagingServiceMethod; +``` + +The Azure extension adds LRO variants: + +```typescript +type SdkServiceMethod = SdkCoreServiceMethod | SdkLroServiceMethod | SdkLroPagingServiceMethod; +``` + +### No Azure Dependencies + +This package has **zero** dependencies on: +- `@azure-tools/typespec-azure-core` +- `@azure-tools/typespec-azure-resource-manager` +- Any Azure-specific TypeSpec libraries + +### TSP Namespace + +Core decorators use `TypeSpec.ClientGenerator.Core` namespace. +Azure-specific decorators remain in `Azure.ClientGenerator.Core` namespace. diff --git a/packages/http-client-generator-core/docs/python-emitter-migration.md b/packages/http-client-generator-core/docs/python-emitter-migration.md new file mode 100644 index 00000000000..69bced9fe5a --- /dev/null +++ b/packages/http-client-generator-core/docs/python-emitter-migration.md @@ -0,0 +1,284 @@ +# Python Emitter Migration Example + +This document shows how the Python emitter (`@typespec/http-client-python`) would +update its imports after the TCGC split. + +## Summary + +| Import Source | What it provides | +|---|---| +| `@typespec/http-client-generator-core` | All core types, context, utilities | +| `@azure-tools/typespec-client-generator-core` | LRO types + Azure-specific helpers (re-exports core) | + +Most files switch entirely to the core package. Only files dealing with LRO or +Azure-specific helpers need the Azure extension. + +--- + +## emitter.ts + +```typescript +// BEFORE +import { createSdkContext } from "@azure-tools/typespec-client-generator-core"; + +// AFTER — createSdkContext is a core function +import { createSdkContext } from "@typespec/http-client-generator-core"; +``` + +--- + +## lib.ts + +```typescript +// BEFORE +import { + SdkContext, + SdkType, + UnbrandedSdkEmitterOptions, +} from "@azure-tools/typespec-client-generator-core"; + +// AFTER — all core types +import { + SdkContext, + SdkType, + UnbrandedSdkEmitterOptions, +} from "@typespec/http-client-generator-core"; +``` + +--- + +## types.ts + +```typescript +// BEFORE +import { + isHttpMetadata, + SdkArrayType, + SdkBuiltInType, + SdkConstantType, + SdkCredentialType, + SdkDateTimeType, + SdkDictionaryType, + SdkDurationType, + SdkEndpointType, + SdkEnumType, + SdkEnumValueType, + SdkModelPropertyType, + SdkModelType, + SdkType, + SdkUnionType, + UsageFlags, +} from "@azure-tools/typespec-client-generator-core"; + +// AFTER — all core types and utilities +import { + isHttpMetadata, + SdkArrayType, + SdkBuiltInType, + SdkConstantType, + SdkCredentialType, + SdkDateTimeType, + SdkDictionaryType, + SdkDurationType, + SdkEndpointType, + SdkEnumType, + SdkEnumValueType, + SdkModelPropertyType, + SdkModelType, + SdkType, + SdkUnionType, + UsageFlags, +} from "@typespec/http-client-generator-core"; +``` + +--- + +## utils.ts + +```typescript +// BEFORE +import { + InitializedByFlags, + SdkCredentialParameter, + SdkEndpointParameter, + SdkHeaderParameter, + SdkHttpParameter, + SdkMethod, + SdkMethodParameter, + SdkModelPropertyType, + SdkQueryParameter, + SdkServiceMethod, + SdkServiceOperation, + SdkServiceResponseHeader, + SdkType, +} from "@azure-tools/typespec-client-generator-core"; + +// AFTER — all core types +import { + InitializedByFlags, + SdkCredentialParameter, + SdkEndpointParameter, + SdkHeaderParameter, + SdkHttpParameter, + SdkMethod, + SdkMethodParameter, + SdkModelPropertyType, + SdkQueryParameter, + SdkServiceMethod, + SdkServiceOperation, + SdkServiceResponseHeader, + SdkType, +} from "@typespec/http-client-generator-core"; +``` + +--- + +## http.ts + +```typescript +// BEFORE +import { + getHttpOperationParameter, + SdkBasicServiceMethod, + SdkBodyParameter, + SdkClientType, + SdkHeaderParameter, + SdkHttpErrorResponse, + SdkHttpOperation, + SdkHttpOperationExample, + SdkHttpResponse, + SdkLroPagingServiceMethod, + SdkLroServiceMethod, + SdkMethodParameter, + SdkModelPropertyType, + SdkPagingServiceMethod, + SdkPathParameter, + SdkQueryParameter, + SdkServiceMethod, + SdkServiceResponseHeader, + SdkType, + UsageFlags, +} from "@azure-tools/typespec-client-generator-core"; + +// AFTER — split between core and azure extension +import { + getHttpOperationParameter, + SdkBasicServiceMethod, + SdkBodyParameter, + SdkClientType, + SdkHeaderParameter, + SdkHttpErrorResponse, + SdkHttpOperation, + SdkHttpOperationExample, + SdkHttpResponse, + SdkMethodParameter, + SdkModelPropertyType, + SdkPagingServiceMethod, + SdkPathParameter, + SdkQueryParameter, + SdkServiceResponseHeader, + SdkType, + UsageFlags, +} from "@typespec/http-client-generator-core"; +// LRO types come from the Azure extension +import { + SdkLroPagingServiceMethod, + SdkLroServiceMethod, +} from "@azure-tools/typespec-client-generator-core"; +// NOTE: SdkServiceMethod is re-exported from azure extension with LRO variants included +import type { SdkServiceMethod } from "@azure-tools/typespec-client-generator-core"; +``` + +--- + +## code-model.ts + +```typescript +// BEFORE +import { + SdkBasicServiceMethod, + SdkClientType, + SdkCredentialParameter, + SdkCredentialType, + SdkEndpointParameter, + SdkEndpointType, + SdkLroPagingServiceMethod, + SdkLroServiceMethod, + SdkMethodParameter, + SdkPagingServiceMethod, + SdkServiceMethod, + SdkServiceOperation, + SdkUnionType, + UsageFlags, + getCrossLanguagePackageId, + isAzureCoreModel, +} from "@azure-tools/typespec-client-generator-core"; + +// AFTER — core types from core, Azure-specific from extension +import { + SdkBasicServiceMethod, + SdkClientType, + SdkCredentialParameter, + SdkCredentialType, + SdkEndpointParameter, + SdkEndpointType, + SdkMethodParameter, + SdkPagingServiceMethod, + SdkServiceOperation, + SdkUnionType, + UsageFlags, + getCrossLanguagePackageId, +} from "@typespec/http-client-generator-core"; +// Azure-specific: LRO types + Azure model detection +import { + SdkLroPagingServiceMethod, + SdkLroServiceMethod, + SdkServiceMethod, + isAzureCoreModel, +} from "@azure-tools/typespec-client-generator-core"; +``` + +--- + +## package.json changes + +```jsonc +{ + // BEFORE + "peerDependencies": { + "@azure-tools/typespec-client-generator-core": ">=0.67.0 <1.0.0" + }, + "devDependencies": { + "@azure-tools/typespec-client-generator-core": "~0.67.0" + } + + // AFTER + "peerDependencies": { + "@typespec/http-client-generator-core": "workspace:^", + // Still needed for LRO + isAzureCoreModel (optional peer for non-Azure usage) + "@azure-tools/typespec-client-generator-core": ">=0.68.0 <1.0.0" + }, + "devDependencies": { + "@typespec/http-client-generator-core": "workspace:^", + "@azure-tools/typespec-client-generator-core": "~0.68.0" + } +} +``` + +--- + +## Key Observations + +1. **4 out of 6 files** can switch entirely to `@typespec/http-client-generator-core` + (emitter.ts, lib.ts, types.ts, utils.ts) + +2. **2 files** need split imports (http.ts, code-model.ts) because they use: + - `SdkLroServiceMethod` / `SdkLroPagingServiceMethod` — Azure LRO extension + - `isAzureCoreModel` — Azure-specific helper + - `SdkServiceMethod` (the full union including LRO) — Azure extension re-export + +3. **Future state**: If/when the Python emitter drops Azure support from its core and + moves Azure handling to a separate plugin, it could depend only on the core package. + +4. **Backward compat**: The Azure package re-exports everything from core, so existing + code continues to work without changes. Migration is opt-in and incremental. diff --git a/packages/http-client-generator-core/generated-defs/TypeSpec.ClientGenerator.Core.Legacy.ts b/packages/http-client-generator-core/generated-defs/TypeSpec.ClientGenerator.Core.Legacy.ts new file mode 100644 index 00000000000..c1eeaf81753 --- /dev/null +++ b/packages/http-client-generator-core/generated-defs/TypeSpec.ClientGenerator.Core.Legacy.ts @@ -0,0 +1,368 @@ +import type { + DecoratorContext, + DecoratorValidatorCallbacks, + Model, + ModelProperty, + Numeric, + Operation, + Type, +} from "@typespec/compiler"; + +/** + * Change the base type of a model in the client SDK. + * + * This decorator updates the model returned from TCGC so that, in the + * generated SDK, the target model inherits from a different base than the + * one declared in the spec. The TypeSpec service definition is not + * affected — only the SDK shape changes. + * + * Common real-world applications: + * + * - **Multi-level discriminated inheritance**: when discriminated subtypes + * need to inherit from a sibling rather than the discriminator root + * (e.g. `SportsCar` inheriting from `Car` instead of from `Vehicle`). + * - **Brownfield base-class alignment**: when a client SDK needs to keep + * API compatibility with a previously-generated SDK that used a + * different base — typically rebasing onto a richer Azure resource base + * such as `TrackedResource` instead of plain `Resource`. + * + * After the rebase, properties supplied by the new base chain are + * inherited; same-named properties on the target (or on intermediate + * ancestors that the rebase walked past) are deduplicated when their + * types are compatible, and a `legacy-hierarchy-building-conflict` + * warning is emitted when the types are unrelated. + * + * This decorator is considered legacy functionality and may be deprecated in + * future releases. + * + * @param target The target model that will gain legacy inheritance behavior + * @param value The model whose properties should be inherited from + * @param scope Optional parameter to specify which language emitters this applies to + * @example Build multiple levels inheritance for discriminated models. + * + * ```typespec + * @discriminator("type") + * model Vehicle { + * type: string; + * } + * + * alias CarProperties = { + * make: string; + * model: string; + * year: int32; + * } + * + * model Car extends Vehicle { + * type: "car"; + * ...CarProperties; + * } + * + * @Azure.ClientGenerator.Core.Legacy.hierarchyBuilding(Car) + * model SportsCar extends Vehicle { + * type: "sports"; + * ...CarProperties; + * topSpeed: int32; + * } + * + * ``` + * @example Replace the base class + * + * ```typespec + * model C { + * c?: string; + * } + * model B extends C { + * b?: string; + * } + * + * @Azure.ClientGenerator.Core.Legacy.hierarchyBuilding(C) + * model A extends B { + * a?: string; + * } + * // After: A extends C. A's own properties are { a, b } (b is lifted from + * // the removed intermediate parent B). C still supplies c. + * ``` + * @example Deduplicate spread properties that overlap with the new base + * + * ```typespec + * model B { + * propB: string; + * } + * + * model A { + * ...B; + * propA: string; + * } + * + * @@Legacy.hierarchyBuilding(A, B); + * // After: A extends B. Overlapping same-typed properties are dropped + * // silently, so A's own property is just { propA }. + * ``` + * @example Brownfield ARM resource rebased onto TrackedResource + * + * ```typespec + * model Resource { + * id?: string; + * name?: string; + * type?: string; + * } + * + * model TrackedResource extends Resource { + * location: string; + * tags?: Record; + * } + * + * model FooProperties { + * provisioningState?: string; + * } + * + * @Azure.ClientGenerator.Core.Legacy.hierarchyBuilding(TrackedResource) + * model Foo extends Resource { + * properties: FooProperties; + * location?: string; + * tags?: Record; + * } + * // After: Foo extends TrackedResource. Foo's own properties are + * // { properties }; location and tags are inherited from TrackedResource. + * ``` + * @example Brownfield ARM envelope dropping an ArmTagsProperty spread + * + * ```typespec + * model ArmTagsProperty { + * tags?: Record; + * } + * + * model TrackedResource { + * id?: string; + * name?: string; + * tags?: Record; + * location?: string; + * } + * + * @Azure.ClientGenerator.Core.Legacy.hierarchyBuilding(TrackedResource) + * model FooResourceWithHierarchy { + * id?: string; + * name?: string; + * ...ArmTagsProperty; + * location?: string; + * } + * // After: FooResourceWithHierarchy extends TrackedResource with no own + * // properties — every field is supplied by the new base chain. + * ``` + */ +export type HierarchyBuildingDecorator = ( + context: DecoratorContext, + target: Model, + value: Model, + scope?: string, +) => DecoratorValidatorCallbacks | void; + +/** + * Set whether a model property should be flattened or not. + * This decorator is not recommended to use for green field services. + * + * @param target The target model property that you want to flatten. + * @param scope Specifies the target language emitters that the decorator should apply. If not set, the decorator will be applied to all language emitters by default. + * You can use "!" to exclude specific languages, for example: !(java, python) or !java, !python. + * @example + * ```typespec + * model Foo { + * @flattenProperty + * prop: Bar; + * } + * model Bar { + * } + * ``` + */ +export type FlattenPropertyDecorator = ( + context: DecoratorContext, + target: ModelProperty, + scope?: string, +) => DecoratorValidatorCallbacks | void; + +/** + * Forces an operation to be treated as a Long Running Operation (LRO) by the SDK generators, + * even when the operation is not long-running on the service side. + * + * NOTE: When used, you will need to verify the operatio and add tests for the generated code + * to make sure the end-to-end works for library users, since there is a risk that forcing + * this operation to be LRO will result in errors. + * + * When applied, TCGC will treat the operation as an LRO and SDK generators should: + * - Generate polling mechanisms (pollers) + * - Return appropriate LRO-specific return types + * - Handle the operation as an asynchronous long-running process + * + * This decorator is considered legacy functionality and should only be used when + * standard TypeSpec LRO patterns are not feasible. + * + * @param target The operation that should be treated as a Long Running Operation + * @param scope Specifies the target language emitters that the decorator should apply. + * If not set, the decorator will be applied to all language emitters by default. + * You can use "!" to exclude specific languages, for example: !(java, python) or !java, !python. + * @example Force a regular operation to be treated as LRO for backward compatibility + * ```typespec + * @Azure.ClientGenerator.Core.Legacy.markAsLro + * @route("/deployments/{deploymentId}") + * @post + * op startDeployment( + * @path deploymentId: string, + * ): DeploymentResult | ErrorResponse; + * ``` + */ +export type MarkAsLroDecorator = ( + context: DecoratorContext, + target: Operation, + scope?: string, +) => DecoratorValidatorCallbacks | void; + +/** + * Forces an operation to be treated as a pageable operation by the SDK generators, + * even when the operation does not follow standard paging patterns on the service side. + * + * NOTE: When used, you will need to verify the operation and add tests for the generated code + * to make sure the end-to-end works for library users, since there is a risk that forcing + * this operation to be pageable will result in errors. + * + * When applied, TCGC will treat the operation as pageable and SDK generators should: + * - Generate paging mechanisms (iterators/async iterators) + * - Return appropriate pageable-specific return types + * - Handle the operation as a collection that may require multiple requests + * + * This decorator is considered legacy functionality and should only be used when + * standard TypeSpec paging patterns are not feasible. + * + * @param target The operation that should be treated as a pageable operation + * @param scope Specifies the target language emitters that the decorator should apply. + * If not set, the decorator will be applied to all language emitters by default. + * You can use "!" to exclude specific languages, for example: !(java, python) or !java, !python. + * @example Force a regular operation to be treated as pageable for backward compatibility + * ```typespec + * @Azure.ClientGenerator.Core.Legacy.markAsPageable + * @route("/items") + * @get + * op listItems(): ItemListResult; + * ``` + */ +export type MarkAsPageableDecorator = ( + context: DecoratorContext, + target: Operation, + scope?: string, +) => DecoratorValidatorCallbacks | void; + +/** + * Prevents an operation from being treated as a pageable operation by the SDK generators, + * even when the operation follows standard paging patterns (e.g., decorated with `@list`). + * + * When applied, the operation will be treated as a basic method: + * - The response will be the paged model itself (not the list of items) + * - The paged model will not be marked with paged result usage + * - No paging mechanisms (iterators/async iterators) will be generated + * + * This decorator is considered legacy functionality and should only be used when + * you need to override the default paging behavior for specific operations. + * + * @param target The operation that should NOT be treated as a pageable operation + * @param scope Specifies the target language emitters that the decorator should apply. + * If not set, the decorator will be applied to all language emitters by default. + * You can use "!" to exclude specific languages, for example: !(java, python) or !java, !python. + * @example Prevent a paging operation from being treated as pageable + * ```typespec + * @Azure.ClientGenerator.Core.Legacy.disablePageable + * @list + * @route("/items") + * @get + * op listItems(): ItemListResult; + * ``` + */ +export type DisablePageableDecorator = ( + context: DecoratorContext, + target: Operation, + scope?: string, +) => DecoratorValidatorCallbacks | void; + +/** + * Specifies the HTTP verb for the next link operation in a paging scenario. + * + * This decorator allows you to override the HTTP method used for fetching the next page + * when the default GET method is not appropriate. Only "POST" and "GET" are supported. + * + * This decorator is considered legacy functionality and should only be used when + * standard TypeSpec paging patterns are not sufficient. + * + * @param target The paging operation to specify next link operation behavior for + * @param verb The HTTP verb to use for next link operations. Must be "POST" or "GET". + * @param scope Specifies the target language emitters that the decorator should apply. + * If not set, the decorator will be applied to all language emitters by default. + * You can use "!" to exclude specific languages, for example: !(java, python) or !java, !python. + * @example Specify POST for next link operations + * ```typespec + * @Azure.ClientGenerator.Core.Legacy.nextLinkVerb("POST") + * @post + * op listItems(): PageResult; + * ``` + */ +export type NextLinkVerbDecorator = ( + context: DecoratorContext, + target: Operation, + verb: Type, + scope?: string, +) => DecoratorValidatorCallbacks | void; + +/** + * Sets a client-level default value for a model property or operation parameter. + * + * This decorator allows brownfield services to specify default values that will be + * used by SDK generators, maintaining backward compatibility with existing SDK users + * who may rely on default values that were previously generated from Swagger definitions. + * + * This decorator is considered legacy functionality and should only be used for + * maintaining backward compatibility in existing services. New services should use + * standard TypeSpec patterns for default values. + * + * @param target The model property or operation parameter that should have a client-level default value + * @param value The default value to be used by SDK generators (must be a string, number, or boolean literal) + * @param scope Specifies the target language emitters that the decorator should apply. + * If not set, the decorator will be applied to all language emitters by default. + * You can use "!" to exclude specific languages, for example: !(java, python) or !java, !python. + * @example Set a default value for a model property + * ```typespec + * model RequestOptions { + * @Azure.ClientGenerator.Core.Legacy.clientDefaultValue(30) + * timeout?: int32; + * + * @Azure.ClientGenerator.Core.Legacy.clientDefaultValue("standard") + * tier?: string; + * } + * ``` + * @example Set a default value for an operation parameter + * ```typespec + * op getItems( + * @Azure.ClientGenerator.Core.Legacy.clientDefaultValue(10) + * @query pageSize?: int32 + * ): Item[]; + * ``` + * @example Apply default value only for specific languages + * ```typespec + * model Config { + * @Azure.ClientGenerator.Core.Legacy.clientDefaultValue(false, "python") + * enableCache?: boolean; + * } + * ``` + */ +export type ClientDefaultValueDecorator = ( + context: DecoratorContext, + target: ModelProperty, + value: string | boolean | Numeric, + scope?: string, +) => DecoratorValidatorCallbacks | void; + +export type AzureClientGeneratorCoreLegacyDecorators = { + hierarchyBuilding: HierarchyBuildingDecorator; + flattenProperty: FlattenPropertyDecorator; + markAsLro: MarkAsLroDecorator; + markAsPageable: MarkAsPageableDecorator; + disablePageable: DisablePageableDecorator; + nextLinkVerb: NextLinkVerbDecorator; + clientDefaultValue: ClientDefaultValueDecorator; +}; diff --git a/packages/http-client-generator-core/generated-defs/TypeSpec.ClientGenerator.Core.ts b/packages/http-client-generator-core/generated-defs/TypeSpec.ClientGenerator.Core.ts new file mode 100644 index 00000000000..bee11c158fc --- /dev/null +++ b/packages/http-client-generator-core/generated-defs/TypeSpec.ClientGenerator.Core.ts @@ -0,0 +1,1268 @@ +import type { + DecoratorContext, + DecoratorValidatorCallbacks, + Enum, + EnumMember, + FunctionContext, + Interface, + Model, + ModelProperty, + Namespace, + Operation, + Scalar, + Type, + Union, +} from "@typespec/compiler"; + +/** + * Overrides the generated name for client SDK elements including clients, methods, parameters, + * unions, models, enums, and model properties. + * + * This decorator takes precedence over all other naming mechanisms, including the `name` + * property in `@client` decorator and default naming conventions. + * + * @param target The type you want to rename. + * @param rename The rename you want applied to the object. + * @param scope Specifies the target language emitters that the decorator should apply. If not set, the decorator will be applied to all language emitters by default. + * + * **Supported language identifiers:** `csharp`, `python`, `java`, `javascript`, `go`, and other language emitter names (derived from the emitter package name, e.g., `@azure-tools/typespec-csharp` → `csharp`). + * + * **Valid patterns:** + * - Single language: `"python"` + * - Multiple languages (comma-separated): `"python, java"` + * - Negation to exclude languages: `"!csharp"` or `"!(java, python)"` + * @example Rename a model + * ```typespec + * @clientName("RenamedModel") + * model TestModel { + * prop: string; + * } + * ``` + * @example Rename a model property + * ```typespec + * model TestModel { + * @clientName("renamedProp") + * prop: string; + * } + * ``` + * @example Rename a parameter + * ```typespec + * op example(@clientName("renamedParameter") parameter: string): void; + * ``` + * @example Rename an operation + * ```typespec + * @clientName("nameInClient") + * op example(): void; + * ``` + * @example Rename an operation for different language emitters + * ```typespec + * @clientName("nameForJava", "java") + * @clientName("name_for_python", "python") + * @clientName("nameForCsharp", "csharp") + * @clientName("nameForJavascript", "javascript") + * op example(): void; + * ``` + */ +export type ClientNameDecorator = ( + context: DecoratorContext, + target: Type, + rename: string, + scope?: string, +) => DecoratorValidatorCallbacks | void; + +/** + * Whether you want to generate an operation as a convenient method. + * When applied to a namespace or interface, it affects all operations within that scope unless explicitly overridden. + * + * @param target The target operation, namespace, or interface. + * @param flag Whether to generate the operation as a convenience method or not. + * @param scope Specifies the target language emitters that the decorator should apply. If not set, the decorator will be applied to all language emitters by default. + * + * **Supported language identifiers:** `csharp`, `python`, `java`, `javascript`, `go`, and other language emitter names (derived from the emitter package name, e.g., `@azure-tools/typespec-csharp` → `csharp`). + * + * **Valid patterns:** + * - Single language: `"python"` + * - Multiple languages (comma-separated): `"python, java"` + * - Negation to exclude languages: `"!csharp"` or `"!(java, python)"` + * @example Apply to a single operation + * ```typespec + * @convenientAPI(false) + * op test: void; + * ``` + * @example Apply to all operations in an interface + * ```typespec + * @convenientAPI(false) + * interface MyOperations { + * op test1(): void; + * op test2(): void; + * } + * ``` + * @example Apply to all operations in a namespace + * ```typespec + * @convenientAPI(false) + * namespace MyService { + * op test1(): void; + * op test2(): void; + * } + * ``` + */ +export type ConvenientAPIDecorator = ( + context: DecoratorContext, + target: Operation | Namespace | Interface, + flag?: boolean, + scope?: string, +) => DecoratorValidatorCallbacks | void; + +/** + * Whether you want to generate an operation as a protocol method. + * When applied to a namespace or interface, it affects all operations within that scope unless explicitly overridden. + * + * @param target The target operation, namespace, or interface. + * @param flag Whether to generate the operation as a protocol method or not. + * @param scope Specifies the target language emitters that the decorator should apply. If not set, the decorator will be applied to all language emitters by default. + * + * **Supported language identifiers:** `csharp`, `python`, `java`, `javascript`, `go`, and other language emitter names (derived from the emitter package name, e.g., `@azure-tools/typespec-csharp` → `csharp`). + * + * **Valid patterns:** + * - Single language: `"python"` + * - Multiple languages (comma-separated): `"python, java"` + * - Negation to exclude languages: `"!csharp"` or `"!(java, python)"` + * @example Apply to a single operation + * ```typespec + * @protocolAPI(false) + * op test: void; + * ``` + * @example Apply to all operations in an interface + * ```typespec + * @protocolAPI(false) + * interface MyOperations { + * op test1(): void; + * op test2(): void; + * } + * ``` + * @example Apply to all operations in a namespace + * ```typespec + * @protocolAPI(false) + * namespace MyService { + * op test1(): void; + * op test2(): void; + * } + * ``` + */ +export type ProtocolAPIDecorator = ( + context: DecoratorContext, + target: Operation | Namespace | Interface, + flag?: boolean, + scope?: string, +) => DecoratorValidatorCallbacks | void; + +/** + * Define the client generated in the client SDK. + * If there is any `@client` definition or `@operationGroup` definition, then each `@client` is a root client and each `@operationGroup` is a sub client with hierarchy. + * This decorator cannot be used along with `@clientLocation`. This decorator cannot be used as augmentation. + * + * @param target The target namespace or interface that you want to define as a client. + * @param options Optional configuration for the service. + * @param scope Specifies the target language emitters that the decorator should apply. If not set, the decorator will be applied to all language emitters by default. + * + * **Supported language identifiers:** `csharp`, `python`, `java`, `javascript`, `go`, and other language emitter names (derived from the emitter package name, e.g., `@azure-tools/typespec-csharp` → `csharp`). + * + * **Valid patterns:** + * - Single language: `"python"` + * - Multiple languages (comma-separated): `"python, java"` + * - Negation to exclude languages: `"!csharp"` or `"!(java, python)"` + * @example Basic client definition + * ```typespec + * namespace MyService {} + * + * @client({service: MyService}) + * interface MyInterface {} + * ``` + * @example Changing client name + * ```typespec + * namespace MyService {} + * + * @client({service: MyService, name: "MySpecialClient"}) + * interface MyInterface {} + * ``` + * @example + * + * + */ +export type ClientDecorator = ( + context: DecoratorContext, + target: Namespace | Interface, + options?: Type, + scope?: string, +) => DecoratorValidatorCallbacks | void; + +/** + * + * + * + * @deprecated Use `@client` instead. The `@operationGroup` decorator is deprecated. Sub clients should be represented using `@client`. + * Define the sub client generated in the client SDK. + * If there is any `@client` definition or `@operationGroup` definition, then each `@client` is a root client and each `@operationGroup` is a sub client with hierarchy. + * This decorator cannot be used along with `@clientLocation`. This decorator cannot be used as augmentation. + * @param target The target namespace or interface that you want to define as a sub client. + * @param scope Specifies the target language emitters that the decorator should apply. If not set, the decorator will be applied to all language emitters by default. + * + * **Supported language identifiers:** `csharp`, `python`, `java`, `javascript`, `go`, and other language emitter names (derived from the emitter package name, e.g., `@azure-tools/typespec-csharp` → `csharp`). + * + * **Valid patterns:** + * - Single language: `"python"` + * - Multiple languages (comma-separated): `"python, java"` + * - Negation to exclude languages: `"!csharp"` or `"!(java, python)"` + * @example + * ```typespec + * @operationGroup + * interface MyInterface{} + * ``` + */ +export type OperationGroupDecorator = ( + context: DecoratorContext, + target: Namespace | Interface, + scope?: string, +) => DecoratorValidatorCallbacks | void; + +/** + * Add usage for models/enums. + * A model/enum's default usage info is always calculated by the operations that use it. + * You can use this decorator to add additional usage info. + * When setting usage for namespaces, + * the usage info will be propagated to the models defined in the namespace. + * If the model has a usage override, the model override takes precedence. + * For example, with operation definition `op test(): OutputModel`, + * the model `OutputModel` has default usage `Usage.output`. + * After adding decorator `@@usage(OutputModel, Usage.input | Usage.json)`, + * the final usage result for `OutputModel` is `Usage.input | Usage.output | Usage.json`. + * The usage info for models will be propagated to models' properties, + * parent models, discriminated sub models. + * + * @param target The target type you want to extend usage. + * @param value The usage info you want to add for this model. It can be a single value of `Usage` enum value or a combination of `Usage` enum values using bitwise OR. + * For example, `Usage.input | Usage.output | Usage.json`. + * @param scope Specifies the target language emitters that the decorator should apply. If not set, the decorator will be applied to all language emitters by default. + * + * **Supported language identifiers:** `csharp`, `python`, `java`, `javascript`, `go`, and other language emitter names (derived from the emitter package name, e.g., `@azure-tools/typespec-csharp` → `csharp`). + * + * **Valid patterns:** + * - Single language: `"python"` + * - Multiple languages (comma-separated): `"python, java"` + * - Negation to exclude languages: `"!csharp"` or `"!(java, python)"` + * @example Add usage for model + * ```typespec + * op test(): OutputModel; + * + * // The resolved usage for `OutputModel` is `Usage.input | Usage.output | Usage.json` + * @usage(Usage.input | Usage.json) + * model OutputModel { + * prop: string + * } + * ``` + * @example Propagation of usage, all usage will be propagated to the parent model, discriminated sub models, and model properties. + * ```typespec + * // The resolved usage for `Fish` is `Usage.input | Usage.output | Usage.json` + * @discriminator("kind") + * model Fish { + * age: int32; + * } + * + * // The resolved usage for `Shark` is `Usage.input | Usage.output | Usage.json` + * @discriminator("sharktype") + * @usage(Usage.input | Usage.json) + * model Shark extends Fish { + * kind: "shark"; + * origin: Origin; + * } + * + * // The resolved usage for `Salmon` is `Usage.output | Usage.json` + * model Salmon extends Fish { + * kind: "salmon"; + * } + * + * // The resolved usage for `SawShark` is `Usage.input | Usage.output | Usage.json` + * model SawShark extends Shark { + * sharktype: "saw"; + * } + * + * // The resolved usage for `Origin` is `Usage.input | Usage.output | Usage.json` + * model Origin { + * country: string; + * city: string; + * manufacture: string; + * } + * + * @get + * op getModel(): Fish; + * ``` + */ +export type UsageDecorator = ( + context: DecoratorContext, + target: Model | Enum | Union | Namespace, + value: EnumMember | Union, + scope?: string, +) => DecoratorValidatorCallbacks | void; + +/** + * Override access for operations, models, enums and model properties. + * When setting access for namespaces, + * the access info will be propagated to the models and operations defined in the namespace. + * If the model has an access override, the model override takes precedence. + * When setting access for an operation, + * it will influence the access info for models/enums that are used by this operation. + * Models/enums that are used in any operations with `@access(Access.public)` will be set to access "public" + * Models/enums that are only used in operations with `@access(Access.internal)` will be set to access "internal". + * The access info for models will be propagated to models' properties, + * parent models, discriminated sub models. + * The override access should not be narrower than the access calculated by operation, + * and different override access should not conflict with each other, + * otherwise a warning will be added to the diagnostics list. + * Model property's access will default to public unless there is an override. + * + * @param target The target type you want to override access info. + * @param value The access info you want to set for this model or operation. It should be one of the `Access` enum values, either `Access.public` or `Access.internal`. + * @param scope Specifies the target language emitters that the decorator should apply. If not set, the decorator will be applied to all language emitters by default. + * + * **Supported language identifiers:** `csharp`, `python`, `java`, `javascript`, `go`, and other language emitter names (derived from the emitter package name, e.g., `@azure-tools/typespec-csharp` → `csharp`). + * + * **Valid patterns:** + * - Single language: `"python"` + * - Multiple languages (comma-separated): `"python, java"` + * - Negation to exclude languages: `"!csharp"` or `"!(java, python)"` + * @example Set access + * ```typespec + * // Access.internal + * @access(Access.internal) + * model ModelToHide { + * prop: string; + * } + * // Access.internal + * @access(Access.internal) + * op test: void; + * ``` + * @example Access propagation + * ```typespec + * // Access.internal + * @discriminator("kind") + * model Fish { + * age: int32; + * } + * + * // Access.internal + * @discriminator("sharktype") + * model Shark extends Fish { + * kind: "shark"; + * origin: Origin; + * } + * + * // Access.internal + * model Salmon extends Fish { + * kind: "salmon"; + * } + * + * // Access.internal + * model SawShark extends Shark { + * sharktype: "saw"; + * } + * + * // Access.internal + * model Origin { + * country: string; + * city: string; + * manufacture: string; + * } + * + * // Access.internal + * @get + * @access(Access.internal) + * op getModel(): Fish; + * ``` + * @example Access influence from operation + * ```typespec + * // Access.internal + * model Test1 { + * } + * + * // Access.internal + * @access(Access.internal) + * @route("/func1") + * op func1( + * @body body: Test1 + * ): void; + * + * // Access.public + * model Test2 { + * } + * + * // Access.public + * @route("/func2") + * op func2( + * @body body: Test2 + * ): void; + * + * // Access.public + * model Test3 { + * } + * + * // Access.public + * @access(Access.public) + * @route("/func3") + * op func3( + * @body body: Test3 + * ): void; + * + * // Access.public + * model Test4 { + * } + * + * // Access.internal + * @access(Access.internal) + * @route("/func4") + * op func4( + * @body body: Test4 + * ): void; + * + * // Access.public + * @route("/func5") + * op func5( + * @body body: Test4 + * ): void; + * + * // Access.public + * model Test5 { + * } + * + * // Access.internal + * @access(Access.internal) + * @route("/func6") + * op func6( + * @body body: Test5 + * ): void; + * + * // Access.public + * @route("/func7") + * op func7( + * @body body: Test5 + * ): void; + * + * // Access.public + * @access(Access.public) + * @route("/func8") + * op func8( + * @body body: Test5 + * ): void; + * ``` + */ +export type AccessDecorator = ( + context: DecoratorContext, + target: ModelProperty | Model | Operation | Enum | Union | Namespace, + value: EnumMember, + scope?: string, +) => DecoratorValidatorCallbacks | void; + +/** + * Customize a method's signature in the generated client SDK. + * Currently, only parameter signature customization is supported. + * This decorator allows you to specify a different method signature for the client SDK than the original definition. + * + * @param target : The target operation that you want to override. + * @param override : The override method definition that specifies the exact client method you want + * @param scope Specifies the target language emitters that the decorator should apply. If not set, the decorator will be applied to all language emitters by default. + * + * **Supported language identifiers:** `csharp`, `python`, `java`, `javascript`, `go`, and other language emitter names (derived from the emitter package name, e.g., `@azure-tools/typespec-csharp` → `csharp`). + * + * **Valid patterns:** + * - Single language: `"python"` + * - Multiple languages (comma-separated): `"python, java"` + * - Negation to exclude languages: `"!csharp"` or `"!(java, python)"` + * @example Customize parameters into an option bag + * ```typespec + * // main.tsp + * @service + * namespace MyService; + * + * op myOperation(foo: string, bar: string): void; // by default, we generate the method signature as `op myOperation(foo: string, bar: string)`; + * + * // client.tsp + * namespace MyCustomizations; + * + * model Params { + * foo: string; + * bar: string; + * } + * + * op myOperationCustomization(params: MyService.Params): void; + * + * @@override(MyService.myOperation, myOperationCustomization); // method signature is now `op myOperation(params: Params)` + * ``` + * @example Customize a parameter to be required + * ```typespec + * // main.tsp + * @service + * namespace MyService; + * + * op myOperation(foo: string, bar?: string): void; // by default, we generate the method signature as `op myOperation(foo: string, bar?: string)`; + * + * // client.tsp + * namespace MyCustomizations; + * + * op myOperationCustomization(foo: string, bar: string): void; + * + * @@override(MyService.myOperation, myOperationCustomization) + * + * // method signature is now `op myOperation(params: Params)` just for csharp // method signature is now `op myOperation(foo: string, bar: string)` + * ``` + */ +export type OverrideDecorator = ( + context: DecoratorContext, + target: Operation, + override: Operation, + scope?: string, +) => DecoratorValidatorCallbacks | void; + +/** + * Whether a model needs the custom JSON converter, this is only used for backward compatibility for csharp. + * + * @param target The target model that you want to set the custom JSON converter. + * @param scope Specifies the target language emitters that the decorator should apply. If not set, the decorator will be applied to all language emitters by default. + * + * **Supported language identifiers:** `csharp`, `python`, `java`, `javascript`, `go`, and other language emitter names (derived from the emitter package name, e.g., `@azure-tools/typespec-csharp` → `csharp`). + * + * **Valid patterns:** + * - Single language: `"python"` + * - Multiple languages (comma-separated): `"python, java"` + * - Negation to exclude languages: `"!csharp"` or `"!(java, python)"` + * @example + * ```typespec + * @useSystemTextJsonConverter + * model MyModel { + * prop: string; + * } + * ``` + */ +export type UseSystemTextJsonConverterDecorator = ( + context: DecoratorContext, + target: Model, + scope?: string, +) => DecoratorValidatorCallbacks | void; + +/** + * Allows customization of how clients are initialized in the generated SDK. + * By default, the root client is initialized independently, while sub clients are initialized through their parent client. + * Initialization parameters typically include endpoint, credential, and API version. + * With `@clientInitialization` decorator, you can elevate operation level parameters to client level, and set how the client is initialized. + * This decorator can be combined with `@paramAlias` decorator to change the parameter name in client initialization. + * + * @param target The target client that you want to customize client initialization for. + * @param options The options for client initialization. You can use `ClientInitializationOptions` model to set the options. + * @param scope Specifies the target language emitters that the decorator should apply. If not set, the decorator will be applied to all language emitters by default. + * + * **Supported language identifiers:** `csharp`, `python`, `java`, `javascript`, `go`, and other language emitter names (derived from the emitter package name, e.g., `@azure-tools/typespec-csharp` → `csharp`). + * + * **Valid patterns:** + * - Single language: `"python"` + * - Multiple languages (comma-separated): `"python, java"` + * - Negation to exclude languages: `"!csharp"` or `"!(java, python)"` + * @example Add client initialization parameters + * ```typespec + * // main.tsp + * namespace MyService; + * + * op upload(blobName: string): void; + * op download(blobName: string): void; + * + * // client.tsp + * namespace MyCustomizations; + * model MyServiceClientOptions { + * blobName: string; + * } + * + * @@clientInitialization(MyService, {parameters: MyServiceClientOptions}) + * // The generated client will have `blobName` in its initialization method. We will also + * // elevate the existing `blobName` parameter from method level to client level. + * ``` + */ +export type ClientInitializationDecorator = ( + context: DecoratorContext, + target: Namespace | Interface, + options: Type, + scope?: string, +) => DecoratorValidatorCallbacks | void; + +/** + * Alias the name of a client parameter to a different name. This permits you to have a different name for the parameter in client initialization and the original parameter in the operation. + * + * @param target The target model property that you want to alias. + * @param paramAlias The alias name you want to apply to the target model property. + * @param scope Specifies the target language emitters that the decorator should apply. If not set, the decorator will be applied to all language emitters by default. + * + * **Supported language identifiers:** `csharp`, `python`, `java`, `javascript`, `go`, and other language emitter names (derived from the emitter package name, e.g., `@azure-tools/typespec-csharp` → `csharp`). + * + * **Valid patterns:** + * - Single language: `"python"` + * - Multiple languages (comma-separated): `"python, java"` + * - Negation to exclude languages: `"!csharp"` or `"!(java, python)"` + * @example Elevate an operation parameter to client level and alias it to a different name + * ```typespec + * // main.tsp + * namespace MyService; + * + * op upload(blobName: string): void; + * + * // client.tsp + * namespace MyCustomizations; + * model MyServiceClientOptions { + * blob: string; + * } + * + * @@clientInitialization(MyService, MyServiceClientOptions) + * @@paramAlias(MyServiceClientOptions.blob, "blobName") + * + * // The `blob` property from MyServiceClientOptions will be elevated to the client level. + * // Because of @@paramAlias, it will be matched to the `blobName` operation parameter. + * ``` + */ +export type ParamAliasDecorator = ( + context: DecoratorContext, + target: ModelProperty, + paramAlias: string, + scope?: string, +) => DecoratorValidatorCallbacks | void; + +/** + * Changes the namespace of a client, model, enum or union generated in the client SDK. + * By default, the client namespace for them will follow the TypeSpec namespace. + * + * @param target The type you want to change the namespace for. + * @param rename The rename you want applied to the object + * @param scope Specifies the target language emitters that the decorator should apply. If not set, the decorator will be applied to all language emitters by default. + * + * **Supported language identifiers:** `csharp`, `python`, `java`, `javascript`, `go`, and other language emitter names (derived from the emitter package name, e.g., `@azure-tools/typespec-csharp` → `csharp`). + * + * **Valid patterns:** + * - Single language: `"python"` + * - Multiple languages (comma-separated): `"python, java"` + * - Negation to exclude languages: `"!csharp"` or `"!(java, python)"` + * @example Change a namespace to a different name + * ```typespec + * @clientNamespace("ContosoClient") + * namespace Contoso; + * ``` + * @example Move a model to a different namespace + * ```typespec + * @clientNamespace("ContosoClient.Models") + * model Test { + * prop: string; + * } + * ``` + */ +export type ClientNamespaceDecorator = ( + context: DecoratorContext, + target: Namespace | Interface | Model | Enum | Union, + rename: string, + scope?: string, +) => DecoratorValidatorCallbacks | void; + +/** + * Set an alternate type for a model property, Scalar, Model, Enum, Union, or function parameter. Note that `@encode` will be overridden by the one defined in the alternate type. + * When the source type is `Scalar`, the alternate type must be `Scalar`. + * The replaced type could be a type defined in the TypeSpec or an external type declared by type identity, package that export the type and package version. + * **Important:** External types (with `identity` property) cannot be applied to model properties. They must be applied to the type definition itself (Scalar, Model, Enum, or Union). + * + * @param target The source type to which the alternate type will be applied. + * @param alternate The alternate type to apply to the target. Can be a TypeSpec type or an ExternalType. + * @param scope Specifies the target language emitters that the decorator should apply. If not set, the decorator will be applied to all language emitters by default. + * + * **Supported language identifiers:** `csharp`, `python`, `java`, `javascript`, `go`, and other language emitter names (derived from the emitter package name, e.g., `@azure-tools/typespec-csharp` → `csharp`). + * + * **Valid patterns:** + * - Single language: `"python"` + * - Multiple languages (comma-separated): `"python, java"` + * - Negation to exclude languages: `"!csharp"` or `"!(java, python)"` + * @example Change a model property to a different type + * ```typespec + * model Foo { + * date: utcDateTime; + * } + * @@alternateType(Foo.date, string); + * ``` + * @example Change a Scalar type to a different type + * ```typespec + * scalar storageDateTime extends utcDateTime; + * @@alternateType(storageDateTime, string, "python"); + * ``` + * @example Change a function parameter to a different type + * ```typespec + * op test(@param @alternateType(string) date: utcDateTime): void; + * ``` + * @example Change a model property to a different type with language specific alternate type + * ```typespec + * model Test { + * @alternateType(unknown) + * thumbprint?: string; + * + * @alternateType(AzureLocation[], "csharp") + * locations: string[]; + * } + * ``` + * @example Use external type for DFE case + * ```typespec + * @alternateType({ + * identity: "Azure.Core.Expressions.DataFactoryExpression", + * }, "csharp") + * union Dfe { + * T, + * DfeExpression + * } + * ``` + * @example Use external type with package information + * ```typespec + * @alternateType({ + * identity: "pystac.Collection", + * package: "pystac", + * minVersion: "1.13.0", + * }, "python") + * model ItemCollection { + * // ... properties + * } + * ``` + * @example Invalid: External type on model property (will emit a warning) + * ```typespec + * model MyModel { + * field: FieldType; + * } + * // This will emit a warning - external types cannot be applied to properties + * @@alternateType(MyModel.field, { + * identity: "ExternalType", + * }, "rust"); + * + * // Correct: Apply external type to the type definition instead + * @alternateType({ + * identity: "ExternalType", + * }, "rust") + * model FieldType { + * // ... properties + * } + * ``` + */ +export type AlternateTypeDecorator = ( + context: DecoratorContext, + target: ModelProperty | Scalar | Model | Enum | Union, + alternate: Type, + scope?: string, +) => DecoratorValidatorCallbacks | void; + +/** + * Define the scope of an operation or model property. + * By default, the element will be applied to all language emitters. + * This decorator allows you to omit the element from certain languages or apply it to specific languages. + * When applied to an operation parameter (which is a `ModelProperty`), the parameter will be excluded + * from the generated method signature for the specified languages. A warning is emitted if a required + * parameter is scoped out. + * + * @param target The target operation or model property that you want to scope. + * @param scope Specifies the target language emitters that the decorator should apply. If not set, the decorator will be applied to all language emitters by default. + * + * **Supported language identifiers:** `csharp`, `python`, `java`, `javascript`, `go`, and other language emitter names (derived from the emitter package name, e.g., `@azure-tools/typespec-csharp` → `csharp`). + * + * **Valid patterns:** + * - Single language: `"python"` + * - Multiple languages (comma-separated): `"python, java"` + * - Negation to exclude languages: `"!csharp"` or `"!(java, python)"` + * @example Omit an operation from a specific language + * ```typespec + * @scope("!csharp") + * op test: void; + * ``` + * @example Apply an operation to specific languages + * ```typespec + * @scope("go") + * op test: void; + * ``` + * @example Apply a model property to specific languages + * ```typespec + * model TestModel { + * @scope("csharp") + * csharpOnlyProp: string; + * } + * ``` + * @example Exclude an operation parameter from a specific language + * ```typespec + * op test( + * name: string, + * @header("X-Custom-Header") @scope("!python") customHeader?: string, + * ): void; + * ``` + */ +export type ScopeDecorator = ( + context: DecoratorContext, + target: Operation | ModelProperty, + scope?: string, +) => DecoratorValidatorCallbacks | void; + +/** + * Specify whether a parameter is an API version parameter or not. + * By default, we detect an API version parameter by matching the parameter name with `api-version` or `apiversion`, or if the type is referenced by the `@versioned` decorator. + * Since API versions are a client parameter, we will also elevate this parameter up onto the client. + * This decorator allows you to explicitly specify whether a parameter should be treated as an API version parameter or not. + * + * @param target The target parameter that you want to mark as an API version parameter. + * @param value If true, we will treat this parameter as an api-version parameter. If false, we will not. Default is true. + * @param scope Specifies the target language emitters that the decorator should apply. If not set, the decorator will be applied to all language emitters by default. + * + * **Supported language identifiers:** `csharp`, `python`, `java`, `javascript`, `go`, and other language emitter names (derived from the emitter package name, e.g., `@azure-tools/typespec-csharp` → `csharp`). + * + * **Valid patterns:** + * - Single language: `"python"` + * - Multiple languages (comma-separated): `"python, java"` + * - Negation to exclude languages: `"!csharp"` or `"!(java, python)"` + * @example Mark a parameter as an API version parameter + * ```typespec + * namespace Contoso; + * + * op test( + * @apiVersion + * @header("x-ms-version") + * version: string + * ): void; + * ``` + * @example Mark a parameter as not presenting an API version parameter + * ```typespec + * namespace Contoso; + * op test( + * @apiVersion(false) + * @query + * api-version: string + * ): void; + * ``` + */ +export type ApiVersionDecorator = ( + context: DecoratorContext, + target: ModelProperty, + value?: boolean, + scope?: string, +) => DecoratorValidatorCallbacks | void; + +/** + * Specify additional API versions that the client can support. These versions should include those defined by the service's versioning configuration. + * This decorator is useful for extending the API version enum exposed by the client. + * It is particularly beneficial when generating a complete API version enum without requiring the entire specification to be annotated with versioning decorators, as the generation process does not depend on versioning details. + * + * @param target The target client for which you want to define additional API versions. + * @param value An enum defining the complete set of API versions the client should support, including both service-defined and additional versions. + * @param scope Specifies the target language emitters that the decorator should apply. If not set, the decorator will be applied to all language emitters by default. + * + * **Supported language identifiers:** `csharp`, `python`, `java`, `javascript`, `go`, and other language emitter names (derived from the emitter package name, e.g., `@azure-tools/typespec-csharp` → `csharp`). + * + * **Valid patterns:** + * - Single language: `"python"` + * - Multiple languages (comma-separated): `"python, java"` + * - Negation to exclude languages: `"!csharp"` or `"!(java, python)"` + * @example Add additional API versions to a client + * ```typespec + * // main.tsp + * @versioned(Versions) + * namespace Contoso { + * enum Versions { v4, v5 } + * } + * + * // client.tsp + * + * enum ClientApiVersions { v1, v2, v3, ...Contoso.Versions } + * + * @@clientApiVersions(Contoso, ClientApiVersions) + * ``` + */ +export type ClientApiVersionsDecorator = ( + context: DecoratorContext, + target: Namespace, + value: Enum, + scope?: string, +) => DecoratorValidatorCallbacks | void; + +/** + * Indicates that a model property of type `string` or a `Scalar` type derived from `string` should be deserialized as `null` when its value is an empty string (`""`). + * + * @param target The target type that you want to apply this deserialization behavior to. + * @param scope Specifies the target language emitters that the decorator should apply. If not set, the decorator will be applied to all language emitters by default. + * + * **Supported language identifiers:** `csharp`, `python`, `java`, `javascript`, `go`, and other language emitter names (derived from the emitter package name, e.g., `@azure-tools/typespec-csharp` → `csharp`). + * + * **Valid patterns:** + * - Single language: `"python"` + * - Multiple languages (comma-separated): `"python, java"` + * - Negation to exclude languages: `"!csharp"` or `"!(java, python)"` + * @example + * ```typespec + * + * model MyModel { + * scalar stringlike extends string; + * + * @deserializeEmptyStringAsNull + * prop: string; + * + * @deserializeEmptyStringAsNull + * prop: stringlike; + * } + * ``` + */ +export type DeserializeEmptyStringAsNullDecorator = ( + context: DecoratorContext, + target: ModelProperty, + scope?: string, +) => DecoratorValidatorCallbacks | void; + +/** + * Indicates that a HEAD operation should be modeled as Response. + * 404 will not raise an error, instead the service method will return `false`. + * 2xx will return `true`. Everything else will still raise an error. + * + * @param target The target operation that you want to apply this behavior to. + * @param scope Specifies the target language emitters that the decorator should apply. If not set, the decorator will be applied to all language emitters by default. + * + * **Supported language identifiers:** `csharp`, `python`, `java`, `javascript`, `go`, and other language emitter names (derived from the emitter package name, e.g., `@azure-tools/typespec-csharp` → `csharp`). + * + * **Valid patterns:** + * - Single language: `"python"` + * - Multiple languages (comma-separated): `"python, java"` + * - Negation to exclude languages: `"!csharp"` or `"!(java, python)"` + * @example + * ```typespec + * @responseAsBool + * @head + * op headOperation(): void; + * ``` + */ +export type ResponseAsBoolDecorator = ( + context: DecoratorContext, + target: Operation, + scope?: string, +) => DecoratorValidatorCallbacks | void; + +/** + * Change the operation location in the client. If the target client is not defined, use `string` to indicate a new client name. For this usage, the decorator cannot be used along with `@client` or `@operationGroup` decorators. + * Change the parameter location to operation or client. For this usage, the decorator cannot be used in the parameter defined in `@clientInitialization` decorator. + * + * @param source The operation to change location for. + * @param target The target `Namespace`, `Interface` or a string which can indicate the client. + * @param scope Specifies the target language emitters that the decorator should apply. If not set, the decorator will be applied to all language emitters by default. + * + * **Supported language identifiers:** `csharp`, `python`, `java`, `javascript`, `go`, and other language emitter names (derived from the emitter package name, e.g., `@azure-tools/typespec-csharp` → `csharp`). + * + * **Valid patterns:** + * - Single language: `"python"` + * - Multiple languages (comma-separated): `"python, java"` + * - Negation to exclude languages: `"!csharp"` or `"!(java, python)"` + * @example Move to existing sub client + * ```typespec + * @service + * namespace MoveToExistingSubClient; + * + * interface UserOperations { + * @route("/user") + * @get + * getUser(): void; + * + * @route("/user") + * @delete + * @clientLocation(AdminOperations) + * deleteUser(): void; // This operation will be moved to AdminOperations sub client. + * } + * + * interface AdminOperations { + * @route("/admin") + * @get + * getAdminInfo(): void; + * } + * ``` + * @example Move to new sub client + * ```typespec + * @service + * namespace MoveToNewSubClient; + * + * interface ProductOperations { + * @route("/products") + * @get + * listProducts(): void; + * + * @route("/products/archive") + * @post + * @clientLocation("ArchiveOperations") + * archiveProduct(): void; // This operation will be moved to a new sub client named ArchiveOperations. + * } + * ``` + * @example Move operation to root client + * ```typespec + * @service + * namespace MoveToRootClient; + * + * interface ResourceOperations { + * @route("/resource") + * @get + * getResource(): void; + * + * @route("/health") + * @get + * @clientLocation(MoveToRootClient) + * getHealthStatus(): void; // This operation will be moved to the root client of MoveToRootClient namespace. + * } + * + * ``` + * @example Move parameter from operation to client + * ```typespec + * @service + * namespace MyClient; + * + * getHealthStatus( + * @clientLocation(MyClient) // This parameter will be moved to the `.clientInitialization` parameters of `MyClient`. It will not appear on the operation-level. + * clientId: string + * ): void; + * ``` + * @example Move parameter from client to operation + * ```typespec + * // client.tsp + * + * @@clientLocation(CommonTypes.SubscriptionIdParameter.subscriptionId, get); // This will keep the `subscriptionId` parameter on the operation level instead of applying TCGC's default logic of elevating `subscriptionId` to client. + * ``` + */ +export type ClientLocationDecorator = ( + context: DecoratorContext, + source: Operation | ModelProperty, + target: Interface | Namespace | Operation | string, + scope?: string, +) => DecoratorValidatorCallbacks | void; + +/** + * Override documentation for a type in client libraries. This allows you to + * provide client-specific documentation that differs from the original documentation. + * + * @param target The target type (operation, model, enum, etc.) for which you want to apply client-specific documentation. + * @param documentation The client-specific documentation to apply + * @param mode Specifies how to apply the documentation (append or replace) + * @param scope Specifies the target language emitters that the decorator should apply. If not set, the decorator will be applied to all language emitters by default. + * + * **Supported language identifiers:** `csharp`, `python`, `java`, `javascript`, `go`, and other language emitter names (derived from the emitter package name, e.g., `@azure-tools/typespec-csharp` → `csharp`). + * + * **Valid patterns:** + * - Single language: `"python"` + * - Multiple languages (comma-separated): `"python, java"` + * - Negation to exclude languages: `"!csharp"` or `"!(java, python)"` + * @example Replacing documentation + * ```typespec + * @doc("This is service documentation") + * @clientDoc("This is client-specific documentation", DocumentationMode.replace) + * op myOperation(): void; + * ``` + * @example Appending documentation + * ```typespec + * @doc("This is service documentation.") + * @clientDoc("This additional note is for client libraries only.", DocumentationMode.append) + * model MyModel { + * prop: string; + * } + * ``` + * @example Language-specific documentation + * ```typespec + * @doc("This is service documentation") + * @clientDoc("Python-specific documentation", DocumentationMode.replace, "python") + * @clientDoc("JavaScript-specific documentation", DocumentationMode.replace, "javascript") + * op myOperation(): void; + * ``` + */ +export type ClientDocDecorator = ( + context: DecoratorContext, + target: Type, + documentation: string, + mode: EnumMember, + scope?: string, +) => DecoratorValidatorCallbacks | void; + +/** + * Pass experimental flags or options to emitters without requiring TCGC reshipping. + * This decorator is intended for temporary workarounds or experimental features and requires + * suppression to acknowledge its experimental nature. + * + * See supported client options for each language emitter here https://azure.github.io/typespec-azure/docs/howtos/generate-client-libraries/12clientOptions/ + * + * **Warning**: This decorator always emits a warning that must be suppressed, and an additional + * warning if no scope is provided (since options are typically language-specific). + * + * @param target The type you want to apply the option to. + * @param name The name of the option (e.g., "enableFeatureFoo"). + * @param value The value of the option. Can be any type; emitters will cast as needed. + * @param scope Specifies the target language emitters that the decorator should apply. If not set, the decorator will be applied to all language emitters by default. + * + * **Supported language identifiers:** `csharp`, `python`, `java`, `javascript`, `go`, and other language emitter names (derived from the emitter package name, e.g., `@azure-tools/typespec-csharp` → `csharp`). + * + * **Valid patterns:** + * - Single language: `"python"` + * - Multiple languages (comma-separated): `"python, java"` + * - Negation to exclude languages: `"!csharp"` or `"!(java, python)"` + * @example Apply an experimental option for Python + * ```typespec + * #suppress "@azure-tools/typespec-client-generator-core/client-option" "preview feature for python" + * @clientOption("enableFeatureFoo", true, "python") + * model MyModel { + * prop: string; + * } + * ``` + */ +export type ClientOptionDecorator = ( + context: DecoratorContext, + target: Type, + name: string, + value: unknown, + scope?: string, +) => DecoratorValidatorCallbacks | void; + +export type AzureClientGeneratorCoreDecorators = { + clientName: ClientNameDecorator; + convenientAPI: ConvenientAPIDecorator; + protocolAPI: ProtocolAPIDecorator; + client: ClientDecorator; + operationGroup: OperationGroupDecorator; + usage: UsageDecorator; + access: AccessDecorator; + override: OverrideDecorator; + useSystemTextJsonConverter: UseSystemTextJsonConverterDecorator; + clientInitialization: ClientInitializationDecorator; + paramAlias: ParamAliasDecorator; + clientNamespace: ClientNamespaceDecorator; + alternateType: AlternateTypeDecorator; + scope: ScopeDecorator; + apiVersion: ApiVersionDecorator; + clientApiVersions: ClientApiVersionsDecorator; + deserializeEmptyStringAsNull: DeserializeEmptyStringAsNullDecorator; + responseAsBool: ResponseAsBoolDecorator; + clientLocation: ClientLocationDecorator; + clientDoc: ClientDocDecorator; + clientOption: ClientOptionDecorator; +}; + +/** + * Replace a parameter in an operation with a new parameter definition. + * This function creates a new operation with the specified parameter replaced, + * enabling composable transformations without mutating the original operation. + * + * @param operation The operation to transform. + * @param selector The parameter to replace, specified either by name (string) or by direct reference (ModelProperty). + * @param replacement The replacement parameter. + * @returns A new operation with the parameter replaced. + * @example Making an optional parameter required + * ```typespec + * model RequiredMaxResults { + * maxResults: int32; + * } + * + * @@override(KeyVault.getSecrets, replaceParameter(KeyVault.getSecrets, "maxResults", RequiredMaxResults.maxResults)); + * ``` + * @example Chaining transformations + * ```typespec + * alias Step1 = replaceParameter(MyService.myOp, "oldParam", NewParams.newParam); + * @@override(MyService.myOp, replaceParameter(Step1, "anotherParam", NewParams.anotherParam)); + * ``` + */ +export type ReplaceParameterFunctionImplementation = ( + context: FunctionContext, + operation: Operation, + selector: string | unknown, + replacement: ModelProperty, +) => Operation; + +/** + * Remove a parameter from an operation. + * This function creates a new operation with the specified parameter removed, + * enabling composable transformations without mutating the original operation. + * + * Note: When used with `@@override`, only optional parameters can be removed. Attempting to + * remove a required parameter will result in an `override-parameters-mismatch` error. + * + * @param operation The operation to transform. + * @param selector The parameter to remove, specified either by name (string) or by direct reference (ModelProperty). + * @returns A new operation with the parameter removed. + * @example Removing an optional parameter + * ```typespec + * @@override(KeyVault.getSecrets, removeParameter(KeyVault.getSecrets, "maxResults")); + * ``` + * @example Chaining with other transformations + * ```typespec + * alias Step1 = removeParameter(MyService.myOp, "unwantedParam"); + * @@override(MyService.myOp, addParameter(Step1, NewParams.newParam)); + * ``` + */ +export type RemoveParameterFunctionImplementation = ( + context: FunctionContext, + operation: Operation, + selector: string | unknown, +) => Operation; + +/** + * Add a new parameter to an operation. + * This function creates a new operation with the additional parameter appended, + * enabling composable transformations without mutating the original operation. + * + * @param operation The operation to transform. + * @param parameter The parameter to add to the operation. + * @returns A new operation with the parameter added. + * @example Adding a required parameter + * ```typespec + * model ExtraParams { + * @header tracingId: string; + * } + * + * @@override(MyService.myOp, addParameter(MyService.myOp, ExtraParams.tracingId)); + * ``` + * @example Chaining with replaceParameter + * ```typespec + * model NewParams { + * oldParam: string; // make required + * newParam: int32; + * } + * + * alias Step1 = replaceParameter(MyService.myOp, "oldParam", NewParams.oldParam); + * @@override(MyService.myOp, addParameter(Step1, NewParams.newParam)); + * ``` + */ +export type AddParameterFunctionImplementation = ( + context: FunctionContext, + operation: Operation, + parameter: ModelProperty, +) => Operation; + +/** + * Reorder parameters of an operation according to the specified order. + * This function creates a new operation with parameters reordered as specified, + * enabling control over the parameter order in generated client SDK methods. + * + * @param operation The operation to transform. + * @param order An array of parameter names specifying the desired order. All parameters must be included. + * @returns A new operation with parameters reordered. + * @example Reordering parameters + * ```typespec + * @service + * namespace MyService; + * + * op myOp(a: string, b: string, c: string): void; + * + * // Reorder to put 'c' first, then 'a', then 'b' + * @@override(MyService.myOp, reorderParameters(MyService.myOp, #["c", "a", "b"])); + * ``` + * @example Chaining with other transformations + * ```typespec + * alias Step1 = addParameter(MyService.myOp, NewParams.newParam); + * @@override(MyService.myOp, reorderParameters(Step1, #["newParam", "existingParam"])); + * ``` + */ +export type ReorderParametersFunctionImplementation = ( + context: FunctionContext, + operation: Operation, + order: readonly string[], +) => Operation; + +export type AzureClientGeneratorCoreFunctions = { + replaceParameter: ReplaceParameterFunctionImplementation; + removeParameter: RemoveParameterFunctionImplementation; + addParameter: AddParameterFunctionImplementation; + reorderParameters: ReorderParametersFunctionImplementation; +}; diff --git a/packages/http-client-generator-core/lib/decorators.tsp b/packages/http-client-generator-core/lib/decorators.tsp new file mode 100644 index 00000000000..12d4750f5e4 --- /dev/null +++ b/packages/http-client-generator-core/lib/decorators.tsp @@ -0,0 +1,71 @@ +using Reflection; + +namespace TypeSpec.ClientGenerator.Core; + +/** + * Overrides the generated name for client SDK elements including clients, methods, parameters, + * unions, models, enums, and model properties. + * + * @param target The type you want to rename. + * @param rename The rename you want applied to the object. + * @param scope Specifies the target language emitters that the decorator should apply. + */ +extern dec clientName(target: unknown, rename: valueof string, scope?: valueof string); + +/** + * Marks a namespace, interface, or operation as a client boundary. + * + * @param target The namespace, interface, or operation to mark as a client. + * @param value Optional client configuration options. + * @param scope Specifies the target language emitters that the decorator should apply. + */ +extern dec client(target: Namespace | Interface, value?: {}, scope?: valueof string); + +/** + * Sets the access level (public or internal) for a type. + * + * @param target The model, enum, operation, or union to set access for. + * @param value The access level: "public" or "internal". + * @param scope Specifies the target language emitters that the decorator should apply. + */ +extern dec access( + target: Model | Enum | Operation | Union, + value: valueof "public" | "internal", + scope?: valueof string, +); + +/** + * Sets usage flags for a model. + * + * @param target The model or enum to set usage for. + * @param value The usage flags value. + * @param scope Specifies the target language emitters that the decorator should apply. + */ +extern dec usage(target: Model | Enum | Union, value: valueof EnumMember, scope?: valueof string); + +/** + * Indicates whether the generate protocol method for the target operation. + * + * @param target The operation to configure. + * @param value Whether to generate a protocol API method. Default is `true`. + * @param scope Specifies the target language emitters that the decorator should apply. + */ +extern dec protocolAPI(target: Operation, value?: valueof boolean, scope?: valueof string); + +/** + * Indicates whether the generate convenient method for the target operation. + * + * @param target The operation to configure. + * @param value Whether to generate a convenient API method. Default is `true`. + * @param scope Specifies the target language emitters that the decorator should apply. + */ +extern dec convenientAPI(target: Operation, value?: valueof boolean, scope?: valueof string); + +/** + * Sets the client namespace for the target type. + * + * @param target The type to set the client namespace for. + * @param value The namespace value. + * @param scope Specifies the target language emitters that the decorator should apply. + */ +extern dec clientNamespace(target: unknown, value?: valueof string, scope?: valueof string); diff --git a/packages/http-client-generator-core/lib/main.tsp b/packages/http-client-generator-core/lib/main.tsp new file mode 100644 index 00000000000..29ee772dd0e --- /dev/null +++ b/packages/http-client-generator-core/lib/main.tsp @@ -0,0 +1 @@ +import "./decorators.tsp"; diff --git a/packages/http-client-generator-core/package.json b/packages/http-client-generator-core/package.json new file mode 100644 index 00000000000..494013d9f8e --- /dev/null +++ b/packages/http-client-generator-core/package.json @@ -0,0 +1,84 @@ +{ + "name": "@typespec/http-client-generator-core", + "version": "0.1.0", + "author": "Microsoft Corporation", + "description": "TypeSpec HTTP Client Generator Core library - protocol-agnostic client generation infrastructure", + "homepage": "https://github.com/microsoft/typespec", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/Azure/typespec-azure.git" + }, + "bugs": { + "url": "https://github.com/Azure/typespec-azure/issues" + }, + "keywords": [ + "typespec", + "sdk", + "ClientGenerator" + ], + "main": "dist/src/index.js", + "tspMain": "./lib/main.tsp", + "exports": { + ".": { + "types": "./dist/src/index.d.ts", + "typespec": "./lib/main.tsp", + "default": "./dist/src/index.js" + }, + "./testing": { + "types": "./dist/src/testing/index.d.ts", + "default": "./dist/src/testing/index.js" + } + }, + "type": "module", + "engines": { + "node": ">=22.0.0" + }, + "scripts": { + "clean": "rimraf ./dist ./temp", + "build": "tsc -p tsconfig.build.json", + "watch": "tsc -p tsconfig.build.json --watch", + "test": "vitest run", + "test:watch": "vitest -w", + "lint": "eslint . --max-warnings=0", + "lint:fix": "eslint . --fix" + }, + "files": [ + "lib/*.tsp", + "dist/**", + "!dist/test/**" + ], + "dependencies": { + "change-case": "catalog:", + "pluralize": "catalog:" + }, + "peerDependencies": { + "@typespec/compiler": "workspace:^", + "@typespec/http": "workspace:^", + "@typespec/openapi": "workspace:^", + "@typespec/rest": "workspace:^", + "@typespec/versioning": "workspace:^", + "@typespec/xml": "workspace:^", + "@typespec/events": "workspace:^", + "@typespec/sse": "workspace:^", + "@typespec/streams": "workspace:^" + }, + "devDependencies": { + "@types/node": "catalog:", + "@types/pluralize": "catalog:", + "@typespec/compiler": "workspace:^", + "@typespec/http": "workspace:^", + "@typespec/openapi": "workspace:^", + "@typespec/rest": "workspace:^", + "@typespec/versioning": "workspace:^", + "@typespec/xml": "workspace:^", + "@typespec/events": "workspace:^", + "@typespec/sse": "workspace:^", + "@typespec/streams": "workspace:^", + "@vitest/coverage-v8": "catalog:", + "@vitest/ui": "catalog:", + "rimraf": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" + } +} diff --git a/packages/http-client-generator-core/src/cache.ts b/packages/http-client-generator-core/src/cache.ts new file mode 100644 index 00000000000..b8f6a820df7 --- /dev/null +++ b/packages/http-client-generator-core/src/cache.ts @@ -0,0 +1,623 @@ +import { + Enum, + Interface, + isService, + isTemplateDeclaration, + isTemplateDeclarationOrInstance, + Namespace, + Operation, +} from "@typespec/compiler"; +import { unsafe_Realm } from "@typespec/compiler/experimental"; +import { getVersions } from "@typespec/versioning"; +import { getClientLocation, getClientNameOverride, isInScope } from "./decorators.js"; +import { SdkClient, TCGCContext } from "./interfaces.js"; +import { + clientKey, + clientLocationKey, + findServiceForOperation, + getScopedDecoratorData, + listAllUserDefinedNamespaces, + listScopedDecoratorData, + omitOperation, + removeVersionsLargerThanExplicitlySpecified, +} from "./internal-utils.js"; +import { reportDiagnostic } from "./lib.js"; +import { getLibraryName } from "./public-utils.js"; + +/** + * Create TCGC client types and prepare the cache for clients and operations. + * + * @param context TCGCContext + */ +export function prepareClientAndOperationCache(context: TCGCContext): void { + // initialize the caches + context.__rawClientsCache = new Map(); + context.__operationToClientCache = new Map(); + context.__clientToOperationsCache = new Map(); + context.__explicitClients = new Set(); + + // get root clients with full hierarchy (root clients + sub clients) + const { clients, mergedSubClientTypes } = getRootClients(context); + + const servicesNs = new Set(); + clients.forEach((c) => c.services.forEach((s) => servicesNs.add(s))); + + // handle versioning with mutated types + context.__packageVersions = new Map(); + context.__packageVersionEnum = new Map(); + + for (const serviceNs of servicesNs) { + const versions = getVersions(context.program, serviceNs)[1]?.getVersions(); + // If the service has no versioning, set empty + if (!versions || versions.length === 0) { + context.__packageVersions!.set(serviceNs, []); + continue; + } + + // Single service needs to filter versions based on `apiVersion` config + if (servicesNs.size === 1) { + removeVersionsLargerThanExplicitlySpecified(context, versions); + } + + context.__packageVersionEnum!.set(serviceNs, versions[0].enumMember.enum); + context.__packageVersions!.set( + serviceNs, + versions.map((v) => v.value), + ); + } + + // iterate all clients and build a map of operations + const queue: SdkClient[] = [...clients]; + let queueIdx = 0; + while (queueIdx < queue.length) { + const client = queue[queueIdx++]; + + // operations directly under the client + const operations = []; + + // Check if this is a merged sub client (has multiple services) + const mergedTypes = mergedSubClientTypes.get(client); + + if (client.parent === undefined && client.services.length > 1 && !mergedTypes) { + // multi-service root client + operations.push(...client.services.flatMap((service) => [...service.operations.values()])); + } else if (mergedTypes) { + // multi-service sub client + for (const type of mergedTypes) { + operations.push(...type.operations.values()); + } + } else if (client.type) { + // single-service client or sub client + operations.push(...client.type.operations.values()); + } + + // add operations + for (const op of operations) { + // skip operations that are not in scope + if (!isInScope(context, op)) { + continue; + } + + // skip templated operations, omit operations (has override decorator) + if ( + !isTemplateDeclarationOrInstance(op) && + !context.program.stateMap(omitOperation).get(op) + ) { + let pushClient: SdkClient = client; + const clientLocation = getClientLocation(context, op); + if (clientLocation) { + // operation with `@clientLocation` decorator is placed in another client + if (context.__rawClientsCache.has(clientLocation)) { + pushClient = context.__rawClientsCache.get(clientLocation)!; + } else { + reportDiagnostic(context.program, { + code: "client-location-wrong-type", + target: op, + }); + } + } + context.__clientToOperationsCache.get(pushClient)!.push(op); + context.__operationToClientCache.set(op, pushClient); + } + } + + queue.push(...client.subClients); + } + + // omit empty clients + const needKeep = (client: SdkClient): boolean => { + if (context.__explicitClients!.has(client) && !client.autoMergeService) return true; + // recursively check and remove empty sub clients + client.subClients = client.subClients.filter((subClient) => { + const keep = needKeep(subClient); + if (!keep) { + context.__rawClientsCache!.delete(subClient.type!); + } + return keep; + }); + + // check if the client has operations or non-empty sub clients + const hasOperations = context.__clientToOperationsCache!.get(client)!.length > 0; + const hasSubClients = client.subClients.length > 0; + + return hasOperations || hasSubClients; + }; + + // start from the top-level clients and remove empty clients + for (const client of clients) { + const keepClient = needKeep(client); + if (!keepClient && client.type) { + context.__rawClientsCache.delete(client.type); + context.__clientToOperationsCache.delete(client); + } + } +} + +interface ClientCreationResult { + clients: SdkClient[]; + mergedSubClientTypes: Map; +} + +/** + * Create a fresh copy of an SdkClient, resetting hierarchy fields to their + * initial state (empty subClients, no parent, clientPath = name). + */ +function cloneSdkClient(client: SdkClient): SdkClient { + return { + kind: "SdkClient", + name: client.name, + services: [...client.services], + type: client.type, + subClients: [], + clientPath: client.name, + autoMergeService: client.autoMergeService, + }; +} + +/** + * Get the TCGC root clients with full hierarchy. + * If user has explicitly defined `@client` then we will use those clients. + * If user has not defined any `@client` then we will create a client for the first service namespace. + * This function also creates sub clients, handles multi-service merging, + * and creates virtual sub clients for `@clientLocation` string values. + * + * @param context TCGCContext + * @returns + */ +function getRootClients(context: TCGCContext): ClientCreationResult { + const mergedSubClientTypes = new Map(); + const namespaces: Namespace[] = listAllUserDefinedNamespaces(context); + + // Collect all explicit @client declarations. + // Clone each SdkClient so this context gets its own mutable copies. + // The decorator stores SdkClient objects in the program state map, which is + // shared across all TCGCContext instances (e.g., lint rules + emitters). + // Without cloning, the hierarchy builder below would mutate the shared + // objects (parent, subClients, clientPath), causing duplicates when a + // second context processes the same program. + const explicitClients: SdkClient[] = []; + for (const ns of namespaces) { + const nsClient = getScopedDecoratorData(context, clientKey, ns); + if (nsClient) { + explicitClients.push(cloneSdkClient(nsClient)); + } + for (const i of ns.interfaces.values()) { + const iClient = getScopedDecoratorData(context, clientKey, i); + if (iClient) { + explicitClients.push(cloneSdkClient(iClient)); + } + } + } + + let clients: SdkClient[]; + + if (explicitClients.length > 0) { + // ── Explicit @client path ── + + // Build client hierarchy + + // Explicit client cache + explicitClients.forEach((c) => { + context.__rawClientsCache!.set(c.type!, c); + context.__clientToOperationsCache!.set(c, []); + context.__explicitClients!.add(c); + }); + + // Build explicit client hierarchy + explicitClients.forEach((client: SdkClient) => { + let parentClientType: Namespace | undefined = client.type!.namespace; + while (parentClientType) { + const parentClient = context.__rawClientsCache?.get(parentClientType); + if (parentClient) { + client.parent = parentClient; + client.clientPath = `${client.parent.name}.${client.clientPath}`; + parentClient.subClients.push(client); + break; + } + parentClientType = parentClientType.namespace; + } + }); + + // Get root clients + let validClients = true; + clients = explicitClients.filter((c: SdkClient) => { + if (c.parent === undefined && c.services.length === 0) { + reportDiagnostic(context.program, { + code: "root-client-missing-service", + target: c.type!, + }); + validClients = false; + return false; + } + return c.parent === undefined; + }); + + // Validate service for sub client is exist or set service if not exist + const validateAndSetServiceForSubClients = (parentClient: SdkClient) => { + for (const subClient of parentClient.subClients) { + if (subClient.services.length === 0) { + subClient.services = [...parentClient.services]; + } else { + for (const svc of subClient.services) { + if (!parentClient.services.includes(svc)) { + reportDiagnostic(context.program, { + code: "nested-client-service-not-subset", + target: subClient.type!, + }); + validClients = false; + break; + } + if (parentClient.autoMergeService) { + reportDiagnostic(context.program, { + code: "auto-merge-service-conflict", + target: subClient.type!, + }); + validClients = false; + break; + } + } + if (!validClients) { + break; + } + validateAndSetServiceForSubClients(subClient); + } + } + }; + for (const client of clients) { + validateAndSetServiceForSubClients(client); + } + + // If there is any invalid client, return empty clients to avoid potential downstream errors. The diagnostics will guide users to fix the issues. + if (!validClients) { + return { clients: [], mergedSubClientTypes }; + } + + // Add sub-client hierarchy if empty explicit client + const subClientNameMap = new Map(); + explicitClients.forEach((client: SdkClient) => { + if (client.autoMergeService) { + // Explicit auto-merge service client: follow services to build hierarchy + const subClients: SdkClient[] = []; + for (const specificService of client.services) { + for (const sc of buildSubClientHierarchy( + context, + specificService, + client.name, + specificService, + client, + )) { + if ( + !handleMultipleServicesSubClientNameConflict( + context, + sc, + client, + subClientNameMap, + mergedSubClientTypes, + ) + ) { + subClients.push(sc); + } + } + } + context.__rawClientsCache!.set(client.type!, client); + client.subClients = subClients; + context.__clientToOperationsCache!.set(client, []); + } + }); + } else { + // ── No explicit @client path ── + // Create a separate root client for each service namespace + + const serviceNamespaces: Namespace[] = namespaces.filter((ns) => + isService(context.program, ns), + ); + if (serviceNamespaces.length >= 1) { + clients = []; + for (const service of serviceNamespaces) { + let originalName; + const clientNameOverride = getClientNameOverride(context, service); + if (clientNameOverride) { + originalName = clientNameOverride; + } else { + originalName = service.name; + } + const clientName = originalName.endsWith("Client") ? originalName : `${originalName}Client`; + const client: SdkClient = { + kind: "SdkClient", + name: clientName, + services: [service], + type: service, + subClients: [], + clientPath: clientName, + }; + client.subClients = buildSubClientHierarchy(context, service, client.name, service, client); + context.__rawClientsCache!.set(client.type!, client); + context.__clientToOperationsCache!.set(client, []); + clients.push(client); + } + } else { + clients = []; + } + + if (clients.length === 0) { + return { clients, mergedSubClientTypes }; + } + } + + // Create virtual sub clients for `@clientLocation` of string value + // This applies to both explicit and non-explicit client paths + createVirtualSubClientsFromClientLocation(context, clients); + + return { clients, mergedSubClientTypes }; +} + +/** + * Create virtual sub clients for `@clientLocation` decorator with string target values. + * This handles cases where operations are moved to a named sub-client that may not exist yet. + */ +function createVirtualSubClientsFromClientLocation( + context: TCGCContext, + clients: SdkClient[], +): void { + if (clients.length === 0) return; + + const newSubClientWithServices = new Map(); + listScopedDecoratorData(context, clientLocationKey).forEach((v, k) => { + // only deal with mutated types or without mutation + if ( + (!context.__mutatedRealm && !unsafe_Realm.realmForType.has(k)) || + (context.__mutatedRealm && context.__mutatedRealm.hasType(k)) + ) { + // If the target sub client already exists, handle the multiple services case + if (typeof v === "string") { + if (clients.length > 1) { + // If there are multiple root clients, then we could not know where to put the virtual sub client, report error + reportDiagnostic(context.program, { + code: "client-location-conflict", + target: k, + }); + return; + } + + // Check if a sub client with this name already exists, only check first level for string target + const existingSc = clients[0].subClients.find( + (sc) => sc.type && getLibraryName(context, sc.type) === v, + ); + + const operationService = + clients[0].services.length > 1 + ? findServiceForOperation(clients[0].services, k as Operation) + : clients[0].services[0]; + + if (existingSc) { + // Sub client already exists - check if moving this operation would create a multi-service situation + if (!existingSc.services.includes(operationService)) { + existingSc.services.push(operationService); + } + // Operation will be moved to this existing sub client during operations processing + context.__rawClientsCache!.set(v, existingSc); + return; + } + + if (newSubClientWithServices.has(v)) { + // Add the service to the list if it's not already there + const services = newSubClientWithServices.get(v)!; + if (!services.includes(operationService)) { + services.push(operationService); + } + } else { + newSubClientWithServices.set(v, [operationService]); + } + } + } + }); + + if (newSubClientWithServices.size > 0) { + newSubClientWithServices.forEach((services, scName) => { + const sc: SdkClient = { + kind: "SdkClient", + name: scName, + clientPath: `${clients[0].name}.${scName}`, + services, + type: undefined, // virtual sub client has no backing type + subClients: [], + parent: clients[0], + }; + context.__rawClientsCache!.set(scName, sc); + clients[0].subClients.push(sc); + context.__clientToOperationsCache!.set(sc, []); + }); + } +} + +function handleMultipleServicesSubClientNameConflict( + context: TCGCContext, + sc: SdkClient, + client: SdkClient, + subClientNameMap: Map, + mergedSubClientTypes: Map, +): boolean { + if (client.services.length > 1 && sc.type) { + // Track for conflict detection + const scName = getLibraryName(context, sc.type); + const existingSc = subClientNameMap.get(scName); + if (!existingSc) { + subClientNameMap.set(scName, sc); + } else { + // Conflict detected, update the existing sub client to have multiple services + existingSc.services.push(sc.services[0]); + + // Re-parent moved children to the surviving sub client + for (const child of sc.subClients) { + child.parent = existingSc; + } + + // Recursively merge same-named grandchildren instead of blindly appending + mergeChildrenRecursively(context, existingSc, sc.subClients, mergedSubClientTypes); + + if (existingSc.type !== undefined) { + mergedSubClientTypes.set(existingSc, [existingSc.type as Namespace | Interface]); + existingSc.type = undefined; + } + // Store the merged types for later operations processing + const types = mergedSubClientTypes.get(existingSc)!; + if (sc.type) { + types.push(sc.type); + } + + // Redirect the merged-away sub client's type to the surviving sub client + // so that @clientLocation lookups still resolve correctly. + context.__rawClientsCache!.set(sc.type!, existingSc); + context.__clientToOperationsCache!.delete(sc); + + return true; + } + } + return false; +} + +/** + * Recursively merge incoming children into an existing sub-client's children. + * If an incoming child has the same name as an existing child, merge them recursively; + * otherwise, append the incoming child. + */ +function mergeChildrenRecursively( + context: TCGCContext, + existingSc: SdkClient, + incomingChildren: SdkClient[], + mergedSubClientTypes: Map, +): void { + for (const incoming of incomingChildren) { + const incomingName = incoming.type ? getLibraryName(context, incoming.type) : incoming.name; + const existing = existingSc.subClients.find((child) => { + const childName = child.type ? getLibraryName(context, child.type) : child.name; + return childName === incomingName; + }); + + if (existing) { + // Same-named grandchild found — merge recursively + existing.services.push(...incoming.services.filter((s) => !existing.services.includes(s))); + + // Re-parent incoming's children + for (const grandchild of incoming.subClients) { + grandchild.parent = existing; + } + mergeChildrenRecursively(context, existing, incoming.subClients, mergedSubClientTypes); + + // Track merged types + if (existing.type !== undefined) { + mergedSubClientTypes.set(existing, [existing.type as Namespace | Interface]); + existing.type = undefined; + } + const types = mergedSubClientTypes.get(existing)!; + if (incoming.type) { + types.push(incoming.type); + } + + // Redirect the merged-away child's type to the surviving child + // so that @clientLocation lookups still resolve correctly. + if (incoming.type) { + context.__rawClientsCache!.set(incoming.type, existing); + } + context.__clientToOperationsCache!.delete(incoming); + } else { + // No conflict — just append + existingSc.subClients.push(incoming); + } + } +} + +/** + * Build a sub-client hierarchy by iterating child namespaces and interfaces of the given type. + * Recursively creates sub-clients for all child namespaces and non-template interfaces, + * returning the direct children as a list. + * + * @param context TCGCContext + * @param type The parent namespace or interface whose children become sub-clients + * @param clientPathPrefix The parent's client path prefix + * @param service The service namespace + * @param parent The parent client + * @returns The list of direct child sub-clients + */ +function buildSubClientHierarchy( + context: TCGCContext, + type: Namespace | Interface, + clientPathPrefix: string, + service: Namespace, + parent?: SdkClient, +): SdkClient[] { + if (type.kind !== "Namespace") return []; + + const subClients: SdkClient[] = []; + + for (const ns of type.namespaces.values()) { + const sc = createSubClient(context, ns, clientPathPrefix, service, parent); + if (sc) subClients.push(sc); + } + for (const iface of type.interfaces.values()) { + const sc = createSubClient(context, iface, clientPathPrefix, service, parent); + if (sc) subClients.push(sc); + } + + return subClients; +} + +/** + * Create a single sub-client for the given type and recursively build its children. + */ +function createSubClient( + context: TCGCContext, + type: Namespace | Interface, + clientPathPrefix: string, + service: Namespace, + parent?: SdkClient, +): SdkClient | undefined { + // Skip template interfaces + if (type.kind === "Interface" && isTemplateDeclaration(type)) { + return undefined; + } + + const clientName = getLibraryName(context, type); + const clientPath = `${clientPathPrefix}.${clientName}`; + + const subClient: SdkClient = { + kind: "SdkClient", + name: clientName, + type, + clientPath, + services: [service], + subClients: [], + parent, + }; + + // Recursively build children for namespaces + if (type.kind === "Namespace") { + subClient.subClients = buildSubClientHierarchy(context, type, clientPath, service, subClient); + } + + // Cache + context.__rawClientsCache!.set(subClient.type!, subClient); + context.__clientToOperationsCache!.set(subClient, []); + + return subClient; +} diff --git a/packages/http-client-generator-core/src/clients.ts b/packages/http-client-generator-core/src/clients.ts new file mode 100644 index 00000000000..1da6b8b2f8d --- /dev/null +++ b/packages/http-client-generator-core/src/clients.ts @@ -0,0 +1,411 @@ +import { createDiagnosticCollector, Diagnostic, getDoc, getSummary } from "@typespec/compiler"; +import { $ } from "@typespec/compiler/typekit"; +import { getServers, HttpServer } from "@typespec/http"; +import { + getClientInitializationOptions, + getClientNameOverride, + getClientNamespace, +} from "./decorators.js"; +import { getSdkHttpParameter } from "./http.js"; +import { + ClientInitializationOptions, + InitializedByFlags, + SdkClient, + SdkClientInitializationType, + SdkClientType, + SdkEndpointParameter, + SdkEndpointType, + SdkHttpOperation, + SdkPathParameter, + SdkServiceOperation, + SdkUnionType, + TCGCContext, + UsageFlags, +} from "./interfaces.js"; +import { + createGeneratedName, + getActualClientType, + getClientDoc, + getTypeDecorators, + getValueTypeValue, + isSubscriptionId, + updateWithApiVersionInformation, +} from "./internal-utils.js"; +import { createDiagnostic } from "./lib.js"; +import { createSdkMethods, getSdkMethodParameter } from "./methods.js"; +import { getCrossLanguageDefinitionId } from "./public-utils.js"; +import { getSdkBuiltInType, getSdkCredentialParameter, getTypeSpecBuiltInType } from "./types.js"; + +function getEndpointTypeFromSingleServer< + TServiceOperation extends SdkServiceOperation = SdkHttpOperation, +>( + context: TCGCContext, + client: SdkClientType, + server: HttpServer | undefined, +): [SdkEndpointType[], readonly Diagnostic[]] { + const diagnostics = createDiagnosticCollector(); + const templateArguments: SdkPathParameter[] = []; + const defaultOverridableEndpointType: SdkEndpointType = { + kind: "endpoint", + serverUrl: "{endpoint}", + templateArguments: [ + { + name: "endpoint", + isGeneratedName: true, + doc: "Service host", + kind: "path", + onClient: true, + explode: false, + style: "simple", + allowReserved: true, + optional: false, + serializedName: "endpoint", + correspondingMethodParams: [], + methodParameterSegments: [], + type: getSdkBuiltInType(context, $(context.program).builtin.url), + isApiVersionParam: false, + apiVersions: client.apiVersions, + crossLanguageDefinitionId: `${client.crossLanguageDefinitionId}.endpoint`, + decorators: [], + access: "public", + flatten: false, + }, + ], + decorators: [], + }; + const types: SdkEndpointType[] = []; + if (!server) return diagnostics.wrap([defaultOverridableEndpointType]); + for (const param of server.parameters.values()) { + const sdkParam = diagnostics.pipe( + getSdkHttpParameter(context, param, undefined, undefined, "path"), + ); + if (sdkParam.kind === "path") { + templateArguments.push(sdkParam); + sdkParam.onClient = true; + if (param.defaultValue) { + sdkParam.clientDefaultValue = getValueTypeValue(param.defaultValue); + } + const apiVersionInfo = updateWithApiVersionInformation(context, param, client.__raw); + sdkParam.isApiVersionParam = apiVersionInfo.isApiVersionParam; + if (sdkParam.isApiVersionParam && apiVersionInfo.clientDefaultValue) { + sdkParam.clientDefaultValue = apiVersionInfo.clientDefaultValue; + } + sdkParam.apiVersions = client.apiVersions; + sdkParam.crossLanguageDefinitionId = `${client.crossLanguageDefinitionId}.${param.name}`; + } else { + diagnostics.add( + createDiagnostic({ + code: "server-param-not-path", + target: param, + format: { + templateArgumentName: sdkParam.name, + templateArgumentType: sdkParam.kind, + }, + }), + ); + } + } + const isOverridable = + templateArguments.length === 1 && server.url.startsWith("{") && server.url.endsWith("}"); + + if (templateArguments.length === 0) { + types.push(defaultOverridableEndpointType); + types[0].templateArguments[0].clientDefaultValue = server.url; + } else { + types.push({ + kind: "endpoint", + serverUrl: server.url, + templateArguments, + decorators: [], + }); + if (!isOverridable) { + types.push(defaultOverridableEndpointType); + } + } + return diagnostics.wrap(types); +} + +function getSdkEndpointParameter( + context: TCGCContext, + client: SdkClientType, +): [SdkEndpointParameter, readonly Diagnostic[]] { + const diagnostics = createDiagnosticCollector(); + const rawClient = client.__raw; + // For multiple services, just take the first one to get servers + const service = rawClient.services[0]; + const servers = getServers(context.program, service); + const types: SdkEndpointType[] = []; + + if (servers === undefined) { + // if there is no defined server url, we will return an overridable endpoint + types.push(...diagnostics.pipe(getEndpointTypeFromSingleServer(context, client, undefined))); + } else { + for (const server of servers) { + types.push(...diagnostics.pipe(getEndpointTypeFromSingleServer(context, client, server))); + } + } + let type: SdkEndpointType | SdkUnionType; + if (types.length > 1) { + type = { + kind: "union", + access: "public", + usage: UsageFlags.None, + variantTypes: types, + name: createGeneratedName(context, service, "Endpoint"), + isGeneratedName: true, + apiVersions: client.apiVersions, + crossLanguageDefinitionId: `${client.crossLanguageDefinitionId}.Endpoint`, + namespace: getClientNamespace(context, service), + decorators: [], + } as SdkUnionType; + } else { + type = types[0]; + } + return diagnostics.wrap({ + kind: "endpoint", + type, + name: "endpoint", + isGeneratedName: true, + doc: "Service host", + onClient: true, + urlEncode: false, + // Endpoint parameter's api versions are derived from the client + apiVersions: client.apiVersions, + optional: false, + isApiVersionParam: false, + crossLanguageDefinitionId: `${client.crossLanguageDefinitionId}.endpoint`, + decorators: [], + access: "public", + flatten: false, + }); +} + +export function createSdkClientType( + context: TCGCContext, + client: SdkClient, + parent?: SdkClientType, +): [SdkClientType, readonly Diagnostic[]] { + const diagnostics = createDiagnosticCollector(); + let name = client.name; + if (client.type) { + const override = getClientNameOverride(context, client.type); + if (override) { + name = override; + } + } + const clientType = getActualClientType(client); + const sdkClientType: SdkClientType = { + __raw: client, + kind: "client", + name, + doc: client.type ? getClientDoc(context, client.type) : undefined, + summary: client.type ? getSummary(context.program, client.type) : undefined, + methods: [], + apiVersions: context.getApiVersionsForType(clientType), + namespace: getClientNamespace(context, clientType), + clientInitialization: diagnostics.pipe( + createSdkClientInitializationType(context, client, parent), + ), + decorators: client.type ? diagnostics.pipe(getTypeDecorators(context, client.type)) : [], + parent, + crossLanguageDefinitionId: getCrossLanguageDefinitionId(context, clientType), + }; + // Handle client methods + sdkClientType.methods = diagnostics.pipe( + createSdkMethods(context, client, sdkClientType), + ); + // Handle sub-clients + for (const subClient of client.subClients) { + const subClientType = diagnostics.pipe( + createSdkClientType(context, subClient, sdkClientType), + ); + if (sdkClientType.children) { + sdkClientType.children.push(subClientType); + } else { + sdkClientType.children = [subClientType]; + } + } + // Handle default client parameters (endpoint, credential, api version, subscription id) + addDefaultClientParameters(context, sdkClientType); + + return diagnostics.wrap(sdkClientType); +} + +function addDefaultClientParameters< + TServiceOperation extends SdkServiceOperation = SdkHttpOperation, +>(context: TCGCContext, client: SdkClientType): void { + const diagnostics = createDiagnosticCollector(); + const defaultClientParamters = []; + // there will always be an endpoint property + defaultClientParamters.push(diagnostics.pipe(getSdkEndpointParameter(context, client))); + const credentialParam = getSdkCredentialParameter(context, client); + if (credentialParam) { + defaultClientParamters.push(credentialParam); + } + let apiVersionParam = context.__clientParametersCache + .get(client.__raw) + ?.find((x) => x.isApiVersionParam); + if (!apiVersionParam) { + for (const sc of client.__raw.subClients) { + // if any sub clients have an api version param, the top level needs + // the api version param as well + apiVersionParam = context.__clientParametersCache.get(sc)?.find((x) => x.isApiVersionParam); + if (apiVersionParam) { + context.__clientParametersCache.get(client.__raw)?.push(apiVersionParam); + break; + } + } + } + if (apiVersionParam) { + if (client.__raw.services.length > 1) { + // for multi-service clients, keep apiVersions empty and no default value + // and set the type to string instead of a specific enum + const multipleServiceApiVersionParam = { ...apiVersionParam }; + multipleServiceApiVersionParam.apiVersions = []; + multipleServiceApiVersionParam.clientDefaultValue = undefined; + multipleServiceApiVersionParam.type = getTypeSpecBuiltInType(context, "string"); + // For multi-service clients, the API version parameter should always be optional + multipleServiceApiVersionParam.optional = true; + defaultClientParamters.push(multipleServiceApiVersionParam); + } else { + // For single-service clients, API version parameters are optional only when they have a client default value + if (apiVersionParam.clientDefaultValue !== undefined) { + apiVersionParam.optional = true; + } + defaultClientParamters.push(apiVersionParam); + } + } + let subId = context.__clientParametersCache + .get(client.__raw) + ?.find((x) => isSubscriptionId(context, x)); + if (subId) { + defaultClientParamters.push(subId); + } + client.clientInitialization.parameters = [ + ...defaultClientParamters, + ...client.clientInitialization.parameters, + ]; +} + +function createSdkClientInitializationType< + TServiceOperation extends SdkServiceOperation = SdkHttpOperation, +>( + context: TCGCContext, + client: SdkClient, + parent?: SdkClientType | undefined, +): [SdkClientInitializationType, readonly Diagnostic[]] { + const diagnostics = createDiagnosticCollector(); + const isRootClient = !client.parent; + const name = `${client.name}Options`; + const result: SdkClientInitializationType = { + kind: "clientinitialization", + doc: "Initialization for the client", + parameters: [], + initializedBy: isRootClient ? InitializedByFlags.Individually : InitializedByFlags.Default, + name, + isGeneratedName: true, + decorators: [], + }; + let initializationOptions: ClientInitializationOptions | undefined = undefined; + + // customization + if (client.type) { + initializationOptions = getClientInitializationOptions(context, client.type); + if (initializationOptions?.parameters) { + result.doc = getDoc(context.program, initializationOptions.parameters); + result.summary = getSummary(context.program, initializationOptions.parameters); + result.name = + initializationOptions.parameters.name === "" ? name : initializationOptions.parameters.name; + result.isGeneratedName = initializationOptions.parameters.name === "" ? true : false; + result.decorators = diagnostics.pipe( + getTypeDecorators(context, initializationOptions.parameters), + ); + result.__raw = initializationOptions.parameters; + for (const parameter of initializationOptions.parameters.properties.values()) { + const clientParameter = diagnostics.pipe(getSdkMethodParameter(context, parameter)); + clientParameter.onClient = true; + result.parameters.push(clientParameter); + } + } + if (initializationOptions?.initializedBy !== undefined) { + if ( + initializationOptions.initializedBy !== InitializedByFlags.CustomizeCode && + isRootClient && + (initializationOptions.initializedBy & InitializedByFlags.Parent) === + InitializedByFlags.Parent + ) { + diagnostics.add( + createDiagnostic({ + code: "invalid-initialized-by", + target: client.type, + format: { + message: + "First level client must have `InitializedBy.individually` specified in `initializedBy`.", + }, + }), + ); + } else if ( + initializationOptions.initializedBy !== InitializedByFlags.CustomizeCode && + !isRootClient && + initializationOptions.initializedBy === InitializedByFlags.Individually + ) { + diagnostics.add( + createDiagnostic({ + code: "invalid-initialized-by", + target: client.type, + format: { + message: + "Sub client must have `InitializedBy.parent` or `InitializedBy.individually | InitializedBy.parent` specified in `initializedBy`.", + }, + }), + ); + } else { + result.initializedBy = initializationOptions.initializedBy; + } + } + if (initializationOptions?.parameters) { + // Cache elevated parameter, then we could use it to set `onClient` property for method parameters. + let clientParams = context.__clientParametersCache.get(client); + if (!clientParams) { + clientParams = []; + context.__clientParametersCache.set(client, clientParams); + } + for (const param of result.parameters) { + if (param.kind === "method") clientParams.push(param); + } + } + } + + // Propagate parent client initialization parameters if InitializedBy.Parent or no InitializedBy is set + // Only propagate if no custom parameters are set on the child + if ( + !initializationOptions?.parameters && + parent && + result.initializedBy !== InitializedByFlags.Individually + ) { + // Prepend parent parameters to child parameters + // This ensures parent parameters come first, child-specific parameters come after + const parentParams = parent.clientInitialization.parameters; + const childParamNames = new Set(result.parameters.map((p) => p.name)); + + // Only add parent params that aren't already defined in child + const inheritedParams = parentParams.filter((p) => !childParamNames.has(p.name)); + + result.parameters = [...inheritedParams, ...result.parameters]; + + // Also update the cache to include parent parameters + let clientParams = context.__clientParametersCache.get(client); + if (!clientParams) { + clientParams = []; + context.__clientParametersCache.set(client, clientParams); + } + + for (const param of inheritedParams) { + if (param.kind === "method" && !clientParams.some((cp) => cp.name === param.name)) { + clientParams.push(param); + } + } + } + + return diagnostics.wrap(result); +} diff --git a/packages/http-client-generator-core/src/configs.ts b/packages/http-client-generator-core/src/configs.ts new file mode 100644 index 00000000000..82ecd34f2b8 --- /dev/null +++ b/packages/http-client-generator-core/src/configs.ts @@ -0,0 +1,4 @@ +export const defaultDecoratorsAllowList = [ + "TypeSpec\\.Xml\\..*", + "TypeSpec\\.ClientGenerator\\.Core\\.@clientOption", +]; diff --git a/packages/http-client-generator-core/src/context.ts b/packages/http-client-generator-core/src/context.ts new file mode 100644 index 00000000000..6d754a8e784 --- /dev/null +++ b/packages/http-client-generator-core/src/context.ts @@ -0,0 +1,293 @@ +import { + createDiagnosticCollector, + EmitContext, + emitFile, + Enum, + getRelativePathFromDirectory, + Interface, + isPathAbsolute, + Model, + ModelProperty, + Namespace, + normalizePath, + Operation, + Program, + resolvePath, + Type, + Union, +} from "@typespec/compiler"; +import { HttpOperation } from "@typespec/http"; +import { stringify } from "yaml"; +import { prepareClientAndOperationCache } from "./cache.js"; +import { defaultDecoratorsAllowList } from "./configs.js"; +import { handleClientExamples } from "./example.js"; +import { + SdkArrayType, + SdkClient, + SdkContext, + SdkDictionaryType, + SdkEnumType, + SdkHttpOperation, + SdkMethodParameter, + SdkModelPropertyType, + SdkModelType, + SdkNullableType, + SdkServiceOperation, + SdkServiceResponseHeader, + SdkUnionType, + TCGCContext, + UsageFlags, +} from "./interfaces.js"; +import { + BrandedSdkEmitterOptionsInterface, + handleVersioningMutationForGlobalNamespace, + parseEmitterName, + TCGCEmitterOptions, + TspLiteralType, +} from "./internal-utils.js"; +import { createDiagnostic } from "./lib.js"; +import { createSdkPackage } from "./package.js"; + +interface CreateTCGCContextOptions { + mutateNamespace?: boolean; // whether to mutate global namespace for versioning +} + +export function createTCGCContext( + program: Program, + emitterName?: string, + options?: CreateTCGCContextOptions, +): TCGCContext { + const diagnostics = createDiagnosticCollector(); + return { + program, + diagnostics: diagnostics.diagnostics, + emitterName: diagnostics.pipe( + parseEmitterName(program, emitterName ?? program.emitters[0]?.metadata?.name), + ), + + previewStringRegex: /-preview$/, + disableUsageAccessPropagationToBase: false, + generateProtocolMethods: true, + generateConvenienceMethods: true, + __referencedTypeCache: new Map< + Type, + SdkModelType | SdkEnumType | SdkUnionType | SdkNullableType + >(), + __arrayDictionaryCache: new Map(), + __methodParameterCache: new Map(), + __modelPropertyCache: new Map(), + __responseHeaderCache: new Map(), + __generatedNames: new Map(), + __httpOperationCache: new Map(), + __clientParametersCache: new Map(), + __tspTypeToApiVersions: new Map(), + __clientApiVersionDefaultValueCache: new Map(), + __httpOperationExamples: new Map(), + __pagedResultSet: new Set(), + __namingContextPath: [], + + getMutatedGlobalNamespace(): Namespace { + if (options?.mutateNamespace === false) { + // If we are not mutating the global namespace, return the original global namespace type. + return program.getGlobalNamespaceType(); + } + if (!this.__mutatedGlobalNamespace) { + this.__mutatedGlobalNamespace = handleVersioningMutationForGlobalNamespace(this); + } + return this.__mutatedGlobalNamespace; + }, + getApiVersionsForType(type): string[] { + return this.__tspTypeToApiVersions.get(type) ?? []; + }, + setApiVersionsForType(type, apiVersions: string[]): void { + const existingApiVersions = this.__tspTypeToApiVersions.get(type) ?? []; + const mergedApiVersions = [...existingApiVersions]; + for (const apiVersion of apiVersions) { + if (!mergedApiVersions.includes(apiVersion)) { + mergedApiVersions.push(apiVersion); + } + } + this.__tspTypeToApiVersions.set(type, mergedApiVersions); + }, + getPackageVersions(): Map { + if (!this.__packageVersions) { + prepareClientAndOperationCache(this); + } + + return this.__packageVersions!; + }, + getPackageVersionEnum(): Map { + if (!this.__packageVersionEnum) { + prepareClientAndOperationCache(this); + } + return this.__packageVersionEnum!; + }, + getClients(): SdkClient[] { + if (!this.__rawClientsCache) { + prepareClientAndOperationCache(this); + } + return [...new Set(this.__rawClientsCache!.values())]; + }, + getRootClients(): SdkClient[] { + if (!this.__rawClientsCache) { + prepareClientAndOperationCache(this); + } + return [...new Set(this.__rawClientsCache!.values())].filter((item) => !item.parent); + }, + getClient(type: Namespace | Interface): SdkClient | undefined { + if (!this.__rawClientsCache) { + prepareClientAndOperationCache(this); + } + return this.__rawClientsCache!.get(type); + }, + getOperationsForClient(client: SdkClient): Operation[] { + if (!this.__clientToOperationsCache) { + prepareClientAndOperationCache(this); + } + return this.__clientToOperationsCache!.get(client)!; + }, + getClientForOperation(operation: Operation): SdkClient { + if (!this.__operationToClientCache) { + prepareClientAndOperationCache(this); + } + return this.__operationToClientCache!.get(operation)!; + }, + }; +} + +interface VersioningStrategy { + readonly previewStringRegex?: RegExp; // regex to match preview versions +} + +export interface CreateSdkContextOptions { + readonly versioning?: VersioningStrategy; + additionalDecorators?: string[]; + disableUsageAccessPropagationToBase?: boolean; // this flag is for some languages that has no need to generate base model, but generate model with composition + exportTCGCoutput?: boolean; // this flag is for emitter to export TCGC output as yaml file + flattenUnionAsEnum?: boolean; // this flag is for emitter to decide whether tcgc should flatten union as enum + enableLegacyHierarchyBuilding?: boolean; // this flag is for emitter to decide whether tcgc should respect the `@hierarchyBuilding` decorator +} + +export async function createSdkContext< + TOptions extends Record = BrandedSdkEmitterOptionsInterface, + TServiceOperation extends SdkServiceOperation = SdkHttpOperation, +>( + context: EmitContext, + emitterName?: string, + options?: CreateSdkContextOptions, +): Promise> { + const diagnostics = createDiagnosticCollector(); + const tcgcContext = createTCGCContext( + context.program, + emitterName ?? context.options["emitter-name"], + ); + const generateProtocolMethods = + context.options["generate-protocol-methods"] ?? tcgcContext.generateProtocolMethods; + const generateConvenienceMethods = + context.options["generate-convenience-methods"] ?? tcgcContext.generateConvenienceMethods; + const sdkContext: SdkContext = { + ...tcgcContext, + emitContext: context, + sdkPackage: undefined!, + generateProtocolMethods: generateProtocolMethods, + generateConvenienceMethods: generateConvenienceMethods, + namespaceFlag: context.options["namespace"], + apiVersion: context.options["api-version"], + license: context.options["license"], + decoratorsAllowList: [...defaultDecoratorsAllowList, ...(options?.additionalDecorators ?? [])], + previewStringRegex: options?.versioning?.previewStringRegex || tcgcContext.previewStringRegex, + disableUsageAccessPropagationToBase: options?.disableUsageAccessPropagationToBase ?? false, + flattenUnionAsEnum: options?.flattenUnionAsEnum ?? true, + enableLegacyHierarchyBuilding: options?.enableLegacyHierarchyBuilding ?? true, + }; + + if (context.options["examples-dir"]) { + const normalizeExamplesDir = normalizePath(context.options["examples-dir"]); + if (isPathAbsolute(normalizeExamplesDir)) { + sdkContext.examplesDir = getRelativePathFromDirectory( + context.program.projectRoot, + normalizeExamplesDir, + false, + ); + } else { + sdkContext.examplesDir = normalizeExamplesDir; + } + } + sdkContext.sdkPackage = diagnostics.pipe(await createSdkPackage(sdkContext)); + for (const client of sdkContext.sdkPackage.clients) { + diagnostics.pipe(await handleClientExamples(sdkContext, client)); + } + // Validate duplicate names within each type kind in each namespace (cross-kind duplicates are allowed). + diagnostics.pipe(validateNamesUnderNamespaces(sdkContext)); + sdkContext.diagnostics = [...sdkContext.diagnostics, ...diagnostics.diagnostics]; + + if (options?.exportTCGCoutput) { + await exportTCGCOutput(sdkContext); + } + return sdkContext; +} + +function validateNamesUnderNamespaces(context: SdkContext) { + const diagnostics = createDiagnosticCollector(); + const validateItems = (namespaceItems: (SdkModelType | SdkEnumType | SdkUnionType)[]) => { + const seenNames = new Set(); + for (const item of namespaceItems) { + if (seenNames.has(item.name)) { + diagnostics.add( + createDiagnostic({ + code: "duplicate-client-name", + format: { name: item.name, scope: context.emitterName }, + target: item.__raw!, + }), + ); + } else { + seenNames.add(item.name); + } + } + }; + + const validateNamespace = (namespace: SdkContext["sdkPackage"]["namespaces"][number]) => { + validateItems(namespace.models); + validateItems(namespace.enums.filter((e) => (e.usage & UsageFlags.ApiVersionEnum) === 0)); + validateItems(namespace.unions.filter((u): u is SdkUnionType => u.kind === "union")); + for (const nestedNamespace of namespace.namespaces) { + validateNamespace(nestedNamespace); + } + }; + + for (const namespace of context.sdkPackage.namespaces) { + validateNamespace(namespace); + } + + return diagnostics.wrap(undefined); +} + +async function exportTCGCOutput(context: SdkContext) { + await emitFile(context.program, { + path: resolvePath(context.emitContext.emitterOutputDir, "tcgc-output.yaml"), + content: stringify( + context.sdkPackage, + (k, v) => { + if (typeof k === "string" && k.startsWith("__")) { + return undefined; // skip keys starting with "__" from the output + } + if (k === "scheme") { + const { model, ...rest } = v; + return rest; // remove credential schema's model property + } + if (k === "rawExample") { + return undefined; // remove raw example + } + return v; + }, + { lineWidth: 0 }, + ), + }); +} + +export async function $onEmit(context: EmitContext) { + if (!context.program.compilerOptions.noEmit) { + const sdkContext = await createSdkContext(context, undefined, { exportTCGCoutput: true }); + context.program.reportDiagnostics(sdkContext.diagnostics); + } +} diff --git a/packages/http-client-generator-core/src/decorators.ts b/packages/http-client-generator-core/src/decorators.ts new file mode 100644 index 00000000000..509723aad15 --- /dev/null +++ b/packages/http-client-generator-core/src/decorators.ts @@ -0,0 +1,1826 @@ +import { + compilerAssert, + DecoratorContext, + DecoratorFunction, + DiagnosticTarget, + Enum, + EnumMember, + getDiscriminator, + getNamespaceFullName, + ignoreDiagnostics, + Interface, + isErrorModel, + isList, + isNumeric, + Model, + ModelProperty, + Namespace, + Numeric, + Operation, + Program, + RekeyableMap, + Scalar, + Type, + Union, +} from "@typespec/compiler"; +import { SyntaxKind, type Node } from "@typespec/compiler/ast"; +import { $ } from "@typespec/compiler/typekit"; +import { + getAuthentication, + getHttpOperation, + getServers, + isBody, + isBodyRoot, +} from "@typespec/http"; +import { resolveVersions } from "@typespec/versioning"; +import { + AccessDecorator, + AlternateTypeDecorator, + ApiVersionDecorator, + ClientApiVersionsDecorator, + ClientDecorator, + ClientDocDecorator, + ClientInitializationDecorator, + ClientNameDecorator, + ClientNamespaceDecorator, + ClientOptionDecorator, + ConvenientAPIDecorator, + DeserializeEmptyStringAsNullDecorator, + OperationGroupDecorator, + ParamAliasDecorator, + ProtocolAPIDecorator, + ResponseAsBoolDecorator, + ScopeDecorator, + UsageDecorator, +} from "../generated-defs/TypeSpec.ClientGenerator.Core.js"; +import { + ClientDefaultValueDecorator, + DisablePageableDecorator, + FlattenPropertyDecorator, + HierarchyBuildingDecorator, + MarkAsLroDecorator, + MarkAsPageableDecorator, + NextLinkVerbDecorator, +} from "../generated-defs/TypeSpec.ClientGenerator.Core.Legacy.js"; +import { + AccessFlags, + ClientInitializationOptions, + ExternalTypeInfo, + LanguageScopes, + SdkClient, + TCGCContext, + UsageFlags, +} from "./interfaces.js"; +import { + AllScopes, + clientKey, + clientLocationKey, + clientNameKey, + clientNamespaceKey, + compareModelProperties, + findEntriesWithTarget, + findRootSourceProperty, + getScopedDecoratorData, + isSameAuth, + isSameServers, + legacyHierarchyBuildingKey, + listAllUserDefinedNamespaces, + negationScopesKey, + omitOperation, + overrideKey, + parseScopes, + scopeKey, + usageKey, +} from "./internal-utils.js"; +import { createStateSymbol, reportDiagnostic } from "./lib.js"; +import { getSdkEnum, getSdkModel, getSdkUnion } from "./types.js"; + +export const namespace = "TypeSpec.ClientGenerator.Core"; + +function setScopedDecoratorData( + context: DecoratorContext, + decorator: DecoratorFunction, + key: symbol, + target: Type, + value: unknown, + scope?: LanguageScopes, +) { + const targetEntry = context.program.stateMap(key).get(target); + // if no scope specified, then set with the new value + if (!scope) { + if (targetEntry && targetEntry[AllScopes]) { + targetEntry[AllScopes] = value; + } else { + const newObject = Object.fromEntries([[AllScopes, value]]); + context.program + .stateMap(key) + .set(target, !targetEntry ? newObject : { ...targetEntry, ...newObject }); + } + return; + } + + const [negationScopes, scopes] = parseScopes(scope); + if (negationScopes !== undefined && negationScopes.length > 0) { + // override the previous value for negation scopes + const newObject: Record = + scopes !== undefined && scopes.length > 0 + ? Object.fromEntries([AllScopes, ...scopes].map((scope) => [scope, value])) + : Object.fromEntries([[AllScopes, value]]); + newObject[negationScopesKey] = negationScopes; + context.program.stateMap(key).set(target, newObject); + + // if a scope exists in the target entry and it overlaps with the negation scope, it means negation scope doesn't override it + if (targetEntry !== undefined) { + const existingScopes = Object.getOwnPropertyNames(targetEntry); + const intersections = existingScopes.filter((x) => negationScopes.includes(x)); + if (intersections !== undefined && intersections.length > 0) { + for (const scopeToKeep of intersections) { + newObject[scopeToKeep] = targetEntry[scopeToKeep]; + } + } + } + } else if (scopes !== undefined && scopes.length > 0) { + // for normal scopes, add them incrementally + const newObject = Object.fromEntries(scopes.map((scope) => [scope, value])); + context.program + .stateMap(key) + .set(target, !targetEntry ? newObject : { ...targetEntry, ...newObject }); + } +} + +export const $client: ClientDecorator = ( + context: DecoratorContext, + target: Namespace | Interface, + options?: Type, + scope?: LanguageScopes, +) => { + if ((context.decoratorTarget as Node).kind === SyntaxKind.AugmentDecoratorStatement) { + reportDiagnostic(context.program, { + code: "wrong-client-decorator", + target: context.decoratorTarget, + }); + return; + } + const explicitName = + options?.kind === "Model" ? options?.properties.get("name")?.type : undefined; + const name: string = explicitName?.kind === "String" ? explicitName.value : target.name; + let services: Namespace[]; + const serviceConfig = + options?.kind === "Model" ? options?.properties.get("service")?.type : undefined; + const autoMergeServiceConfig = + options?.kind === "Model" ? options?.properties.get("autoMergeService")?.type : undefined; + + if (serviceConfig?.kind === "Namespace") { + // Explicit single service + services = [serviceConfig]; + } else if ( + serviceConfig?.kind === "Tuple" && + serviceConfig.values.every((v) => v.kind === "Namespace") + ) { + // Explicit multiple services + if (target.kind === "Interface") { + reportDiagnostic(context.program, { + code: "invalid-client-service-multiple", + target: context.decoratorTarget, + }); + return; + } + services = serviceConfig.values; + // validate all services has same server definition + let servers = undefined; + let auth = undefined; + let isSame = true; + for (const svc of services) { + const currentServers = getServers(context.program, svc); + if (currentServers === undefined) continue; + if (servers === undefined) { + servers = currentServers; + } else { + isSame = isSameServers(servers, currentServers); + if (!isSame) { + break; + } + } + } + for (const svc of services) { + const currentAuth = getAuthentication(context.program, svc); + if (currentAuth === undefined) continue; + if (auth === undefined) { + auth = currentAuth; + } else { + isSame = isSameAuth(auth, currentAuth); + if (!isSame) { + break; + } + } + } + if (!isSame) { + reportDiagnostic(context.program, { + code: "inconsistent-multiple-service", + target: context.decoratorTarget, + }); + return; + } + // For clients merging multiple services, ensure all services agree on the + // version of any shared library dependency (e.g. ARM common-types). + // Diverging versions cause TCGC to emit duplicated/diverged models. + validateMultipleServiceDependencyVersions( + context.program, + name, + services, + context.decoratorTarget, + ); + } else { + // No explicit service - store empty array. Cache.ts will either: + // - inherit from parent client (if nested) + // - report an error (if root client) + services = []; + } + + const client: SdkClient = { + kind: "SdkClient", + name, + services, + type: target, + subClients: [], + clientPath: name, + autoMergeService: + autoMergeServiceConfig?.kind === "Boolean" ? autoMergeServiceConfig.value : false, + }; + setScopedDecoratorData(context, $client, clientKey, target, client, scope); +}; + +/** + * Validate that all services merged into the same client agree on the version + * of every shared library dependency. Diverging versions silently produce + * duplicated/diverged models in the generated SDK. + */ +function validateMultipleServiceDependencyVersions( + program: Program, + clientName: string, + services: Namespace[], + target: DiagnosticTarget, +): void { + // For each shared dependency namespace, collect the set of versions picked + // across all merged services. + const depVersions = new Map>(); + const serviceSet: ReadonlySet = new Set(services); + + for (const service of services) { + const resolutions = resolveVersions(program, service); + if (resolutions.length === 0) continue; + // Use the latest resolved version of this service (matches what TCGC picks). + for (const [depNs, depVersion] of resolutions[resolutions.length - 1].versions) { + // Ignore versions of the merged services themselves. + if (serviceSet.has(depNs)) continue; + const versions = depVersions.get(depNs) ?? new Set(); + versions.add(depVersion.value ?? depVersion.name); + depVersions.set(depNs, versions); + } + } + + // Report any dependency that resolved to more than one version. + for (const [depNs, versions] of depVersions) { + if (versions.size <= 1) continue; + reportDiagnostic(program, { + code: "inconsistent-multiple-service-dependency", + format: { + clientName, + dependencyName: getNamespaceFullName(depNs), + versions: [...versions].map((v) => `"${v}"`).join(", "), + }, + target, + }); + } +} + +/** + * Return the client object for the given namespace or interface, or undefined if the given namespace or interface is not a client. + * + * @param context TCGCContext + * @param type Type to check + * @returns Client or undefined + */ +export function getClient( + context: TCGCContext, + type: Namespace | Interface, +): SdkClient | undefined { + return context.getClient(type); +} + +/** + * List all the root clients. + * + * @param context TCGCContext + * @returns Array of root clients + */ +export function listClients(context: TCGCContext): SdkClient[] { + return context.getRootClients(); +} + +/** + * @deprecated Use `@client` instead. The `@operationGroup` decorator is deprecated. + */ +// eslint-disable-next-line @typescript-eslint/no-deprecated +export const $operationGroup: OperationGroupDecorator = ( + context: DecoratorContext, + target: Namespace | Interface, + scope?: LanguageScopes, +) => { + // Delegate to $client - @operationGroup is now just an alias for @client + context.call($client, target, undefined, scope); +}; + +/** + * List all the sub clients inside a client. If ignoreHierarchy is true, the result will include all nested sub clients. + * + * @param context TCGCContext + * @param group Client to list sub clients + * @param ignoreHierarchy Whether to get all nested sub clients + * @returns Array of sub clients + */ +export function listSubClients( + context: TCGCContext, + group: SdkClient, + ignoreHierarchy = false, +): SdkClient[] { + if (!ignoreHierarchy) return group.subClients; + + const clients: SdkClient[] = [...group.subClients]; + let current = 0; + while (current < clients.length) { + const subClient = clients[current]; + if (subClient.subClients) { + clients.push(...subClient.subClients); + } + current++; + } + + return clients; +} + +/** + * List operations inside a client or sub client. If ignoreHierarchy is true, the result will include all nested operations. + * @param context TCGCContext + * @param client Client to list operations + * @param ignoreHierarchy Whether to get all nested operations + * @returns + */ +export function listOperationsInClient( + context: TCGCContext, + client: SdkClient, + ignoreHierarchy = false, +): Operation[] { + if (!ignoreHierarchy) return context.getOperationsForClient(client); + + const subClients: SdkClient[] = [...client.subClients]; + const operations: Operation[] = [...context.getOperationsForClient(client)]; + let groupIdx = 0; + while (groupIdx < subClients.length) { + const subClient = subClients[groupIdx++]; + if (subClient.subClients) { + subClients.push(...subClient.subClients); + } + operations.push(...context.getOperationsForClient(subClient)); + } + + return operations; +} + +const protocolAPIKey = createStateSymbol("protocolAPI"); + +export const $protocolAPI: ProtocolAPIDecorator = ( + context: DecoratorContext, + entity: Operation | Namespace | Interface, + value?: boolean, + scope?: LanguageScopes, +) => { + setScopedDecoratorData(context, $protocolAPI, protocolAPIKey, entity, value, scope); +}; + +const convenientAPIKey = createStateSymbol("convenientAPI"); + +export const $convenientAPI: ConvenientAPIDecorator = ( + context: DecoratorContext, + entity: Operation | Namespace | Interface, + value?: boolean, + scope?: LanguageScopes, +) => { + setScopedDecoratorData(context, $convenientAPI, convenientAPIKey, entity, value, scope); +}; + +function getConvenientOrProtocolValue( + context: TCGCContext, + key: symbol, + entity: Operation, +): boolean | undefined { + // First check if the operation itself has the decorator + const value = getScopedDecoratorData(context, key, entity); + if (value !== undefined) { + return value; + } + + // Check the parent interface if the operation is in an interface + if (entity.interface) { + const interfaceValue = getScopedDecoratorData(context, key, entity.interface); + if (interfaceValue !== undefined) { + return interfaceValue; + } + } + + // Check the parent namespace hierarchy + let currentNamespace: Namespace | undefined = entity.namespace; + while (currentNamespace) { + const namespaceValue = getScopedDecoratorData(context, key, currentNamespace); + if (namespaceValue !== undefined) { + return namespaceValue; + } + currentNamespace = currentNamespace.namespace; + } + + return undefined; +} + +export function shouldGenerateProtocol(context: TCGCContext, entity: Operation): boolean { + const value = getConvenientOrProtocolValue(context, protocolAPIKey, entity); + return value ?? Boolean(context.generateProtocolMethods); +} + +export function shouldGenerateConvenient(context: TCGCContext, entity: Operation): boolean { + const value = getConvenientOrProtocolValue(context, convenientAPIKey, entity); + return value ?? Boolean(context.generateConvenienceMethods); +} + +export const $usage: UsageDecorator = ( + context: DecoratorContext, + entity: Model | Enum | Union | Namespace, + value: EnumMember | Union, + scope?: LanguageScopes, +) => { + const isValidValue = (value: number): boolean => { + // Allow the new usage values: input(2), output(4), json(256), xml(512) + return ( + value === UsageFlags.Input || + value === UsageFlags.Output || + value === UsageFlags.Json || + value === UsageFlags.Xml + ); + }; + + let newUsage = 0; + + if (value.kind === "EnumMember") { + if (typeof value.value === "number" && isValidValue(value.value)) { + newUsage = value.value; + } else { + reportDiagnostic(context.program, { + code: "invalid-usage", + format: {}, + target: entity, + }); + return; + } + } else { + for (const variant of value.variants.values()) { + if (variant.type.kind === "EnumMember" && typeof variant.type.value === "number") { + if (isValidValue(variant.type.value)) { + newUsage |= variant.type.value; + } + } else { + reportDiagnostic(context.program, { + code: "invalid-usage", + format: {}, + target: entity, + }); + return; + } + } + + if (newUsage === 0) { + reportDiagnostic(context.program, { + code: "invalid-usage", + format: {}, + target: entity, + }); + return; + } + } + + // Get existing usage and combine with new usage (additive behavior) + const existingUsage = getScopedDecoratorData(context as any, usageKey, entity) || 0; + const combinedUsage = existingUsage | newUsage; + + setScopedDecoratorData(context, $usage, usageKey, entity, combinedUsage, scope); +}; + +export function getUsageOverride( + context: TCGCContext, + entity: Model | Enum | Union, +): number | undefined { + const usageFlags = getScopedDecoratorData(context, usageKey, entity); + if (usageFlags || entity.namespace === undefined) return usageFlags; + return getScopedDecoratorData(context, usageKey, entity.namespace); +} + +export function getUsage(context: TCGCContext, entity: Model | Enum | Union): UsageFlags { + switch (entity.kind) { + case "Union": + const type = getSdkUnion(context, entity); + if (type.kind === "enum" || type.kind === "union" || type.kind === "nullable") { + return type.usage; + } + return UsageFlags.None; + case "Model": + return getSdkModel(context, entity).usage; + case "Enum": + return getSdkEnum(context, entity).usage; + } +} + +const accessKey = createStateSymbol("access"); + +export const $access: AccessDecorator = ( + context: DecoratorContext, + entity: Model | Enum | Operation | Union | Namespace | ModelProperty, + value: EnumMember, + scope?: LanguageScopes, +) => { + if (typeof value.value !== "string" || (value.value !== "public" && value.value !== "internal")) { + reportDiagnostic(context.program, { + code: "invalid-access", + format: {}, + target: entity, + }); + return; + } + setScopedDecoratorData(context, $access, accessKey, entity, value.value, scope); +}; + +export function getAccessOverride( + context: TCGCContext, + entity: Model | Enum | Operation | Union | Namespace | ModelProperty, +): AccessFlags | undefined { + const accessOverride = getScopedDecoratorData(context, accessKey, entity); + + if (!accessOverride && entity.kind !== "ModelProperty" && entity.namespace) { + return getAccessOverride(context, entity.namespace); + } + + return accessOverride; +} + +export function getAccess( + context: TCGCContext, + entity: Model | Enum | Operation | Union | ModelProperty, +) { + const override = getAccessOverride(context, entity); + if (override || entity.kind === "Operation" || entity.kind === "ModelProperty") { + return override || "public"; + } + + switch (entity.kind) { + case "Model": + return getSdkModel(context, entity).access; + case "Enum": + return getSdkEnum(context, entity).access; + case "Union": { + const type = getSdkUnion(context, entity); + if (type.kind === "enum" || type.kind === "union" || type.kind === "nullable") { + return type.access; + } + return "public"; + } + } +} + +const flattenPropertyKey = createStateSymbol("flattenProperty"); +/** + * Whether a model property should be flattened. + * + * @param context DecoratorContext + * @param target ModelProperty to mark as flattened + * @param scope Names of the projection (e.g. "python", "csharp", "java", "javascript") + */ +export const $flattenProperty: FlattenPropertyDecorator = ( + context: DecoratorContext, + target: ModelProperty, + scope?: LanguageScopes, +) => { + if (getDiscriminator(context.program, target.type)) { + reportDiagnostic(context.program, { + code: "flatten-polymorphism", + format: {}, + target: target, + }); + return; + } + setScopedDecoratorData(context, $flattenProperty, flattenPropertyKey, target, true, scope); +}; + +/** + * Whether a model property should be flattened or not. + * + * @param context TCGCContext + * @param target ModelProperty that we want to check whether it should be flattened or not + * @returns whether the model property should be flattened or not + */ +export function shouldFlattenProperty(context: TCGCContext, target: ModelProperty): boolean { + return getScopedDecoratorData(context, flattenPropertyKey, target) ?? false; +} + +export const $clientName: ClientNameDecorator = ( + context: DecoratorContext, + entity: Type, + value: string, + scope?: LanguageScopes, +) => { + // workaround for current lack of functionality in compiler + // https://github.com/microsoft/typespec/issues/2717 + if (entity.kind === "Model" || entity.kind === "Operation") { + const target = context.decoratorTarget as Node; + if (target.kind === SyntaxKind.AugmentDecoratorStatement) { + if ( + ( + ignoreDiagnostics( + (context.program.checker as any).resolveTypeReference(target.targetType), + ) as any + )?.node !== entity.node + ) { + return; + } + } + if (target.kind === SyntaxKind.DecoratorExpression) { + if (target.parent !== entity.node) { + return; + } + } + } + if (value.trim() === "") { + reportDiagnostic(context.program, { + code: "empty-client-name", + format: {}, + target: entity, + }); + return; + } + setScopedDecoratorData(context, $clientName, clientNameKey, entity, value, scope); +}; + +export function getClientNameOverride( + context: TCGCContext, + entity: Type, + languageScope?: string | typeof AllScopes, +): string | undefined { + return getScopedDecoratorData(context, clientNameKey, entity, languageScope); +} + +// Recursive function to collect parameter names +function collectParams( + program: Program, + properties: RekeyableMap, + params: ModelProperty[] = [], +): ModelProperty[] { + properties.forEach((value, key) => { + // If the property is of type 'model', recurse into its properties + if (!params.some((x) => compareModelProperties(program, x, value))) { + if (value.type.kind === "Model") { + collectParams(program, value.type.properties, params); + } else { + params.push(findRootSourceProperty(value)); + } + } + }); + + return params; +} + +export const $override = ( + context: DecoratorContext, + original: Operation, + override: Operation, + scope?: LanguageScopes, +) => { + // omit all override operation + context.program.stateMap(omitOperation).set(override, true); + + // Extract and sort parameter names + const originalParams = collectParams(context.program, original.parameters.properties).sort( + (a, b) => a.name.localeCompare(b.name), + ); + const overrideParams = collectParams(context.program, override.parameters.properties).sort( + (a, b) => a.name.localeCompare(b.name), + ); + + // Check if the sorted parameter names arrays are equal, omit optional parameters + let parametersMatch = true; + let checkParameter: ModelProperty | undefined = undefined; + let index = 0; + for (const originalParam of originalParams) { + if (index > overrideParams.length - 1) { + if (!originalParam.optional) { + parametersMatch = false; + checkParameter = originalParam; + break; + } else { + continue; + } + } + if (!compareModelProperties(context.program, originalParam, overrideParams[index])) { + if (!originalParam.optional) { + parametersMatch = false; + checkParameter = originalParam; + break; + } else { + continue; + } + } + + // Apply the alternate type to the original parameter + const overrideParam = overrideParams[index]; + overrideParam.decorators + .filter( + (d) => + d.definition?.name === "@alternateType" && + getNamespaceFullName(d.definition?.namespace) === namespace, + ) + .map((d) => + context.call( + $alternateType, + originalParam, + d.args[0].value as Type, + d.args[1]?.jsValue as string | undefined, + ), + ); + + index++; + } + + if (!parametersMatch) { + reportDiagnostic(context.program, { + code: "override-parameters-mismatch", + target: context.decoratorTarget, + format: { + methodName: original.name, + checkParameter: checkParameter?.name ?? "", + }, + }); + } + setScopedDecoratorData(context, $override, overrideKey, original, override, scope); +}; + +/** + * Gets additional information on how to serialize / deserialize TYPESPEC standard types depending + * on whether additional serialization information is provided or needed + * + * @param context the Sdk Context + * @param entity the entity whose client format we are going to get + * @returns the format in which to serialize the typespec type or undefined + */ +export function getOverriddenClientMethod( + context: TCGCContext, + entity: Operation, +): Operation | undefined { + return getScopedDecoratorData(context, overrideKey, entity); +} + +/** + * Check if a model is an external type. + * The external type model has properties: identity (required), package (optional), minVersion (optional). + */ +function isExternalType(model: Model): boolean { + if (model.indexer !== undefined) { + return false; + } + + const properties = [...model.properties.values()]; + + // Check if it has an 'identity' property with String literal type + const hasIdentity = properties.some( + (prop) => prop.name === "identity" && prop.type.kind === "String", + ); + + if (!hasIdentity) { + return false; + } + + // Check that all other properties are only 'package' or 'minVersion' with String literal types + const otherProps = properties.filter((prop) => prop.name !== "identity"); + const validProps = otherProps.every( + (prop) => + (prop.name === "package" || prop.name === "minVersion") && prop.type.kind === "String", + ); + + return validProps; +} + +const alternateTypeKey = createStateSymbol("alternateType"); + +/** + * Replace a source type with an alternate type in a specific scope. + * + * @param context the decorator context + * @param source source type to be replaced + * @param alternate target type to replace the source type or ExternalType object + * @param scope Names of the projection (e.g. "python", "csharp", "java", "javascript") + */ +export const $alternateType: AlternateTypeDecorator = ( + context: DecoratorContext, + source: ModelProperty | Scalar | Model | Enum | Union, + alternate: Type, + scope?: LanguageScopes, +) => { + let alternateInput: Type | ExternalTypeInfo = alternate; + if (alternate.kind === "Model" && isExternalType(alternate)) { + // This means we're dealing with external type + if (source.kind === "ModelProperty") { + reportDiagnostic(context.program, { + code: "external-type-on-model-property", + target: source, + }); + return; + } + if (!scope) { + reportDiagnostic(context.program, { + code: "missing-scope", + format: { + decoratorName: "@alternateType", + }, + target: source, + }); + } + + const alternatePropertyValues = [...alternate.properties.values()]; + // Get identity if needed + const identity = alternatePropertyValues + .filter((x) => x.name === "identity") + .map((x) => x.type) + .filter((x) => x.kind === "String") + .map((x) => x.value)[0]; + + const packageName = alternatePropertyValues + .filter((x) => x.name === "package") + .map((x) => x.type) + .filter((x) => x.kind === "String") + .map((x) => x.value)[0]; + + const minVersion = alternatePropertyValues + .filter((x) => x.name === "minVersion") + .map((x) => x.type) + .filter((x) => x.kind === "String") + .map((x) => x.value)[0]; + + alternateInput = { + kind: "externalTypeInfo", + identity, + package: packageName, + minVersion, + }; + } else { + // Not external type + if (source.kind === "Scalar" && alternate.kind !== "Scalar") { + reportDiagnostic(context.program, { + code: "invalid-alternate-type", + format: { + kindName: alternate.kind, + }, + target: alternate, + }); + return; + } + } + setScopedDecoratorData(context, $alternateType, alternateTypeKey, source, alternateInput, scope); +}; + +/** + * Get the alternate type for a source type in a specific scope. + * + * @param context the Sdk Context + * @param source source type to be replaced + * @returns alternate type to replace the source type, or undefined if no alternate type is found + */ +export function getAlternateType( + context: TCGCContext, + source: ModelProperty | Scalar | Model | Enum | Union, +): Type | ExternalTypeInfo | undefined { + const retval: Type | ExternalTypeInfo | undefined = getScopedDecoratorData( + context, + alternateTypeKey, + source, + ); + if (retval !== undefined && retval.kind === "externalTypeInfo") { + if (!context.__externalPackageToVersions) { + context.__externalPackageToVersions = new Map(); + } + const externalPackage = retval.package; + const externalMinVersion = retval.minVersion; + if (externalPackage && externalMinVersion) { + const existingVersion = context.__externalPackageToVersions.get(externalPackage); + if (existingVersion && existingVersion !== externalMinVersion) { + reportDiagnostic(context.program, { + code: "external-library-version-mismatch", + format: { + libraryName: externalPackage, + versionA: existingVersion, + versionB: externalMinVersion, + }, + target: source, + }); + } + context.__externalPackageToVersions.set(externalPackage, externalMinVersion); + } + } + return retval; +} + +export const $useSystemTextJsonConverter: DecoratorFunction = ( + context: DecoratorContext, + entity: Model, + scope?: LanguageScopes, +) => {}; + +const clientInitializationKey = createStateSymbol("clientInitialization"); + +export const $clientInitialization: ClientInitializationDecorator = ( + context: DecoratorContext, + target: Namespace | Interface, + options: Type, + scope?: LanguageScopes, +) => { + if (options.kind === "Model") { + if (options.properties.get("initializedBy")) { + const value = options.properties.get("initializedBy")!.type; + + const isValidValue = (value: number): boolean => value === 4 || value === 1 || value === 2; + + if (value.kind === "EnumMember") { + if (typeof value.value !== "number" || !isValidValue(value.value)) { + reportDiagnostic(context.program, { + code: "invalid-initialized-by", + format: { + message: "Please use `InitializedBy` enum to set the value.", + }, + target: target, + }); + return; + } + } else if (value.kind === "Union") { + for (const variant of value.variants.values()) { + if ( + variant.type.kind !== "EnumMember" || + typeof variant.type.value !== "number" || + !isValidValue(variant.type.value) + ) { + reportDiagnostic(context.program, { + code: "invalid-initialized-by", + format: { + message: "Please use `InitializedBy` enum to set the value.", + }, + target: target, + }); + return; + } + if (variant.type.value === 4) { + reportDiagnostic(context.program, { + code: "invalid-initialized-by", + format: { + message: "`InitializedBy.customizeCode` cannot be combined with other values.", + }, + target: target, + }); + return; + } + } + } + } + + setScopedDecoratorData( + context, + $clientInitialization, + clientInitializationKey, + target, + options, + scope, + ); + } +}; + +/** + * Get client initialization options for namespace or interface. The info is from `@clientInitialization` decorator. + * + * @param context + * @param entity namespace or interface which represents a client + * @returns + */ +export function getClientInitializationOptions( + context: TCGCContext, + entity: Namespace | Interface, +): ClientInitializationOptions | undefined { + const options = getScopedDecoratorData(context, clientInitializationKey, entity); + + // backward compatibility + if ( + options && + options.properties.get("initializedBy") === undefined && + options.properties.get("parameters") === undefined + ) { + return { + parameters: options, + }; + } + + let initializedBy = undefined; + + if (options?.properties.get("initializedBy")) { + if (options.properties.get("initializedBy").type.kind === "EnumMember") { + initializedBy = options.properties.get("initializedBy").type.value; + } else if (options.properties.get("initializedBy").type.kind === "Union") { + initializedBy = 0; + for (const variant of options.properties.get("initializedBy").type.variants.values()) { + initializedBy |= variant.type.value; + } + } + } + + let parametersModel = options?.properties.get("parameters")?.type; + let currEntity: Namespace | Interface | undefined = entity; + while (currEntity) { + const movedParameters = findEntriesWithTarget( + context, + clientLocationKey, + currEntity, + "ModelProperty", + ); + const tk = $(context.program); + if (movedParameters.length > 0) { + if (parametersModel) { + // If the parameters model already exists, we will merge the moved parameters into it. + for (const movedParameter of movedParameters) { + parametersModel.properties.set(movedParameter.name, movedParameter); + } + } else { + parametersModel = tk.model.create({ + name: "ClientInitializationParameters", + properties: { + ...Object.fromEntries( + movedParameters.map((movedParameter) => [movedParameter.name, movedParameter]), + ), + }, + }); + } + } + currEntity = currEntity.namespace; + } + + return { + parameters: parametersModel, + initializedBy: initializedBy, + }; +} + +const paramAliasKey = createStateSymbol("paramAlias"); + +export const $paramAlias: ParamAliasDecorator = ( + context: DecoratorContext, + original: ModelProperty, + paramAlias: string, + scope?: LanguageScopes, +) => { + const paramAliasDec = context.program.stateMap(paramAliasKey).get(original); + const paramAliasVal = paramAliasDec?.[scope || AllScopes] ?? paramAliasDec?.[AllScopes]; + if (paramAliasVal) { + reportDiagnostic(context.program, { + code: "multiple-param-alias", + format: { + originalName: original.name, + firstParamAlias: paramAliasVal, + }, + target: context.decoratorTarget, + }); + return; + } + setScopedDecoratorData(context, $paramAlias, paramAliasKey, original, paramAlias, scope); +}; + +export function getParamAlias(context: TCGCContext, original: ModelProperty): string | undefined { + return getScopedDecoratorData(context, paramAliasKey, original); +} + +const apiVersionKey = createStateSymbol("apiVersion"); + +export const $apiVersion: ApiVersionDecorator = ( + context: DecoratorContext, + target: ModelProperty, + value?: boolean, + scope?: LanguageScopes, +) => { + setScopedDecoratorData(context, $apiVersion, apiVersionKey, target, value ?? true, scope); +}; + +export function getIsApiVersion(context: TCGCContext, param: ModelProperty): boolean | undefined { + return getScopedDecoratorData(context, apiVersionKey, param); +} + +export const $clientNamespace: ClientNamespaceDecorator = ( + context: DecoratorContext, + entity: Namespace | Interface | Model | Enum | Union, + value: string, + scope?: LanguageScopes, +) => { + if (value.trim() === "") { + reportDiagnostic(context.program, { + code: "empty-client-namespace", + format: {}, + target: entity, + }); + return; + } + setScopedDecoratorData(context, $clientNamespace, clientNamespaceKey, entity, value, scope); +}; + +/** + * Find the shortest namespace that overlaps with the override string. + * @param override + * @param userDefinedNamespaces + * @returns + */ +function findNamespaceOverlapClosestToRoot( + override: string, + userDefinedNamespaces: Namespace[], +): Namespace | undefined { + for (const namespace of userDefinedNamespaces) { + if (override.includes(namespace.name)) { + return namespace; + } + } + + return undefined; +} + +/** + * Returns the client namespace for a given entity. The order of operations is as follows: + * + * 1. If `@clientNamespace` is applied to the entity, this wins out. + * a. If the `--namespace` flag is passed in during generation, we will replace the root of the client namespace with the flag. + * 2. If the `--namespace` flag is passed in, we treat that as the only namespace in the entire spec, and return that namespace. + * 3. We return the namespace of the entity retrieved from the original spec. + * @param context + * @param entity + * @returns + */ +export function getClientNamespace( + context: TCGCContext, + entity: Namespace | Interface | Model | Enum | Union, +): string { + const override = getScopedDecoratorData(context, clientNamespaceKey, entity); + if (override) { + // if `@clientNamespace` is applied to the entity, this wins out + // if the override matches or extends the namespace flag, no replacement is needed + if ( + context.namespaceFlag && + (override === context.namespaceFlag || override.startsWith(context.namespaceFlag + ".")) + ) { + return override; + } + const userDefinedNamespace = findNamespaceOverlapClosestToRoot( + override, + listAllUserDefinedNamespaces(context), + ); + if (userDefinedNamespace && context.namespaceFlag) { + // we still make sure to replace the root of the client namespace with the flag (if the flag exists) + return override.replace(userDefinedNamespace.name, context.namespaceFlag); + } + return override; + } + if (!entity.namespace) { + return ""; + } + if (entity.kind === "Namespace") { + return getNamespaceFullNameWithOverride(context, entity); + } + return getNamespaceFullNameWithOverride(context, entity.namespace); +} + +function getNamespaceFullNameWithOverride(context: TCGCContext, namespace: Namespace): string { + const segments = []; + let current: Namespace | undefined = namespace; + let isOverridden: boolean = false; + while (current && current.name !== "") { + const override = getScopedDecoratorData(context, clientNamespaceKey, current); + if (override) { + segments.unshift(override); + isOverridden = true; + break; + } + segments.unshift(current.name); + current = current.namespace; + } + const joinedSegments = segments.join("."); + if (isOverridden) { + // if it's overridden, and there's a `@clientNamespace` flag, we want to do the shortest namespace overlap replacement + const userDefinedNamespace = findNamespaceOverlapClosestToRoot( + joinedSegments, + listAllUserDefinedNamespaces(context), + ); + if (userDefinedNamespace && context.namespaceFlag) { + // Check if replacement would cause duplication: + // This happens when the namespace flag is an extension of the user-defined namespace + // and joinedSegments already starts with the flag (meaning override already applied it) + if ( + context.namespaceFlag.startsWith(userDefinedNamespace.name) && + (joinedSegments.startsWith(context.namespaceFlag + ".") || + joinedSegments === context.namespaceFlag) + ) { + return joinedSegments; + } + return joinedSegments.replace(userDefinedNamespace.name, context.namespaceFlag); + } + return joinedSegments; + } + if (context.namespaceFlag) return context.namespaceFlag; + return joinedSegments; +} + +export const $scope: ScopeDecorator = ( + context: DecoratorContext, + entity: Operation | ModelProperty, + scope?: LanguageScopes, +) => { + const [negationScopes, scopes] = parseScopes(scope); + if (negationScopes !== undefined && negationScopes.length > 0) { + // for negation scope, override the previous value + setScopedDecoratorData(context, $scope, negationScopesKey, entity, negationScopes); + } + if (scopes !== undefined && scopes.length > 0) { + // for normal scope, add them incrementally + const targetEntry = context.program.stateMap(scopeKey).get(entity); + setScopedDecoratorData( + context, + $scope, + scopeKey, + entity, + !targetEntry ? scopes : [...Object.values(targetEntry), ...scopes], + ); + } +}; + +const clientApiVersionsKey = createStateSymbol("clientApiVersions"); + +/** + * Add additional api versions that are possible for the client to use. + * + * @param context + * @param target Service namespace that has these additional api versions + * @param value Enum with the additional api versions + * @param scope + */ +export const $clientApiVersions: ClientApiVersionsDecorator = ( + context: DecoratorContext, + target: Namespace, + value: Enum, + scope?: LanguageScopes, +) => { + setScopedDecoratorData(context, $clientApiVersions, clientApiVersionsKey, target, value, scope); +}; + +/** + * Get the explicit client api versions that are possible for the client to use denoted by `@clientApiVersions` + * + * @param context + * @param target + * @returns + */ +export function getExplicitClientApiVersions( + context: TCGCContext, + target: Namespace, +): Enum | undefined { + return getScopedDecoratorData(context, clientApiVersionsKey, target); +} +export const $deserializeEmptyStringAsNull: DeserializeEmptyStringAsNullDecorator = ( + context: DecoratorContext, + target: ModelProperty, + scope?: LanguageScopes, +) => { + if (target.type.kind !== "Scalar") { + reportDiagnostic(context.program, { + code: "invalid-deserializeEmptyStringAsNull-target-type", + format: {}, + target: target, + }); + return; + } + + if (target.type.name !== "string") { + let scalarType = target.type as Scalar; + while (scalarType.baseScalar !== undefined) { + scalarType = scalarType.baseScalar; + } + + if (scalarType.name !== "string") { + reportDiagnostic(context.program, { + code: "invalid-deserializeEmptyStringAsNull-target-type", + format: {}, + target: target, + }); + return; + } + } +}; + +const responseAsBoolKey = createStateSymbol("responseAsBool"); + +export const $responseAsBool: ResponseAsBoolDecorator = ( + context: DecoratorContext, + target: Operation, + scope?: LanguageScopes, +) => { + if (!target.decorators.some((d) => d.definition?.name === "@head")) { + reportDiagnostic(context.program, { + code: "non-head-bool-response-decorator", + format: { + operationName: target.name, + }, + target: target, + }); + return; + } + setScopedDecoratorData(context, $responseAsBool, responseAsBoolKey, target, true, scope); +}; + +export function getResponseAsBool(context: TCGCContext, target: Operation): boolean { + return getScopedDecoratorData(context, responseAsBoolKey, target); +} + +const clientDocKey = createStateSymbol("clientDoc"); + +/** + * Type representing the client documentation data stored. + */ +interface ClientDocData { + documentation: string; + mode: string; +} + +export const $clientDoc: ClientDocDecorator = ( + context: DecoratorContext, + target: Type, + documentation: string, + mode: EnumMember, + scope?: LanguageScopes, +) => { + const docMode = mode.value as string; + // Validate the mode value + if (docMode !== "append" && docMode !== "replace") { + reportDiagnostic(context.program, { + code: "invalid-client-doc-mode", + format: { mode: docMode }, + target: context.decoratorTarget, + }); + return; + } + + const docData: ClientDocData = { + documentation, + mode: docMode, + }; + + setScopedDecoratorData(context, $clientDoc, clientDocKey, target, docData, scope); +}; + +/** + * Gets the client documentation data for a type. + * + * @param context TCGCContext + * @param target Type to get client documentation for + * @returns ClientDocData or undefined if no client documentation exists + */ +export function getClientDocExplicit( + context: TCGCContext, + target: Type, +): ClientDocData | undefined { + return getScopedDecoratorData(context, clientDocKey, target); +} + +export const $clientLocation = ( + context: DecoratorContext, + source: Operation | ModelProperty, + target: Interface | Namespace | Operation | string, + scope?: LanguageScopes, +) => { + if (source.kind === "Operation") { + // can only move parameters to an operation, not another operation + if (typeof target !== "string" && target.kind === "Operation") { + reportDiagnostic(context.program, { + code: "client-location-conflict", + format: { operationName: source.name }, + target: context.decoratorTarget, + messageId: "operationToOperation", + }); + return; + } + } else if (source.kind === "ModelProperty") { + // verify that there isn't a conflict with existing client initialization parameter + if ( + typeof target !== "string" && + (target.kind === "Interface" || target.kind === "Namespace") + ) { + const clientInitializationParams = target.decorators + .filter((d) => d.decorator.name === "$clientInitialization") + .map((d) => d.args[0].value) + .filter((a): a is Model => a.entityKind === "Type" && a.kind === "Model") + .filter((model) => model.properties.has(source.name)) + .map((model) => model.properties.get(source.name)!); + if (clientInitializationParams.length > 0) { + reportDiagnostic(context.program, { + code: "client-location-conflict", + format: { parameterName: source.name }, + target: context.decoratorTarget, + messageId: "modelPropertyToClientInitialization", + }); + return; + } + } + if (typeof target === "string") { + reportDiagnostic(context.program, { + code: "client-location-conflict", + format: { parameterName: source.name }, + target: context.decoratorTarget, + messageId: "modelPropertyToString", + }); + return; + } + } + setScopedDecoratorData(context, $clientLocation, clientLocationKey, source, target, scope); +}; + +/** + * Gets the `Namespace`, `Interface` or name of client where an operation changes location to. + */ +export function getClientLocation( + context: TCGCContext, + input: Operation, +): Namespace | Interface | string | undefined; + +/** + * Gets the `Namespace`, `Interface`, `Operation` where a parameter changes location to. + */ +export function getClientLocation( + context: TCGCContext, + input: ModelProperty, +): Namespace | Interface | Operation | undefined; + +/** + * Gets the `Namespace`, `Interface`, `Operation` or name of client where an operation / parameter change the location to. + * + * @param context TCGCContext + * @param input Operation or parameter to be moved + * @returns `Namespace`, `Interface`, `Operation`, `string` target or undefined if no location change. + */ +export function getClientLocation( + context: TCGCContext, + input: Operation | ModelProperty, +): Namespace | Interface | Operation | string | undefined { + return getScopedDecoratorData(context, clientLocationKey, input); +} + +export const $legacyHierarchyBuilding: HierarchyBuildingDecorator = ( + context: DecoratorContext, + target: Model, + value: Model, + scope?: LanguageScopes, +) => { + setScopedDecoratorData( + context, + $legacyHierarchyBuilding, + legacyHierarchyBuildingKey, + target, + value, + scope, + ); +}; + +export function getLegacyHierarchyBuilding(context: TCGCContext, target: Model): Model | undefined { + // If legacy hierarchy building is not respected, ignore the decorator completely + if (!context.enableLegacyHierarchyBuilding) return undefined; + + return getScopedDecoratorData(context, legacyHierarchyBuildingKey, target); +} + +const markAsLroKey = createStateSymbol("markAsLro"); + +export const $markAsLro: MarkAsLroDecorator = ( + context: DecoratorContext, + target: Operation, + scope?: LanguageScopes, +) => { + const httpOperation = ignoreDiagnostics(getHttpOperation(context.program, target)); + const hasModelResponse = httpOperation.responses.filter( + (r) => + r.type?.kind === "Model" && !(r.statusCodes === "*" || isErrorModel(context.program, r.type)), + )[0]; + if (!hasModelResponse) { + reportDiagnostic(context.program, { + code: "invalid-mark-as-lro-target", + format: { + operation: target.name, + }, + target: context.decoratorTarget, + }); + return; + } + setScopedDecoratorData(context, $markAsLro, markAsLroKey, target, true, scope); +}; + +export function getMarkAsLro(context: TCGCContext, entity: Operation): boolean { + return getScopedDecoratorData(context, markAsLroKey, entity) ?? false; +} + +const markAsPageableKey = createStateSymbol("markAsPageable"); + +export const $markAsPageable: MarkAsPageableDecorator = ( + context: DecoratorContext, + target: Operation, + scope?: LanguageScopes, +) => { + const httpOperation = ignoreDiagnostics(getHttpOperation(context.program, target)); + const modelResponse = httpOperation.responses.filter( + (r) => + r.type?.kind === "Model" && !(r.statusCodes === "*" || isErrorModel(context.program, r.type)), + )[0]; + if (!modelResponse) { + reportDiagnostic(context.program, { + code: "invalid-mark-as-pageable-target", + format: { + operation: target.name, + }, + target: context.decoratorTarget, + }); + return; + } + + // Check if already marked with @list decorator + if (isList(context.program, target)) { + reportDiagnostic(context.program, { + code: "mark-as-pageable-ineffective", + format: { + operation: target.name, + }, + target: context.decoratorTarget, + }); + return; + } + + // Check the response model for @pageItems decorator + const responseType = getRealResponseModel(context.program, modelResponse.type as Model); + if (responseType.kind !== "Model") { + reportDiagnostic(context.program, { + code: "invalid-mark-as-pageable-target", + format: { + operation: target.name, + }, + target: context.decoratorTarget, + }); + return; + } + + // Check if any property has @pageItems decorator by checking the program state + // The @pageItems decorator uses a state symbol "TypeSpec.pageItems" + const pageItemsStateKey = Symbol.for("TypeSpec.pageItems"); + let itemsProperty: ModelProperty | undefined = undefined; + for (const [, prop] of responseType.properties) { + if (context.program.stateSet(pageItemsStateKey).has(prop)) { + itemsProperty = prop; + break; + } + } + + if (!itemsProperty) { + // Try to find a property named "value" + itemsProperty = responseType.properties.get("value"); + if (!itemsProperty) { + // No @pageItems property and no "value" property found + reportDiagnostic(context.program, { + code: "invalid-mark-as-pageable-target", + format: { + operation: target.name, + }, + target: context.decoratorTarget, + }); + return; + } + } + + // Store metadata that will be checked by TCGC to treat this operation as pageable + setScopedDecoratorData( + context, + $markAsPageable, + markAsPageableKey, + target, + { itemsProperty }, + scope, + ); +}; + +export function getMarkAsPageable( + context: TCGCContext, + entity: Operation, +): MarkAsPageableInfo | undefined { + return getScopedDecoratorData(context, markAsPageableKey, entity); +} + +export interface MarkAsPageableInfo { + itemsProperty: ModelProperty; +} + +const disablePageableKey = createStateSymbol("disablePageable"); + +export const $disablePageable: DisablePageableDecorator = ( + context: DecoratorContext, + target: Operation, + scope?: LanguageScopes, +) => { + setScopedDecoratorData(context, $disablePageable, disablePageableKey, target, true, scope); +}; + +export function getDisablePageable(context: TCGCContext, entity: Operation): boolean { + return getScopedDecoratorData(context, disablePageableKey, entity) ?? false; +} + +function getRealResponseModel(program: Program, responseModel: Model): Type { + let bodyProperty: ModelProperty | undefined = undefined; + for (const prop of responseModel.properties.values()) { + if (isBody(program, prop) || isBodyRoot(program, prop)) { + bodyProperty = prop; + break; + } + } + if (bodyProperty) { + return bodyProperty.type; + } + return responseModel; +} + +const nextLinkVerbKey = createStateSymbol("nextLinkVerb"); + +export const $nextLinkVerb: NextLinkVerbDecorator = ( + context: DecoratorContext, + target: Operation, + verb: Type, + scope?: LanguageScopes, +) => { + compilerAssert( + verb.kind === "String" && (verb.value === "POST" || verb.value === "GET"), + "@nextLinkVerb decorator only supports 'POST' or 'GET' string literal values.", + ); + setScopedDecoratorData(context, $nextLinkVerb, nextLinkVerbKey, target, verb.value, scope); +}; + +/** + * Get the HTTP verb specified for next link operations in paging scenarios. + * @param context TCGCContext + * @param entity Operation to check for nextLinkVerb decorator + * @returns The HTTP verb string ("POST" or "GET"). Defaults to "GET" if decorator is not applied. + */ +export function getNextLinkVerb(context: TCGCContext, entity: Operation): "GET" | "POST" { + return getScopedDecoratorData(context, nextLinkVerbKey, entity) ?? "GET"; +} + +const clientDefaultValueKey = createStateSymbol("clientDefaultValue"); + +export const $clientDefaultValue: ClientDefaultValueDecorator = ( + context: DecoratorContext, + target: ModelProperty, + value: string | boolean | Numeric, + scope?: LanguageScopes, +) => { + const actualValue = isNumeric(value) ? value.asNumber() : value; + setScopedDecoratorData( + context, + $clientDefaultValue, + clientDefaultValueKey, + target, + actualValue, + scope, + ); +}; + +/** + * Get the client-level default value for a model property. + * @param context TCGCContext + * @param entity ModelProperty to check for clientDefaultValue decorator + * @returns The client-level default value if decorator is applied, undefined otherwise. + */ +export function getClientDefaultValue( + context: TCGCContext, + entity: ModelProperty, +): string | boolean | Numeric | undefined { + return getScopedDecoratorData(context, clientDefaultValueKey, entity); +} + +/** + * Check if an operation or model property is in scope for the current emitter. + * @param context TCGCContext + * @param entity Operation or ModelProperty to check if it is in scope + * @returns + */ +export function isInScope(context: TCGCContext, entity: Operation | ModelProperty): boolean { + const scopes = getScopedDecoratorData(context, scopeKey, entity); + const negationScopes = getScopedDecoratorData(context, negationScopesKey, entity); + + if (scopes !== undefined) { + if (scopes.includes(context.emitterName)) { + return true; + } + + if (negationScopes === undefined) { + return false; + } + } + + if (negationScopes !== undefined && negationScopes.includes(context.emitterName)) { + return false; + } + return true; +} + +export const clientOptionKey = createStateSymbol("ClientOption"); + +/** + * `@clientOption` decorator implementation. + * Pass experimental flags or options to emitters without requiring TCGC reshipping. + * The decorator data is stored as {name, value} and exposed via the decorators array. + */ +export const $clientOption: ClientOptionDecorator = ( + context: DecoratorContext, + target: Type, + name: string, + value: unknown, + scope?: LanguageScopes, +) => { + // Always emit warning that this is experimental + reportDiagnostic(context.program, { + code: "client-option", + target: context.decoratorTarget, + }); + + // Emit additional warning if scope is not provided + if (scope === undefined) { + reportDiagnostic(context.program, { + code: "client-option-requires-scope", + target: context.decoratorTarget, + }); + } + + // Store the option data - each decorator application is stored separately + // The decorator info will be exposed via the decorators array on SDK types + setScopedDecoratorData(context, $clientOption, clientOptionKey, target, { name, value }, scope); +}; + +/** + * Gets the value of a specific client option for a target. + * Checks the target itself and walks up the namespace/interface hierarchy. + */ +export function getClientOptionValue( + context: TCGCContext, + target: Operation, + optionName: string, +): unknown | undefined { + // Check operation directly + const opOption = getScopedDecoratorData(context, clientOptionKey, target) as + | { name: string; value: unknown } + | undefined; + if (opOption?.name === optionName) { + return opOption.value; + } + + // Check interface if operation is in one + if (target.interface) { + const ifaceOption = getScopedDecoratorData(context, clientOptionKey, target.interface) as + | { name: string; value: unknown } + | undefined; + if (ifaceOption?.name === optionName) { + return ifaceOption.value; + } + } + + // Check namespace hierarchy + let ns = target.namespace; + while (ns) { + const nsOption = getScopedDecoratorData(context, clientOptionKey, ns) as + | { name: string; value: unknown } + | undefined; + if (nsOption?.name === optionName) { + return nsOption.value; + } + ns = ns.namespace; + } + + return undefined; +} + +/** + * Known client option: omitSlashFromEmptyRoute + * When set to true, operations with empty routes ("/") will have their path set to "". + */ +export function shouldOmitSlashFromEmptyRoute(context: TCGCContext, target: Operation): boolean { + return getClientOptionValue(context, target, "omitSlashFromEmptyRoute") === true; +} diff --git a/packages/http-client-generator-core/src/example.ts b/packages/http-client-generator-core/src/example.ts new file mode 100644 index 00000000000..715c797a47b --- /dev/null +++ b/packages/http-client-generator-core/src/example.ts @@ -0,0 +1,789 @@ +import { + CompilerHost, + Diagnostic, + DiagnosticCollector, + NoTarget, + Program, + createDiagnosticCollector, + getAnyExtensionFromPath, + getRelativePathFromDirectory, + joinPaths, + normalizePath, + resolvePath, +} from "@typespec/compiler"; +import { + SdkArrayExampleValue, + SdkArrayType, + SdkClientType, + SdkDictionaryExampleValue, + SdkDictionaryType, + SdkExampleValue, + SdkHttpOperation, + SdkHttpOperationExample, + SdkHttpParameter, + SdkHttpParameterExampleValue, + SdkHttpResponse, + SdkHttpResponseExampleValue, + SdkModelExampleValue, + SdkModelPropertyType, + SdkModelType, + SdkServiceMethod, + SdkServiceOperation, + SdkType, + TCGCContext, + isSdkFloatKind, + isSdkIntKind, +} from "./interfaces.js"; +import { createDiagnostic } from "./lib.js"; +import { resolveOperationId } from "./public-utils.js"; + +interface LoadedExample { + readonly relativePath: string; + readonly data: any; +} + +async function checkExamplesDirExists(host: CompilerHost, dir: string) { + try { + return (await host.stat(dir)).isDirectory(); + } catch (err) { + return false; + } +} + +/** + * Load all examples for a client + * + * @param context + * @returns a map of all operations' examples, key is operation's operation id, + * value is a map of examples, key is example's title, value is example's details + */ +async function loadExamples( + context: TCGCContext, +): Promise<[Map>, readonly Diagnostic[]]> { + const diagnostics = createDiagnosticCollector(); + + const apiVersions = context.getPackageVersions(); + const exampleDirs: string[][] = []; + if (apiVersions.size <= 1) { + // single service case + const apiVersion = + apiVersions.size === 1 ? apiVersions.values().next().value?.at(-1) : undefined; + const examplesBaseDir = resolvePath( + context.program.projectRoot, + context.examplesDir ?? "./examples", + ); + const exampleDir = apiVersion + ? resolvePath(examplesBaseDir, apiVersion) + : resolvePath(examplesBaseDir); + if (!(await checkExamplesDirExists(context.program.host, exampleDir))) { + if (context.examplesDir) { + diagnostics.add( + createDiagnostic({ + code: "example-loading", + messageId: "noDirectory", + format: { directory: exampleDir }, + target: NoTarget, + }), + ); + } + return diagnostics.wrap(new Map()); + } + exampleDirs.push([exampleDir, examplesBaseDir]); + } else { + // multiple services case, we need to load examples from sub service folders + for (const [service, versions] of apiVersions) { + const apiVersion = versions.length > 0 ? versions[versions.length - 1] : undefined; + const examplesBaseDir = resolvePath( + context.program.projectRoot, + service.name, + context.examplesDir ?? "./examples", + ); + const exampleDir = apiVersion + ? resolvePath(examplesBaseDir, apiVersion) + : resolvePath(examplesBaseDir); + + if (await checkExamplesDirExists(context.program.host, exampleDir)) { + exampleDirs.push([exampleDir, examplesBaseDir]); + } + } + } + + const map = new Map>(); + for (const [exampleDir, examplesBaseDir] of exampleDirs) { + const exampleFiles = await searchExampleJsonFiles(context.program, exampleDir); + for (const fileName of exampleFiles) { + try { + const exampleFile = await context.program.host.readFile(resolvePath(exampleDir, fileName)); + const example = JSON.parse(exampleFile.text); + if (!example.operationId || !example.title) { + diagnostics.add( + createDiagnostic({ + code: "example-loading", + messageId: "noOperationId", + format: { filename: fileName }, + target: NoTarget, + }), + ); + continue; + } + + if (!map.has(example.operationId.toLowerCase())) { + map.set(example.operationId.toLowerCase(), {}); + } + const examples = map.get(example.operationId.toLowerCase())!; + + if (example.title in examples) { + diagnostics.add( + createDiagnostic({ + code: "duplicate-example-file", + target: NoTarget, + format: { + filename: fileName, + operationId: example.operationId, + title: example.title, + }, + }), + ); + } + + examples[example.title] = { + relativePath: getRelativePathFromDirectory( + examplesBaseDir, + resolvePath(exampleDir, fileName), + false, + ), + data: example, + }; + } catch (err) { + diagnostics.add( + createDiagnostic({ + code: "example-loading", + messageId: "default", + format: { filename: fileName, error: err?.toString() ?? "" }, + target: NoTarget, + }), + ); + } + } + } + return diagnostics.wrap(map); +} + +async function searchExampleJsonFiles(program: Program, exampleDir: string): Promise { + const host = program.host; + const exampleFiles: string[] = []; + + // Recursive file search + async function recursiveSearch(dir: string): Promise { + const fileItems = await host.readDir(dir); + + for (const item of fileItems) { + const fullPath = joinPaths(dir, item); + const relativePath = getRelativePathFromDirectory(exampleDir, fullPath, false); + + if ((await host.stat(fullPath)).isDirectory()) { + await recursiveSearch(fullPath); + } else if ( + (await host.stat(fullPath)).isFile() && + getAnyExtensionFromPath(item) === ".json" + ) { + exampleFiles.push(normalizePath(relativePath)); + } + } + } + + await recursiveSearch(exampleDir); + return exampleFiles; +} + +export async function handleClientExamples( + context: TCGCContext, + client: SdkClientType, +): Promise<[void, readonly Diagnostic[]]> { + const diagnostics = createDiagnosticCollector(); + + const examples = diagnostics.pipe(await loadExamples(context)); + const clientQueue = [client]; + while (clientQueue.length > 0) { + const client = clientQueue.pop()!; + if (client.children) { + clientQueue.push(...client.children); + } + for (const method of client.methods) { + // since operation could have customization in client.tsp, we need to handle all the original operation + let operation = method.__raw; + while (operation) { + // try operation id with renaming + let operationId = resolveOperationId(context, operation, true).toLowerCase(); + if (examples.has(operationId)) { + diagnostics.pipe(handleMethodExamples(context, method, examples.get(operationId)!)); + break; + } + // try operation id without renaming + operationId = resolveOperationId(context, operation, false).toLowerCase(); + if (examples.has(operationId)) { + diagnostics.pipe(handleMethodExamples(context, method, examples.get(operationId)!)); + break; + } + operation = operation.sourceOperation; + } + } + } + return diagnostics.wrap(undefined); +} + +function handleMethodExamples( + context: TCGCContext, + method: SdkServiceMethod, + examples: Record, +): [void, readonly Diagnostic[]] { + const diagnostics = createDiagnosticCollector(); + + if (method.operation.kind === "http") { + diagnostics.pipe(handleHttpOperationExamples(method.operation, examples)); + if (method.operation.examples) { + context.__httpOperationExamples.set(method.operation.__raw, method.operation.examples); + } + } + + return diagnostics.wrap(undefined); +} + +function handleHttpOperationExamples( + operation: SdkHttpOperation, + examples: Record, +) { + const diagnostics = createDiagnosticCollector(); + operation.examples = []; + + for (const [title, example] of Object.entries(examples)) { + const operationExample: SdkHttpOperationExample = { + kind: "http", + name: title, + doc: title, + filePath: example.relativePath, + parameters: diagnostics.pipe( + handleHttpParameters( + operation.bodyParam + ? [...operation.parameters, operation.bodyParam] + : operation.parameters, + example.data, + example.relativePath, + ), + ), + responses: diagnostics.pipe( + handleHttpResponses(operation.responses, example.data, example.relativePath), + ), + rawExample: example.data, + }; + + operation.examples.push(operationExample); + } + + // sort examples by file path + operation.examples.sort((a, b) => (a.filePath > b.filePath ? 1 : -1)); + + return diagnostics.wrap(undefined); +} + +function handleHttpParameters( + parameters: SdkHttpParameter[], + example: any, + relativePath: string, +): [SdkHttpParameterExampleValue[], readonly Diagnostic[]] { + const diagnostics = createDiagnosticCollector(); + const parameterExamples: SdkHttpParameterExampleValue[] = []; + if ( + "parameters" in example && + typeof example.parameters === "object" && + example.parameters !== null + ) { + for (const name of Object.keys(example.parameters)) { + let parameter = parameters.find((p) => p.serializedName === name); + // fallback to use client name for any body parameter + if (!parameter) { + parameter = parameters.find((p) => p.name === name && p.kind === "body"); + } + // fallback to body in example for any body parameter + if (!parameter && name === "body") { + parameter = parameters.find((p) => p.kind === "body"); + } + if (parameter) { + const value = diagnostics.pipe( + getSdkTypeExample(parameter.type, example.parameters[name], relativePath), + ); + if (value) { + parameterExamples.push({ + parameter, + value, + }); + } + } else { + addExampleValueNoMappingDignostic( + diagnostics, + { [name]: example.parameters[name] }, + relativePath, + ); + } + } + } + return diagnostics.wrap(parameterExamples); +} + +function handleHttpResponses( + responses: SdkHttpResponse[], + example: any, + relativePath: string, +): [SdkHttpResponseExampleValue[], readonly Diagnostic[]] { + const diagnostics = createDiagnosticCollector(); + const responseExamples: SdkHttpResponseExampleValue[] = []; + if ( + "responses" in example && + typeof example.responses === "object" && + example.responses !== null + ) { + for (const code of Object.keys(example.responses)) { + const statusCode = parseInt(code, 10); + let found = false; + for (const response of responses) { + const responseCode = response.statusCodes; + if (responseCode === statusCode) { + responseExamples.push( + diagnostics.pipe( + handleHttpResponse(response, statusCode, example.responses[code], relativePath), + ), + ); + found = true; + break; + } else if ( + typeof responseCode === "object" && + responseCode !== null && + responseCode.start <= statusCode && + responseCode.end >= statusCode + ) { + responseExamples.push( + diagnostics.pipe( + handleHttpResponse(response, statusCode, example.responses[code], relativePath), + ), + ); + found = true; + break; + } + } + if (!found) { + addExampleValueNoMappingDignostic( + diagnostics, + { [code]: example.responses[code] }, + relativePath, + ); + } + } + } + return diagnostics.wrap(responseExamples); +} + +function handleHttpResponse( + response: SdkHttpResponse, + statusCode: number, + example: any, + relativePath: string, +): [SdkHttpResponseExampleValue, readonly Diagnostic[]] { + const diagnostics = createDiagnosticCollector(); + const responseExample: SdkHttpResponseExampleValue = { + response, + statusCode, + headers: [], + }; + if (typeof example === "object" && example !== null) { + for (const name of Object.keys(example)) { + if (name === "description") { + continue; + } else if (name === "body") { + if (response.type) { + responseExample.bodyValue = diagnostics.pipe( + getSdkTypeExample(response.type, example.body, relativePath), + ); + } else { + addExampleValueNoMappingDignostic(diagnostics, { body: example.body }, relativePath); + } + } else if (name === "headers") { + for (const subName of Object.keys(example.headers)) { + const header = response.headers.find((p) => p.serializedName === subName); + if (header) { + const value = diagnostics.pipe( + getSdkTypeExample(header.type, example[name][subName], relativePath), + ); + if (value) { + responseExample.headers.push({ + header, + value, + }); + } + } else { + addExampleValueNoMappingDignostic( + diagnostics, + { [subName]: example[name][subName] }, + relativePath, + ); + } + } + } else { + addExampleValueNoMappingDignostic(diagnostics, { [name]: example[name] }, relativePath); + } + } + } + return diagnostics.wrap(responseExample); +} + +function getSdkTypeExample( + type: SdkType, + example: any, + relativePath: string, +): [SdkExampleValue | undefined, readonly Diagnostic[]] { + const diagnostics = createDiagnosticCollector(); + + if (example === null && type.kind !== "nullable" && type.kind !== "unknown") { + return diagnostics.wrap(undefined); + } + + if (isSdkIntKind(type.kind) || isSdkFloatKind(type.kind)) { + return getSdkBaseTypeExample("number", type as SdkType, example, relativePath); + } else { + switch (type.kind) { + case "string": + case "bytes": + return getSdkBaseTypeExample("string", type, example, relativePath); + case "boolean": + return getSdkBaseTypeExample("boolean", type, example, relativePath); + case "url": + case "plainDate": + case "plainTime": + return getSdkBaseTypeExample("string", type, example, relativePath); + case "nullable": + if (example === null) { + return diagnostics.wrap({ + kind: "null", + type, + value: null, + }); + } else { + return getSdkTypeExample(type.type, example, relativePath); + } + case "unknown": + return diagnostics.wrap({ + kind: "unknown", + type, + value: example, + }); + case "constant": + if (example === type.value) { + return getSdkBaseTypeExample( + typeof type.value as "string" | "number" | "boolean", + type, + example, + relativePath, + ); + } else { + addExampleValueNoMappingDignostic(diagnostics, example, relativePath); + return diagnostics.wrap(undefined); + } + case "enum": + if (type.values.some((v) => v.value === example) || !type.isFixed) { + return getSdkBaseTypeExample( + typeof example as "string" | "number", + type, + example, + relativePath, + ); + } else { + addExampleValueNoMappingDignostic(diagnostics, example, relativePath); + return diagnostics.wrap(undefined); + } + case "enumvalue": + if (type.value === example) { + return getSdkBaseTypeExample( + typeof example as "string" | "number", + type, + example, + relativePath, + ); + } else { + addExampleValueNoMappingDignostic(diagnostics, example, relativePath); + return diagnostics.wrap(undefined); + } + case "utcDateTime": + case "offsetDateTime": + case "duration": + const inner = diagnostics.pipe(getSdkTypeExample(type.wireType, example, relativePath)); + if (inner) { + inner.type = type; + } + return diagnostics.wrap(inner); + case "union": + return diagnostics.wrap({ + kind: "union", + type, + value: example, + }); + case "array": + return getSdkArrayExample(type, example, relativePath); + case "dict": + return getSdkDictionaryExample(type, example, relativePath); + case "model": + return getSdkModelExample(type, example, relativePath); + } + } + return diagnostics.wrap(undefined); +} + +/** + * Attempts to convert a string value to a number. + * Returns the converted number if valid, undefined otherwise. + */ +function tryConvertStringToNumber(value: string): number | undefined { + if (typeof value !== "string" || value.trim() === "") { + return undefined; + } + + const num = Number(value.trim()); + if (isNaN(num) || !isFinite(num)) { + return undefined; + } + + return num; +} + +/** + * Attempts to convert a string value to a boolean. + * Returns the converted boolean if valid, undefined otherwise. + */ +function tryConvertStringToBoolean(value: string): boolean | undefined { + if (typeof value !== "string") { + return undefined; + } + + const lowerValue = value.toLowerCase().trim(); + if (lowerValue === "true") { + return true; + } else if (lowerValue === "false") { + return false; + } + + return undefined; +} + +function getSdkBaseTypeExample( + kind: "string" | "number" | "boolean", + type: SdkType, + example: any, + relativePath: string, +): [SdkExampleValue | undefined, readonly Diagnostic[]] { + const diagnostics = createDiagnosticCollector(); + + // Direct type match - use as is + if (typeof example === kind) { + return diagnostics.wrap({ + kind, + type, + value: example, + } as SdkExampleValue); + } + + // Try string conversion for number and boolean types + if (typeof example === "string") { + if (kind === "number") { + const convertedNumber = tryConvertStringToNumber(example); + if (convertedNumber !== undefined) { + return diagnostics.wrap({ + kind, + type, + value: convertedNumber, + } as SdkExampleValue); + } + } else if (kind === "boolean") { + const convertedBoolean = tryConvertStringToBoolean(example); + if (convertedBoolean !== undefined) { + return diagnostics.wrap({ + kind, + type, + value: convertedBoolean, + } as SdkExampleValue); + } + } + } + + // If no conversion was possible, add diagnostic + addExampleValueNoMappingDignostic(diagnostics, example, relativePath); + return diagnostics.wrap(undefined); +} + +function getSdkArrayExample( + type: SdkArrayType, + example: any, + relativePath: string, +): [SdkArrayExampleValue | undefined, readonly Diagnostic[]] { + const diagnostics = createDiagnosticCollector(); + if (Array.isArray(example)) { + const arrayExample: SdkExampleValue[] = []; + for (const item of example) { + const result = diagnostics.pipe(getSdkTypeExample(type.valueType, item, relativePath)); + if (result) { + arrayExample.push(result); + } + } + return diagnostics.wrap({ + kind: "array", + type, + value: arrayExample, + }); + } else { + addExampleValueNoMappingDignostic(diagnostics, example, relativePath); + return diagnostics.wrap(undefined); + } +} + +function getSdkDictionaryExample( + type: SdkDictionaryType, + example: any, + relativePath: string, +): [SdkDictionaryExampleValue | undefined, readonly Diagnostic[]] { + const diagnostics = createDiagnosticCollector(); + if (typeof example === "object") { + const dictionaryExample: Record = {}; + for (const key of Object.keys(example)) { + const result = diagnostics.pipe( + getSdkTypeExample(type.valueType, example[key], relativePath), + ); + if (result) { + dictionaryExample[key] = result; + } + } + return diagnostics.wrap({ + kind: "dict", + type, + value: dictionaryExample, + }); + } else { + addExampleValueNoMappingDignostic(diagnostics, example, relativePath); + return diagnostics.wrap(undefined); + } +} + +function getSdkModelExample( + type: SdkModelType, + example: any, + relativePath: string, +): [SdkModelExampleValue | undefined, readonly Diagnostic[]] { + const diagnostics = createDiagnosticCollector(); + if (typeof example === "object") { + // handle discriminated model + if (type.discriminatorProperty) { + if (type.discriminatorProperty.name in example) { + if ( + type.discriminatedSubtypes && + example[type.discriminatorProperty.name] in type.discriminatedSubtypes + ) { + // handle example type that is defined in discriminated subtypes + // else, fallback to the base model, handle out of the discriminator if + return getSdkModelExample( + type.discriminatedSubtypes![example[type.discriminatorProperty.name]], + example, + relativePath, + ); + } + } else { + addExampleValueNoMappingDignostic(diagnostics, example, relativePath); + return diagnostics.wrap(undefined); + } + } + + let additionalPropertiesType: SdkType | undefined; + const additionalProperties: Record = new Map(); + const additionalPropertiesExample: Record = {}; + + const properties: Map = new Map(); + const propertiesExample: Record = {}; + + // get all properties type and additional properties type if exist + const modelQueue = [type]; + while (modelQueue.length > 0) { + const model = modelQueue.pop()!; + for (const property of model.properties) { + // for query/path/cookie/header parameters, they should have been handled in parameters. + if ( + property.kind === "property" && + property.serializationOptions.json?.name && + !properties.has(property.serializationOptions.json.name) + ) { + properties.set(property.serializationOptions.json.name, property); + } + } + if (model.additionalProperties && additionalPropertiesType === undefined) { + additionalPropertiesType = model.additionalProperties; + } + if (model.baseModel) { + modelQueue.push(model.baseModel); + } + } + + for (const name of Object.keys(example)) { + const property = properties.get(name); + if (property) { + const result = diagnostics.pipe( + getSdkTypeExample(property.type, example[name], relativePath), + ); + if (result) { + propertiesExample[name] = result; + } + } else { + additionalProperties[name] = example[name]; + } + } + + // handle additional properties + if (Object.keys(additionalProperties).length > 0) { + if (additionalPropertiesType) { + for (const [name, value] of Object.entries(additionalProperties)) { + const result = diagnostics.pipe( + getSdkTypeExample(additionalPropertiesType, value, relativePath), + ); + if (result) { + additionalPropertiesExample[name] = result; + } + } + } else { + addExampleValueNoMappingDignostic(diagnostics, additionalProperties, relativePath); + } + } + + return diagnostics.wrap({ + kind: "model", + type, + value: propertiesExample, + additionalPropertiesValue: + Object.keys(additionalPropertiesExample).length > 0 + ? additionalPropertiesExample + : undefined, + }); + } else { + addExampleValueNoMappingDignostic(diagnostics, example, relativePath); + return diagnostics.wrap(undefined); + } +} + +function addExampleValueNoMappingDignostic( + diagnostics: DiagnosticCollector, + value: any, + relativePath: string, +) { + diagnostics.add( + createDiagnostic({ + code: "example-value-no-mapping", + target: NoTarget, + format: { + value: JSON.stringify(value), + relativePath, + }, + }), + ); +} diff --git a/packages/http-client-generator-core/src/functions.ts b/packages/http-client-generator-core/src/functions.ts new file mode 100644 index 00000000000..1c7424f36f7 --- /dev/null +++ b/packages/http-client-generator-core/src/functions.ts @@ -0,0 +1,236 @@ +import { FunctionContext, ModelProperty, Operation, Type } from "@typespec/compiler"; +import { $ } from "@typespec/compiler/typekit"; +import { reportDiagnostic } from "./lib.js"; + +// Helper function to clone an operation with new parameters and/or return type +function cloneOperation( + tk: ReturnType, + operation: Operation, + options: { + parameters?: ModelProperty[]; + returnType?: Type; + }, +): Operation { + const newOp = tk.operation.create({ + name: operation.name, + parameters: options.parameters ?? [...operation.parameters.properties.values()], + returnType: options.returnType ?? operation.returnType, + }); + + // Copy decorators from the original operation + if (operation.decorators) { + newOp.decorators = [...operation.decorators]; + } + + // Set the source operation for tracing + newOp.sourceOperation = operation.sourceOperation ?? operation; + + return newOp; +} + +// Helper function to clone a model property +function cloneModelProperty(tk: ReturnType, prop: ModelProperty): ModelProperty { + const clonedProp = tk.modelProperty.create({ + name: prop.name, + type: prop.type, + optional: prop.optional, + defaultValue: prop.defaultValue, + }); + // Copy decorators from the original property + if (prop.decorators) { + clonedProp.decorators = [...prop.decorators]; + } + return clonedProp; +} + +/** + * Replace a parameter in an operation with a new parameter definition. + * + * @param context The function context provided by TypeSpec + * @param operation The operation to transform + * @param selector The parameter to replace - either a string name or a ModelProperty reference + * @param replacement The replacement parameter + * @returns A new operation with the parameter replaced + */ +export function replaceParameter( + context: FunctionContext, + operation: Operation, + selector: string | ModelProperty, + replacement: ModelProperty, +): Operation { + const program = context.program; + const tk = $(program); + + // Find the parameter to replace + const selectorName = typeof selector === "string" ? selector : selector.name; + const existingParam = operation.parameters.properties.get(selectorName); + + if (!existingParam) { + reportDiagnostic(program, { + code: "replace-parameter-not-found", + format: { paramName: selectorName, operationName: operation.name }, + target: context.functionCallTarget, + }); + // Return the original operation unchanged + return operation; + } + + // Build the new parameters by cloning properties + const newProperties: ModelProperty[] = []; + + for (const [name, prop] of operation.parameters.properties) { + if (name === selectorName) { + newProperties.push(cloneModelProperty(tk, replacement)); + } else { + newProperties.push(cloneModelProperty(tk, prop)); + } + } + + return cloneOperation(tk, operation, { parameters: newProperties }); +} + +/** + * Remove a parameter from an operation. + * + * @param context The function context provided by TypeSpec + * @param operation The operation to transform + * @param selector The parameter to remove - either a string name or a ModelProperty reference + * @returns A new operation with the parameter removed + */ +export function removeParameter( + context: FunctionContext, + operation: Operation, + selector: string | ModelProperty, +): Operation { + const program = context.program; + const tk = $(program); + + // Find the parameter to remove + const selectorName = typeof selector === "string" ? selector : selector.name; + const existingParam = operation.parameters.properties.get(selectorName); + + if (!existingParam) { + reportDiagnostic(program, { + code: "remove-parameter-not-found", + format: { paramName: selectorName, operationName: operation.name }, + target: context.functionCallTarget, + }); + return operation; + } + + // Build the new parameters, excluding the one to remove + const newProperties: ModelProperty[] = []; + + for (const [name, prop] of operation.parameters.properties) { + if (name !== selectorName) { + newProperties.push(cloneModelProperty(tk, prop)); + } + } + + return cloneOperation(tk, operation, { parameters: newProperties }); +} + +/** + * Add a new parameter to an operation. + * + * @param context The function context provided by TypeSpec + * @param operation The operation to transform + * @param parameter The parameter to add to the operation + * @returns A new operation with the parameter added + */ +export function addParameter( + context: FunctionContext, + operation: Operation, + parameter: ModelProperty, +): Operation { + const program = context.program; + const tk = $(program); + + // Check if a parameter with the same name already exists + if (operation.parameters.properties.has(parameter.name)) { + reportDiagnostic(program, { + code: "add-parameter-duplicate", + format: { paramName: parameter.name, operationName: operation.name }, + target: context.functionCallTarget, + }); + return operation; + } + + // Clone all existing parameters and add the new one + const newProperties: ModelProperty[] = []; + for (const prop of operation.parameters.properties.values()) { + newProperties.push(cloneModelProperty(tk, prop)); + } + newProperties.push(cloneModelProperty(tk, parameter)); + + return cloneOperation(tk, operation, { parameters: newProperties }); +} + +/** + * Reorder parameters of an operation according to the specified order. + * + * @param context The function context provided by TypeSpec + * @param operation The operation to transform + * @param order An array of parameter names specifying the desired order + * @returns A new operation with parameters reordered + */ +export function reorderParameters( + context: FunctionContext, + operation: Operation, + order: readonly string[], +): Operation { + const program = context.program; + const tk = $(program); + + const paramMap = new Map(); + for (const prop of operation.parameters.properties.values()) { + paramMap.set(prop.name, prop); + } + + // Build a Set from order to detect duplicates and enable O(1) lookups + const orderSet = new Set(); + for (const paramName of order) { + if (orderSet.has(paramName)) { + reportDiagnostic(program, { + code: "reorder-parameter-duplicate", + format: { paramName, operationName: operation.name }, + target: context.functionCallTarget, + }); + return operation; + } + orderSet.add(paramName); + } + + // Validate that all parameters in the order list exist in the operation + for (const paramName of orderSet) { + if (!paramMap.has(paramName)) { + reportDiagnostic(program, { + code: "reorder-parameter-not-found", + format: { paramName, operationName: operation.name }, + target: context.functionCallTarget, + }); + return operation; + } + } + + // Validate that all parameters in the operation are in the order list + for (const paramName of paramMap.keys()) { + if (!orderSet.has(paramName)) { + reportDiagnostic(program, { + code: "reorder-parameter-missing", + format: { paramName, operationName: operation.name }, + target: context.functionCallTarget, + }); + return operation; + } + } + + // Build parameters in the specified order + const newProperties: ModelProperty[] = []; + for (const paramName of order) { + const prop = paramMap.get(paramName)!; + newProperties.push(cloneModelProperty(tk, prop)); + } + + return cloneOperation(tk, operation, { parameters: newProperties }); +} diff --git a/packages/http-client-generator-core/src/http.ts b/packages/http-client-generator-core/src/http.ts new file mode 100644 index 00000000000..f4e8e6530df --- /dev/null +++ b/packages/http-client-generator-core/src/http.ts @@ -0,0 +1,1038 @@ +import { + Diagnostic, + ModelProperty, + Operation, + Type, + Union, + compilerAssert, + createDiagnosticCollector, + getEncode, + getSummary, + isErrorModel, +} from "@typespec/compiler"; +import { $ } from "@typespec/compiler/typekit"; +import { + HttpOperation, + HttpOperationHeaderParameter, + HttpOperationParameter, + HttpOperationPathParameter, + HttpOperationQueryParameter, + Visibility, + getCookieParamOptions, + getHeaderFieldName, + getHeaderFieldOptions, + getPathParamName, + getQueryParamName, + getQueryParamOptions, + isBody, + isCookieParam, + isHeader, + isPathParam, + isQueryParam, +} from "@typespec/http"; +import { StreamMetadata, getStreamMetadata } from "@typespec/http/experimental"; +import { camelCase } from "change-case"; +import { getResponseAsBool, isInScope, shouldOmitSlashFromEmptyRoute } from "./decorators.js"; +import { + CollectionFormat, + SdkBodyParameter, + SdkClientType, + SdkCookieParameter, + SdkHeaderParameter, + SdkHttpErrorResponse, + SdkHttpOperation, + SdkHttpParameter, + SdkHttpResponse, + SdkMethodParameter, + SdkModelPropertyType, + SdkModelType, + SdkPathParameter, + SdkQueryParameter, + SdkServiceResponseHeader, + SdkStreamMetadata, + SdkType, + SerializationOptions, + TCGCContext, +} from "./interfaces.js"; +import { + compareModelProperties, + getActualClientType, + getAvailableApiVersions, + getClientDoc, + getCorrespondingClientParam, + getHttpBodyType, + getHttpOperationResponseHeaders, + getStreamAsBytes, + getTypeDecorators, + isAcceptHeader, + isContentTypeHeader, + isHttpBodySpread, + isNeverOrVoidType, + isSubscriptionId, +} from "./internal-utils.js"; +import { createDiagnostic } from "./lib.js"; +import { isMediaTypeJson, isMediaTypeTextPlain, isMediaTypeXml } from "./media-types.js"; +import { + getCrossLanguageDefinitionId, + getEffectivePayloadType, + getWireName, + isApiVersion, +} from "./public-utils.js"; +import { + addEncodeInfo, + getClientType, + getClientTypeWithDiagnostics, + getSdkConstant, + getSdkModelPropertyTypeBase, + getTypeSpecBuiltInType, + isReadOnly, +} from "./types.js"; + +/** + * Build serialization options from content types. + * This provides a consistent way for emitters to determine the serialization format + * for body parameters and HTTP responses, regardless of whether the type is a model or basic type. + * @param contentTypes - The content types to build serialization options from. + * @param name - The serialized name of the body parameter (for request bodies). + */ +function buildSerializationOptionsFromContentTypes( + contentTypes: string[], + name?: string, +): SerializationOptions { + const options: SerializationOptions = {}; + if (contentTypes.some(isMediaTypeJson)) { + options.json = { name: name ?? "" }; + } + if (contentTypes.some(isMediaTypeXml)) { + options.xml = { name: name ?? "" }; + } + return options; +} + +function buildSdkStreamMetadata( + context: TCGCContext, + tspStreamMetadata: StreamMetadata, + operation: Operation, +): [SdkStreamMetadata, readonly Diagnostic[]] { + const diagnostics = createDiagnosticCollector(); + const bodyType = diagnostics.pipe( + getClientTypeWithDiagnostics(context, tspStreamMetadata.bodyType, operation), + ); + const originalType = diagnostics.pipe( + getClientTypeWithDiagnostics(context, tspStreamMetadata.originalType, operation), + ); + const streamType = diagnostics.pipe( + getClientTypeWithDiagnostics(context, tspStreamMetadata.streamType, operation), + ); + return diagnostics.wrap({ + bodyType, + originalType, + streamType, + contentTypes: [...tspStreamMetadata.contentTypes], + }); +} + +export function getSdkHttpOperation( + context: TCGCContext, + httpOperation: HttpOperation, + methodParameters: SdkMethodParameter[], + client: SdkClientType, +): [SdkHttpOperation, readonly Diagnostic[]] { + const diagnostics = createDiagnosticCollector(); + const { responses, exceptions } = diagnostics.pipe( + getSdkHttpResponseAndExceptions(context, httpOperation, client), + ); + if (getResponseAsBool(context, httpOperation.operation)) { + // HEAD operations never have a response body, so we clear response.type here. + // The boolean return type is a client-side concept handled at the method response level. + for (const response of responses) { + response.type = undefined; + } + // Promote 404 from exception to valid response. + const fourOFourResponse = exceptions.find((e) => e.statusCodes === 404); + if (fourOFourResponse) { + // move from exception to valid response with status code 404 + responses.push({ + ...fourOFourResponse, + type: undefined, + statusCodes: 404, + }); + exceptions.splice(exceptions.indexOf(fourOFourResponse), 1); + } else { + // add 404 response to the list of valid responses + responses.push({ + kind: "http", + statusCodes: 404, + apiVersions: getAvailableApiVersions( + context, + httpOperation.operation, + getActualClientType(client.__raw), + ), + headers: [], + __raw: (responses[0] || exceptions[0]).__raw, + serializationOptions: {}, + }); + } + } + const successResponsesWithBodies = responses.filter((r) => r.type); + const parameters = diagnostics.pipe( + getSdkHttpParameters(context, httpOperation, methodParameters, successResponsesWithBodies[0]), + ); + filterOutUselessPathParameters(context, httpOperation, methodParameters); + filterOutReadOnlyParameters(methodParameters); + + // Check if empty route should be treated as empty string + let path = httpOperation.path; + if (path === "/" && shouldOmitSlashFromEmptyRoute(context, httpOperation.operation)) { + path = ""; + } + + return diagnostics.wrap({ + __raw: httpOperation, + kind: "http", + path, + uriTemplate: httpOperation.uriTemplate, + verb: httpOperation.verb, + ...parameters, + responses, + exceptions, + }); +} + +export function isSdkHttpParameter(context: TCGCContext, type: ModelProperty): boolean { + const program = context.program; + return ( + isPathParam(program, type) || + isQueryParam(program, type) || + isHeader(program, type) || + isBody(program, type) || + isCookieParam(program, type) + ); +} + +interface SdkHttpParameters { + parameters: (SdkPathParameter | SdkQueryParameter | SdkHeaderParameter | SdkCookieParameter)[]; + bodyParam?: SdkBodyParameter; +} + +function getSdkHttpParameters( + context: TCGCContext, + httpOperation: HttpOperation, + methodParameters: SdkMethodParameter[], + responseBody?: SdkHttpResponse | SdkHttpErrorResponse, +): [SdkHttpParameters, readonly Diagnostic[]] { + const diagnostics = createDiagnosticCollector(); + const retval: SdkHttpParameters = { + parameters: [], + bodyParam: undefined, + }; + + const methodParametersMap = new Map(); + methodParameters.map((mp) => { + if (mp.__raw) { + methodParametersMap.set(mp.__raw, mp); + } + }); + + // Filter parameters by type and scope, warning if required parameters are scoped out + const filteredParams: HttpOperationParameter[] = []; + for (const x of httpOperation.parameters.parameters) { + if (isNeverOrVoidType(x.param.type)) { + continue; + } + if (!isInScope(context, x.param)) { + // Warn if a required parameter is being scoped out + if (!x.param.optional) { + diagnostics.add( + createDiagnostic({ + code: "required-parameter-scoped-out", + target: x.param, + format: { + paramName: x.param.name, + scope: context.emitterName, + }, + }), + ); + } + continue; + } + filteredParams.push(x); + } + retval.parameters = filteredParams.map((x) => + diagnostics.pipe(getSdkHttpParameter(context, x.param, httpOperation.operation, x, x.type)), + ) as (SdkPathParameter | SdkQueryParameter | SdkHeaderParameter | SdkCookieParameter)[]; + const headerParams = retval.parameters.filter( + (x): x is SdkHeaderParameter => x.kind === "header", + ); + // add operation info onto body param + const tspBody = httpOperation.parameters.body; + if (tspBody) { + if (tspBody.property && !isNeverOrVoidType(tspBody.property.type)) { + const bodyParam = diagnostics.pipe( + getSdkHttpParameter(context, tspBody.property, httpOperation.operation, undefined, "body"), + ); + if (bodyParam.kind !== "body") { + diagnostics.add( + createDiagnostic({ + code: "unexpected-http-param-type", + target: tspBody.property, + format: { + paramName: tspBody.property.name, + expectedType: "body", + actualType: bodyParam.kind, + }, + }), + ); + return diagnostics.wrap(retval); + } + retval.bodyParam = bodyParam; + } else if (!isNeverOrVoidType(tspBody.type)) { + const type = diagnostics.pipe( + getClientTypeWithDiagnostics(context, getHttpBodyType(tspBody), httpOperation.operation), + ); + const name = camelCase((type as { name: string }).name ?? "body"); + retval.bodyParam = { + kind: "body", + name, + isGeneratedName: true, + serializedName: "", + doc: getClientDoc(context, tspBody.type), + summary: getSummary(context.program, tspBody.type), + onClient: false, + contentTypes: [], + defaultContentType: "application/json", // actual content type info is added later + isApiVersionParam: false, + apiVersions: getAvailableApiVersions(context, tspBody.type, httpOperation.operation), + type, + optional: isHttpBodySpread(tspBody) ? false : (tspBody.property?.optional ?? false), // optional is always false for spread body + correspondingMethodParams: [], + methodParameterSegments: [], + crossLanguageDefinitionId: `${getCrossLanguageDefinitionId(context, httpOperation.operation)}.body`, + decorators: diagnostics.pipe(getTypeDecorators(context, tspBody.type)), + access: "public", + flatten: false, + serializationOptions: {}, + }; + } + if (retval.bodyParam) { + retval.bodyParam.methodParameterSegments = diagnostics.pipe( + getMethodParameterSegments( + context, + httpOperation.operation, + methodParameters, + methodParametersMap, + retval.bodyParam, + ), + ); + // Derive correspondingMethodParams from methodParameterSegments (last element of each path) + // eslint-disable-next-line @typescript-eslint/no-deprecated + retval.bodyParam.correspondingMethodParams = retval.bodyParam.methodParameterSegments.map( + (segment) => segment[segment.length - 1], + ); + + addContentTypeInfoToBodyParam(context, httpOperation, retval.bodyParam); + + // populate serialization options based on content types + retval.bodyParam.serializationOptions = buildSerializationOptionsFromContentTypes( + retval.bodyParam.contentTypes, + retval.bodyParam.serializedName, + ); + + // map stream request body type to bytes, but preserve stream metadata + const requestStreamMeta = getStreamMetadata(context.program, httpOperation.parameters); + if (requestStreamMeta) { + retval.bodyParam.type = diagnostics.pipe( + getStreamAsBytes(context, retval.bodyParam.type.__raw!), + ); + retval.bodyParam.streamMetadata = diagnostics.pipe( + buildSdkStreamMetadata(context, requestStreamMeta, httpOperation.operation), + ); + // eslint-disable-next-line @typescript-eslint/no-deprecated + retval.bodyParam.correspondingMethodParams.map((p) => (p.type = retval.bodyParam!.type)); + } + } + } + if (retval.bodyParam && !headerParams.some((h) => isContentTypeHeader(h))) { + // if we have a body param and no content type header, we add one + const contentTypeBase = { + ...createContentTypeOrAcceptHeader(context, httpOperation, retval.bodyParam), + doc: `Body parameter's content type. Known values are ${retval.bodyParam.contentTypes}`, + }; + let methodParameter: SdkMethodParameter | undefined = methodParameters.find( + (m) => m.name === "contentType", + ); + if (!methodParameter) { + methodParameter = { + ...contentTypeBase, + kind: "method", + }; + methodParameters.push(methodParameter); + } + retval.parameters.push({ + ...contentTypeBase, + kind: "header", + serializedName: "Content-Type", + correspondingMethodParams: [methodParameter], + methodParameterSegments: [[methodParameter]], + }); + } + if (responseBody && !headerParams.some((h) => isAcceptHeader(h))) { + // If our operation returns a body, we add an accept header if none exist + const acceptBase = { + ...createContentTypeOrAcceptHeader(context, httpOperation, responseBody), + }; + let methodParameter: SdkMethodParameter | undefined = methodParameters.find( + (m) => m.name === "accept", + ); + if (!methodParameter) { + methodParameter = { + ...acceptBase, + kind: "method", + }; + methodParameters.push(methodParameter); + } + retval.parameters.push({ + ...acceptBase, + kind: "header", + serializedName: "Accept", + correspondingMethodParams: [methodParameter], + methodParameterSegments: [[methodParameter]], + }); + } + for (const param of retval.parameters) { + if (param.methodParameterSegments.length > 0) continue; + param.methodParameterSegments = diagnostics.pipe( + getMethodParameterSegments( + context, + httpOperation.operation, + methodParameters, + methodParametersMap, + param, + ), + ); + // Derive correspondingMethodParams from methodParameterSegments (last element of each path) + // eslint-disable-next-line @typescript-eslint/no-deprecated + param.correspondingMethodParams = param.methodParameterSegments.map( + (segment) => segment[segment.length - 1], + ); + } + return diagnostics.wrap(retval); +} + +function createContentTypeOrAcceptHeader( + context: TCGCContext, + httpOperation: HttpOperation, + bodyObject: SdkBodyParameter | SdkHttpResponse | SdkHttpErrorResponse, +): Omit { + const name = bodyObject.kind === "body" ? "contentType" : "accept"; + let type: SdkType = getTypeSpecBuiltInType(context, "string"); + // Honor the content types from the HTTP library result. + // For a single content type, create a constant. + // For multiple content types on a request body (`contentType`), create an enum since the + // caller actually picks one value to send. + // For multiple content types on a response (`accept`), create a single constant whose value + // is a comma-joined list of all response content types, with structured content types + // (JSON/XML/text-plain) listed first. This avoids treating the synthetic `accept` parameter + // as a content-negotiation parameter. Services that genuinely need content negotiation + // should use `@sharedRoute` to split the operation per content type. + // For File type bodies, the content type is constrained by the File type itself; + // treat it the same as a user-defined content type/accept parameter. + if (bodyObject.contentTypes && bodyObject.contentTypes.length > 0) { + const tk = $(context.program); + context.__namingContextPath.push({ + name: httpOperation.operation.name, + type: httpOperation.operation, + }); + context.__namingContextPath.push({ + name: name === "accept" ? "Accept" : "ContentType", + type: undefined, + }); + try { + if (bodyObject.contentTypes.length === 1) { + // Single content type → constant. + const literal = tk.literal.createString(bodyObject.contentTypes[0]); + type = getSdkConstant(context, literal, httpOperation.operation); + } else if (name === "accept") { + // Multi accept → single constant whose value is a comma-joined string. Stable + // partition: structured content types first, others after, preserving order. + const isStructured = (ct: string) => + isMediaTypeJson(ct) || isMediaTypeXml(ct) || isMediaTypeTextPlain(ct); + const structured = bodyObject.contentTypes.filter(isStructured); + const others = bodyObject.contentTypes.filter((ct) => !isStructured(ct)); + const combined = [...structured, ...others].join(", "); + const literal = tk.literal.createString(combined); + type = getSdkConstant(context, literal, httpOperation.operation); + } else { + // Multi content types on request → enum. + const union = tk.union.create( + bodyObject.contentTypes.map((ct) => tk.literal.createString(ct)), + ); + type = getClientType(context, union, httpOperation.operation); + } + } finally { + context.__namingContextPath.pop(); + context.__namingContextPath.pop(); + } + } + const optional = bodyObject.kind === "body" ? bodyObject.optional : false; + // No need for clientDefaultValue because it's a constant, it only has one value + return { + type, + name, + isGeneratedName: true, + apiVersions: bodyObject.apiVersions, + isApiVersionParam: false, + onClient: false, + optional: optional, + crossLanguageDefinitionId: `${getCrossLanguageDefinitionId(context, httpOperation.operation)}.${name}`, + decorators: [], + access: "public", + flatten: false, + }; +} + +function addContentTypeInfoToBodyParam( + context: TCGCContext, + httpOperation: HttpOperation, + bodyParam: SdkBodyParameter, +): readonly Diagnostic[] { + const diagnostics = createDiagnosticCollector(); + const tspBody = httpOperation.parameters.body; + if (!tspBody) return diagnostics.diagnostics; + const contentTypes = tspBody.contentTypes; + compilerAssert(contentTypes.length > 0, "contentTypes should not be empty"); // this should be http lib bug + const defaultContentType = contentTypes.includes("application/json") + ? "application/json" + : contentTypes[0]; + bodyParam.contentTypes = contentTypes; + bodyParam.defaultContentType = defaultContentType; + diagnostics.pipe(addEncodeInfo(context, bodyParam.__raw!, bodyParam.type, defaultContentType)); + // set the correct encode for body parameter of method according to the content-type + // eslint-disable-next-line @typescript-eslint/no-deprecated + if (bodyParam.correspondingMethodParams.length === 1) { + // eslint-disable-next-line @typescript-eslint/no-deprecated + const methodBodyParam = bodyParam.correspondingMethodParams[0]; + diagnostics.pipe( + addEncodeInfo( + context, + methodBodyParam.__raw!, + methodBodyParam.type, + bodyParam.defaultContentType, + ), + ); + } + return diagnostics.diagnostics; +} + +/** + * Generate TCGC Http parameter type, `httpParam` or `location` should be provided at least one + * @param context + * @param param TypeSpec param for the http parameter + * @param operation + * @param httpParam TypeSpec Http parameter type + * @param location Location of the http parameter + * @returns + */ +export function getSdkHttpParameter( + context: TCGCContext, + param: ModelProperty, + operation?: Operation, + httpParam?: HttpOperationParameter, + location?: "path" | "query" | "header" | "body" | "cookie", +): [SdkHttpParameter, readonly Diagnostic[]] { + const diagnostics = createDiagnosticCollector(); + const base = diagnostics.pipe(getSdkModelPropertyTypeBase(context, param, operation)); + const program = context.program; + if (isPathParam(context.program, param) || location === "path") { + return diagnostics.wrap({ + ...base, + kind: "path", + explode: (httpParam as HttpOperationPathParameter)?.explode ?? false, + style: (httpParam as HttpOperationPathParameter)?.style ?? "simple", + // url type need allow reserved + allowReserved: + (httpParam as HttpOperationPathParameter)?.allowReserved ?? + $(program).type.isAssignableTo(param.type, $(program).builtin.url, param.type), + serializedName: getPathParamName(program, param) ?? base.name, + correspondingMethodParams: [], + methodParameterSegments: [], + optional: param.optional, + }); + } + if (isCookieParam(context.program, param) || location === "cookie") { + return diagnostics.wrap({ + ...base, + kind: "cookie", + serializedName: getCookieParamOptions(program, param)?.name ?? base.name, + correspondingMethodParams: [], + methodParameterSegments: [], + optional: param.optional, + }); + } + if (isBody(context.program, param) || location === "body") { + const serializedName = param.name === "" ? "body" : getWireName(context, param); + return diagnostics.wrap({ + ...base, + kind: "body", + serializedName, + contentTypes: ["application/json"], + defaultContentType: "application/json", + optional: param.optional, + correspondingMethodParams: [], + methodParameterSegments: [], + serializationOptions: buildSerializationOptionsFromContentTypes( + ["application/json"], + serializedName, + ), + }); + } + const headerQueryBase = { + ...base, + optional: param.optional, + collectionFormat: diagnostics.pipe(getCollectionFormat(context, param)), + correspondingMethodParams: [], + methodParameterSegments: [], + }; + if (isQueryParam(context.program, param) || location === "query") { + return diagnostics.wrap({ + ...headerQueryBase, + kind: "query", + serializedName: getQueryParamName(program, param) ?? base.name, + explode: (httpParam as HttpOperationQueryParameter)?.explode, + }); + } + if (!(isHeader(context.program, param) || location === "header")) { + diagnostics.add( + createDiagnostic({ + code: "unexpected-http-param-type", + target: param, + format: { + paramName: param.name, + expectedType: "path, query, header, or body", + actualType: param.kind, + }, + }), + ); + } + return diagnostics.wrap({ + ...headerQueryBase, + kind: "header", + serializedName: + getHeaderFieldName(program, param) ?? + (httpParam as HttpOperationHeaderParameter)?.name ?? + base.name, + }); +} + +function getSdkHttpResponseAndExceptions( + context: TCGCContext, + httpOperation: HttpOperation, + client: SdkClientType, +): [ + { + responses: SdkHttpResponse[]; + exceptions: SdkHttpErrorResponse[]; + }, + readonly Diagnostic[], +] { + const tk = $(context.program); + const diagnostics = createDiagnosticCollector(); + const responses: SdkHttpResponse[] = []; + const exceptions: SdkHttpErrorResponse[] = []; + for (const response of httpOperation.responses) { + const headers: SdkServiceResponseHeader[] = []; + const bodyTypes: Type[] = []; + let type: SdkType | undefined; + let contentTypes: string[] = []; + let streamMetadata: SdkStreamMetadata | undefined; + let lastBodyProperty: ModelProperty | undefined; + let lastDefaultContentType: string | undefined; + + for (const innerResponse of response.responses) { + const defaultContentType = innerResponse.body?.contentTypes.includes("application/json") + ? "application/json" + : innerResponse.body?.contentTypes[0]; + for (const header of getHttpOperationResponseHeaders(innerResponse)) { + if (isNeverOrVoidType(header.type)) continue; + headers.push({ + ...diagnostics.pipe( + getSdkModelPropertyTypeBase(context, header, httpOperation.operation), + ), + __raw: header, + kind: "responseheader", + serializedName: + getHeaderFieldName(context.program, header) ?? + (header === innerResponse.body?.contentTypeProperty ? "Content-Type" : header.name), + }); + context.__responseHeaderCache.set(header, headers[headers.length - 1]); + } + if (innerResponse.body && !isNeverOrVoidType(innerResponse.body.type)) { + if (bodyTypes.length > 0 && !bodyTypes.includes(innerResponse.body.type)) { + diagnostics.add( + createDiagnostic({ + code: "multiple-response-types", + target: innerResponse.body.type, + format: { + operation: httpOperation.operation.name, + }, + }), + ); + } + if (!bodyTypes.includes(innerResponse.body.type)) { + bodyTypes.push(innerResponse.body.type); + } + contentTypes = contentTypes.concat(innerResponse.body.contentTypes); + lastBodyProperty = innerResponse.body.property; + lastDefaultContentType = defaultContentType; + const responseStreamMeta = getStreamMetadata(context.program, innerResponse); + if (responseStreamMeta) { + // map stream response body type to bytes, but preserve stream metadata + type = diagnostics.pipe(getStreamAsBytes(context, innerResponse.body.type)); + streamMetadata = diagnostics.pipe( + buildSdkStreamMetadata(context, responseStreamMeta, httpOperation.operation), + ); + } + } + } + + // Create SDK type from collected body types after iteration + let body: Type | undefined; + if (!type && bodyTypes.length > 0) { + if (bodyTypes.length === 1) { + body = bodyTypes[0]; + } else { + body = tk.union.create(bodyTypes); + } + body = body.kind === "Model" ? getEffectivePayloadType(context, body, Visibility.Read) : body; + if (bodyTypes.length > 1) { + // Push naming context for the synthetic union so it gets a proper generated name + context.__namingContextPath.push({ + name: httpOperation.operation.name, + type: httpOperation.operation, + }); + context.__namingContextPath.push({ + name: "Response", + type: body as Union, + }); + } + type = diagnostics.pipe(getClientTypeWithDiagnostics(context, body, httpOperation.operation)); + if (bodyTypes.length > 1) { + context.__namingContextPath.pop(); + context.__namingContextPath.pop(); + } + if (lastBodyProperty) { + addEncodeInfo(context, lastBodyProperty, type, lastDefaultContentType); + } + } + const sdkResponse = { + __raw: response, + type, + headers, + contentTypes: contentTypes.length > 0 ? contentTypes : undefined, + defaultContentType: contentTypes.includes("application/json") + ? "application/json" + : contentTypes[0], + apiVersions: getAvailableApiVersions( + context, + httpOperation.operation, + getActualClientType(client.__raw), + ), + description: response.description, + streamMetadata, + serializationOptions: buildSerializationOptionsFromContentTypes(contentTypes), + }; + + if ( + response.statusCodes === "*" || + isErrorModel(context.program, response.type) || + (body && isErrorModel(context.program, body)) + ) { + exceptions.push({ + ...sdkResponse, + kind: "http", + statusCodes: response.statusCodes, + }); + } else { + responses.push({ + ...sdkResponse, + kind: "http", + statusCodes: response.statusCodes, + }); + } + } + return diagnostics.wrap({ responses, exceptions }); +} + +/** + * Get method parameter segments for a service parameter. + * This builds the complete path from method parameters to the HTTP parameter. + * For body parameters with spread, multiple paths may be returned. + * @param context + * @param operation + * @param methodParameters + * @param serviceParam + * @returns Array of path segments, where each inner array represents a complete path + */ +export function getMethodParameterSegments( + context: TCGCContext, + operation: Operation, + methodParameters: SdkMethodParameter[], + methodParametersMap: Map, + serviceParam: SdkHttpParameter, +): [(SdkMethodParameter | SdkModelPropertyType)[][], readonly Diagnostic[]] { + const diagnostics = createDiagnosticCollector(); + + if (serviceParam.onClient) { + // 1. To see if the service parameter is a client parameter. + if (serviceParam.__raw) { + const correspondingClientParam = getCorrespondingClientParam( + context, + serviceParam.__raw, + operation, + ); + if (correspondingClientParam) return diagnostics.wrap([[correspondingClientParam]]); + } + + const clientParams = context.__clientParametersCache.get( + context.getClientForOperation(operation), + ); + + // 2. To see if the service parameter is api version parameter that has been elevated to client. + if (clientParams && serviceParam.isApiVersionParam && serviceParam.onClient) { + const existingApiVersion = clientParams.find((x) => isApiVersion(context, x.__raw!)); + if (existingApiVersion) return diagnostics.wrap([[existingApiVersion]]); + } + + // 3. To see if the service parameter is subscription parameter that has been elevated to client (only for arm service). + if (clientParams && isSubscriptionId(context, serviceParam)) { + const subId = clientParams.find((x) => isSubscriptionId(context, x)); + if (subId) return diagnostics.wrap([[subId]]); + } + } + + // Since service param come from the original operation when using `@override`, so the `onClient` info might not be correct. + // We need to reset the `onClient` info for the service param and find corresponding method param again. + serviceParam.onClient = false; + + // 4. To see if the service parameter is a method parameter or a property of a method parameter. + const directMappingPath = findMappingWithPath( + context, + methodParameters, + methodParametersMap, + serviceParam, + ); + if (directMappingPath) { + return diagnostics.wrap([directMappingPath]); + } + + // 5. To see if all the property of the service parameter could be mapped to a method parameter or a property of a method parameter. + // This is the spread body case where multiple paths may exist. + if (serviceParam.kind === "body" && serviceParam.type.kind === "model") { + const paths: (SdkMethodParameter | SdkModelPropertyType)[][] = []; + let optionalSkip = 0; + for (const serviceParamProp of serviceParam.type.properties) { + const propertyMappingPath = findMappingWithPath( + context, + methodParameters, + methodParametersMap, + serviceParamProp, + ); + if (propertyMappingPath) { + paths.push(propertyMappingPath); + } else if (serviceParamProp.optional) { + // If the property is optional, we can skip the mapping. + optionalSkip++; + } + } + if (paths.length + optionalSkip === serviceParam.type.properties.length) { + return diagnostics.wrap(paths); + } + } + + // If mapping could not be found, and the service param is required, TCGC will report error since we can't generate the client code without this mapping. + if (!serviceParam.optional) { + diagnostics.add( + createDiagnostic({ + code: "no-corresponding-method-param", + target: operation, + format: { + paramName: serviceParam.name, + methodName: operation.name, + }, + }), + ); + } + + return diagnostics.wrap([]); +} + +/** + * Build path segments from method parameters to service parameter. The map could be a service parameter or a property of a service parameter to a method parameter or a property of a method parameter. + * This function finds the complete path from method parameter to the HTTP parameter. + * @param context + * @param methodParameters + * @param serviceParam + * @returns An array of path segments, where each segment is a path from method parameter to the service parameter + */ +function findMappingWithPath( + context: TCGCContext, + methodParameters: SdkMethodParameter[], + methodParametersMap: Map, + serviceParam: SdkHttpParameter | SdkModelPropertyType, +): (SdkMethodParameter | SdkModelPropertyType)[] | undefined { + // Quick check for direct mapping + if (serviceParam.__raw && methodParametersMap.has(serviceParam.__raw)) { + return [methodParametersMap.get(serviceParam.__raw)!]; + } + + // BFS with index-based traversal (O(1) dequeue) and parent pointers (avoid path copying per node) + const queue: (SdkMethodParameter | SdkModelPropertyType)[] = [...methodParameters]; + const parentMap = new Map< + SdkMethodParameter | SdkModelPropertyType, + SdkMethodParameter | SdkModelPropertyType | undefined + >(); + for (const p of methodParameters) { + parentMap.set(p, undefined); + } + const visited: Set = new Set(); + let front = 0; + + while (front < queue.length) { + const methodParam = queue[front++]; + + // HTTP operation parameter/body parameter/property of body parameter could either from an operation parameter directly or from a property of an operation parameter. + if ( + methodParam.__raw && + serviceParam.__raw && + compareModelProperties(context.program, methodParam.__raw, serviceParam.__raw) + ) { + return buildPathFromParentMap(methodParam, parentMap); + } + + // If the service parameter is a body parameter, try to see if we could find a method parameter with same type of the body parameter. + if (serviceParam.kind === "body" && serviceParam.type === methodParam.type) { + return buildPathFromParentMap(methodParam, parentMap); + } + + // BFS to explore nested properties + if (methodParam.type.kind === "model" && !visited.has(methodParam.type)) { + visited.add(methodParam.type); + let current: SdkModelType | undefined = methodParam.type; + while (current) { + for (const prop of current.properties) { + parentMap.set(prop, methodParam); + queue.push(prop); + } + current = current.baseModel; + } + } + } + return undefined; +} + +function buildPathFromParentMap( + node: SdkMethodParameter | SdkModelPropertyType, + parentMap: Map< + SdkMethodParameter | SdkModelPropertyType, + SdkMethodParameter | SdkModelPropertyType | undefined + >, +): (SdkMethodParameter | SdkModelPropertyType)[] { + const path: (SdkMethodParameter | SdkModelPropertyType)[] = []; + let current: SdkMethodParameter | SdkModelPropertyType | undefined = node; + while (current !== undefined) { + path.push(current); + current = parentMap.get(current); + } + return path.reverse(); +} + +function filterOutUselessPathParameters( + context: TCGCContext, + httpOperation: HttpOperation, + methodParameters: SdkMethodParameter[], +) { + // there are some cases that method path parameter is not in operation: + // 1. autoroute with constant parameter + // 2. singleton arm resource name + // 3. visibility mis-match + // so we will remove the method parameter for consistent + for (let i = 0; i < methodParameters.length; i++) { + const param = methodParameters[i]; + if ( + param.__raw && + isPathParam(context.program, param.__raw) && + httpOperation.parameters.parameters.filter( + (p) => + p.type === "path" && + p.name === (getPathParamName(context.program, param.__raw!) ?? param.name), + ).length === 0 + ) { + methodParameters.splice(i, 1); + i--; + } + } +} + +function filterOutReadOnlyParameters(methodParameters: SdkMethodParameter[]) { + // ReadOnly parameters should not be included in method parameters + // since they cannot be set by the user + for (let i = 0; i < methodParameters.length; i++) { + const param = methodParameters[i]; + if (isReadOnly(param)) { + methodParameters.splice(i, 1); + i--; + } + } +} + +function getCollectionFormat( + context: TCGCContext, + type: ModelProperty, +): [CollectionFormat | undefined, readonly Diagnostic[]] { + const diagnostics = createDiagnosticCollector(); + const program = context.program; + if (isHeader(program, type)) { + return getFormatFromExplodeOrEncode( + context, + type, + getHeaderFieldOptions(program, type).explode, + ); + } else if (isQueryParam(program, type)) { + return getFormatFromExplodeOrEncode( + context, + type, + getQueryParamOptions(program, type)?.explode, + ); + } + return diagnostics.wrap(undefined); +} + +function getFormatFromExplodeOrEncode( + context: TCGCContext, + type: ModelProperty, + explode?: boolean, +): [CollectionFormat | undefined, readonly Diagnostic[]] { + const diagnostics = createDiagnosticCollector(); + if ($(context.program).array.is(type.type)) { + if (explode) { + return diagnostics.wrap("multi"); + } + const encode = getEncode(context.program, type); + if (encode) { + if (encode?.encoding === "ArrayEncoding.pipeDelimited") { + return diagnostics.wrap("pipes"); + } + if (encode?.encoding === "ArrayEncoding.spaceDelimited") { + return diagnostics.wrap("ssv"); + } + diagnostics.add( + createDiagnostic({ + code: "invalid-encode-for-collection-format", + target: type, + }), + ); + } + return diagnostics.wrap("csv"); + } + return diagnostics.wrap(undefined); +} diff --git a/packages/http-client-generator-core/src/index.ts b/packages/http-client-generator-core/src/index.ts new file mode 100644 index 00000000000..0f211ea313b --- /dev/null +++ b/packages/http-client-generator-core/src/index.ts @@ -0,0 +1,10 @@ +export * from "./context.js"; +export * from "./decorators.js"; +export * from "./interfaces.js"; +export * from "./lib.js"; +export { $linter } from "./linter.js"; +export * from "./public-utils.js"; +export * from "./types.js"; + +/** @internal */ +export { $decorators, $functions } from "./tsp-index.js"; \ No newline at end of file diff --git a/packages/http-client-generator-core/src/interfaces.ts b/packages/http-client-generator-core/src/interfaces.ts new file mode 100644 index 00000000000..2a0bb0fee87 --- /dev/null +++ b/packages/http-client-generator-core/src/interfaces.ts @@ -0,0 +1,1267 @@ +import { + DateTimeKnownEncoding, + Diagnostic, + DurationKnownEncoding, + EmitContext, + Enum, + Interface, + IntrinsicScalarName, + Model, + ModelProperty, + Namespace, + Operation, + PagingOperation, + Program, + Type, + Union, +} from "@typespec/compiler"; +import { unsafe_Realm } from "@typespec/compiler/experimental"; +import { + HttpAuth, + HttpOperation, + HttpOperationResponse, + HttpStatusCodeRange, + HttpVerb, + Visibility, +} from "@typespec/http"; +import type { ContextNode } from "./internal-utils.js"; + +// Types for TCGC lib + +type SourceKind = "RequestParameter" | "RequestBody" | "ResponseBody"; + +export interface TCGCContext { + program: Program; + diagnostics: readonly Diagnostic[]; + emitterName: string; + + generateProtocolMethods?: boolean; + generateConvenienceMethods?: boolean; + examplesDir?: string; + namespaceFlag?: string; + apiVersion?: string; + license?: { + name: string; + company?: string; + header?: string; + link?: string; + description?: string; + }; + + decoratorsAllowList?: string[]; + previewStringRegex: RegExp; + disableUsageAccessPropagationToBase: boolean; + flattenUnionAsEnum?: boolean; + enableLegacyHierarchyBuilding?: boolean; + + __referencedTypeCache: Map; + __arrayDictionaryCache: Map; + __methodParameterCache: Map; + __modelPropertyCache: Map; + __responseHeaderCache: Map; + __generatedNames: Map; + __httpOperationCache: Map; + __tspTypeToApiVersions: Map; + __explicitClients?: Set; + __rawClientsCache?: Map; + __clientToOperationsCache?: Map; + __operationToClientCache?: Map; + __clientParametersCache: Map; + __clientApiVersionDefaultValueCache: Map; + __httpOperationExamples: Map; + __pagedResultSet: Set; + __namingContextPath: ContextNode[]; // Stack tracking the current traversal position for naming anonymous types. + __orphanTypesCache?: (Model | Enum | Union)[]; // cached result of listOrphanTypes to avoid repeated namespace traversals + __mutatedGlobalNamespace?: Namespace; // the root of all tsp namespaces for this instance. Starting point for traversal, so we don't call mutation multiple times + __mutatedRealm?: unsafe_Realm; // the realm that contains all mutated types for this instance + __packageVersions?: Map; // the package versions (for each service) from the service versioning config and api version setting in tspconfig. + __packageVersionEnum?: Map; // the enum type that contains all the package versions (for each service). + __externalPackageToVersions?: Map; + + getMutatedGlobalNamespace(): Namespace; + getApiVersionsForType(type: Type): string[]; + setApiVersionsForType(type: Type, apiVersions: string[]): void; + getPackageVersions(): Map; + getPackageVersionEnum(): Map; + getClients(): SdkClient[]; + getRootClients(): SdkClient[]; + getClient(type: Namespace | Interface): SdkClient | undefined; + getOperationsForClient(client: SdkClient): Operation[]; + getClientForOperation(operation: Operation): SdkClient; +} + +export interface SdkContext< + TOptions extends object = Record, + TServiceOperation extends SdkServiceOperation = SdkHttpOperation, +> extends TCGCContext { + emitContext: EmitContext; + sdkPackage: SdkPackage; +} + +// Types for TCGC customization decorators + +export interface SdkClient { + kind: "SdkClient"; + name: string; + services: Namespace[]; + /** The type associated with this client. If it is created from string client location, or is a merged client, this will be undefined. */ + type?: Namespace | Interface; + /** Sub clients of this client. */ + subClients: SdkClient[]; + /** The path of this client in the client hierarchy. For example, "MyClient.SubClient". */ + clientPath: string; + /** The parent client. Only set for sub clients. */ + parent?: SdkClient; + /** Whether to auto-merge service's things into current client. */ + autoMergeService?: boolean; +} + +export type AccessFlags = "internal" | "public"; + +/** + * This enum represents the different ways a model can be used in a method. + */ +export enum UsageFlags { + None = 0, + Input = 1 << 1, + Output = 1 << 2, + ApiVersionEnum = 1 << 3, + /** Input and Json will also be set when JsonMergePatch is set. */ + JsonMergePatch = 1 << 4, + /** Input will also be set when MultipartFormData is set. */ + MultipartFormData = 1 << 5, + /** Used in spread. */ + Spread = 1 << 6, + /** Set when type is used in conjunction with an application/json content type. */ + Json = 1 << 8, + /** Set when type is used in conjunction with an application/xml content type. */ + Xml = 1 << 9, + /** Set when type is used for exception output. */ + Exception = 1 << 10, + /** Set when type is used as LRO initial response. */ + LroInitial = 1 << 11, + /** Set when type is used as LRO polling response. */ + LroPolling = 1 << 12, + /** Set when type is used as LRO final envelop response. */ + LroFinalEnvelope = 1 << 13, + /** Set when type is only referenced by external types. */ + External = 1 << 14, +} + +/** + * Flags used to indicate how a client is initialized. + * + * - `Default` (0): No user-specific initialization setting has been specified. This is the default value for sub clients when no explicit initialization decorator is set. + * - `Individually` (1): The client could be initialized individually. + * - `Parent` (2): The client could be initialized by its parent client. + * - `CustomizeCode` (4): Indicates that the client initialization should be omitted from generated code and handled manually in custom code. + * - `Individually` and `Parent` are bit flags that can be combined using bitwise OR. + */ +export enum InitializedByFlags { + Default = 0, + CustomizeCode = 1 << 2, + Individually = 1 << 0, + Parent = 1 << 1, +} + +/** + * Options used to indicate how to initialize a client. + * `parameters` is a model that used to . + * `initializedBy` is a flag that indicates how the client is initialized. + */ +export interface ClientInitializationOptions { + parameters?: Model; + initializedBy?: InitializedByFlags; +} + +// Types for TCGC specific type graph + +export interface DecoratedType { + /** + * Client types sourced from TypeSpec decorated types will have this generic decoratores list. + * Only decorators in allowed list will be included in this list. + * Language's emitter could set `additionalDecorators` in the option when `createSdkContext` to extend the allowed list. + */ + decorators: DecoratorInfo[]; +} + +export interface DecoratorInfo { + /** + * Fully qualified name of the decorator. For example, `TypeSpec.@encode`, `TypeSpec.Xml.@attribute`. + */ + name: string; + /** + * A dict of the decorator's arguments. For example, `{ encoding: "base64url" }`. + */ + arguments: Record; +} + +/** + * Represents a client in the package. + */ +export interface SdkClientType< + TServiceOperation extends SdkServiceOperation, +> extends DecoratedType { + __raw: SdkClient; + kind: "client"; + /** Name of the client. */ + name: string; + /** Full qualified namespace. */ + namespace: string; + /** Document for the type. */ + doc?: string; + /** Summary for the type. */ + summary?: string; + /** Client initialization way. */ + clientInitialization: SdkClientInitializationType; + /** Methods of the client. */ + methods: SdkMethod[]; + /** API versions supported for current type. */ + apiVersions: string[]; + /** Unique ID for the current type. */ + crossLanguageDefinitionId: string; + /** The parent client of this client. The structure follows the definition hierarchy. */ + parent?: SdkClientType; + /** The sub clients of this client. */ + children?: SdkClientType[]; +} + +interface ExternalType { + external?: ExternalTypeInfo; +} + +export interface ExternalTypeInfo { + kind: "externalTypeInfo"; + identity: string; + package?: string; + minVersion?: string; +} + +interface SdkTypeBase extends DecoratedType, ExternalType { + __raw?: Type; + kind: string; + /** Whether the type is deprecated. */ + deprecation?: string; + /** Document for the type. */ + doc?: string; + /** Summary for the type. */ + summary?: string; + __accessSet?: boolean; +} + +export type SdkType = + | SdkBuiltInType + | SdkDateTimeType + | SdkDurationType + | SdkArrayType + | SdkTupleType + | SdkDictionaryType + | SdkNullableType + | SdkEnumType + | SdkEnumValueType + | SdkConstantType + | SdkUnionType + | SdkModelType + | SdkCredentialType + | SdkEndpointType; + +export interface SdkBuiltInType< + TKind extends SdkBuiltInKinds = SdkBuiltInKinds, +> extends SdkTypeBase { + kind: TKind; + /** How to encode the type on wire. */ + encode?: string; + /** Client name for the type. */ + name: string; + /** Which type this type is derived from. */ + baseType?: SdkBuiltInType; + /** Unique ID for the current type. */ + crossLanguageDefinitionId: string; +} + +type TypeEquality = keyof T extends keyof U + ? keyof U extends keyof T + ? true + : false + : false; + +// these two vars are used to validate whether our SdkBuiltInKinds are exhaustive for all possible values from typespec +// if it is not, a typescript compilation error will be thrown here. +const _: TypeEquality, never> = true; +const __: TypeEquality, never> = true; + +type SupportedBuiltInKinds = + | keyof typeof SdkIntKindsEnum + | keyof typeof SdkFloatingPointKindsEnum + | keyof typeof SdkFixedPointKindsEnum + | keyof typeof SdkGenericBuiltInStringKindsEnum + | keyof typeof SdkBuiltInKindsMiscellaneousEnum; + +enum SdkIntKindsEnum { + numeric = "numeric", + integer = "integer", + safeint = "safeint", + int8 = "int8", + int16 = "int16", + int32 = "int32", + int64 = "int64", + uint8 = "uint8", + uint16 = "uint16", + uint32 = "uint32", + uint64 = "uint64", +} + +enum SdkFloatingPointKindsEnum { + float = "float", + float32 = "float32", + float64 = "float64", +} + +enum SdkFixedPointKindsEnum { + decimal = "decimal", + decimal128 = "decimal128", +} + +enum SdkGenericBuiltInStringKindsEnum { + string = "string", + url = "url", +} + +enum SdkBuiltInKindsMiscellaneousEnum { + bytes = "bytes", + boolean = "boolean", + plainDate = "plainDate", + plainTime = "plainTime", + unknown = "unknown", +} + +export type SdkBuiltInKinds = Exclude | "unknown"; + +type SdkBuiltInKindsExcludes = "utcDateTime" | "offsetDateTime" | "duration"; + +export function getKnownScalars(): Record { + const retval: Record = {}; + const typespecNamespace = Object.keys(SdkBuiltInKindsMiscellaneousEnum) + .concat(Object.keys(SdkIntKindsEnum)) + .concat(Object.keys(SdkFloatingPointKindsEnum)) + .concat(Object.keys(SdkFixedPointKindsEnum)) + .concat(Object.keys(SdkGenericBuiltInStringKindsEnum)); + for (const kind of typespecNamespace) { + if (!isSdkBuiltInKind(kind)) continue; // it will always be true + retval[`TypeSpec.${kind}`] = kind; + } + return retval; +} + +export function isSdkBuiltInKind(kind: string): kind is SdkBuiltInKinds { + return ( + kind in SdkBuiltInKindsMiscellaneousEnum || + isSdkIntKind(kind) || + isSdkFloatKind(kind) || + isSdkFixedPointKind(kind) || + kind in SdkGenericBuiltInStringKindsEnum + ); +} + +export function isSdkIntKind(kind: string): kind is keyof typeof SdkIntKindsEnum { + return kind in SdkIntKindsEnum; +} + +export function isSdkFloatKind(kind: string): kind is keyof typeof SdkFloatingPointKindsEnum { + return kind in SdkFloatingPointKindsEnum; +} + +function isSdkFixedPointKind(kind: string): kind is keyof typeof SdkFixedPointKindsEnum { + return kind in SdkFixedPointKindsEnum; +} + +const SdkDateTimeEncodingsConst = ["rfc3339", "rfc7231", "unixTimestamp"] as const; + +export function isSdkDateTimeEncodings(encoding: string): encoding is DateTimeKnownEncoding { + return SdkDateTimeEncodingsConst.includes(encoding as DateTimeKnownEncoding); +} + +interface SdkDateTimeTypeBase extends SdkTypeBase { + name: string; + baseType?: SdkDateTimeType; + /** How to encode the type on wire. */ + encode: DateTimeKnownEncoding | string; + wireType: SdkBuiltInType; + /** Unique ID for the current type. */ + crossLanguageDefinitionId: string; +} + +interface SdkUtcDateTimeType extends SdkDateTimeTypeBase { + kind: "utcDateTime"; +} + +interface SdkOffsetDateTimeType extends SdkDateTimeTypeBase { + kind: "offsetDateTime"; +} + +export type SdkDateTimeType = SdkUtcDateTimeType | SdkOffsetDateTimeType; + +export interface SdkDurationType extends SdkTypeBase { + kind: "duration"; + name: string; + baseType?: SdkDurationType; + /** How to encode the type on wire. */ + encode: DurationKnownEncoding | string; + wireType: SdkBuiltInType; + /** Unique ID for the current type. */ + crossLanguageDefinitionId: string; +} + +export interface SdkArrayType extends SdkTypeBase { + kind: "array"; + name: string; + valueType: SdkType; + /** Unique ID for the current type. */ + crossLanguageDefinitionId: string; +} + +export interface SdkTupleType extends SdkTypeBase { + kind: "tuple"; + valueTypes: SdkType[]; +} + +export interface SdkDictionaryType extends SdkTypeBase { + kind: "dict"; + keyType: SdkType; + valueType: SdkType; +} + +export interface SdkNullableType extends SdkTypeBase { + kind: "nullable"; + name: string; + /** Whether name is created by TCGC. */ + isGeneratedName: boolean; + /** Unique ID for the current type. */ + crossLanguageDefinitionId: string; + type: SdkType; + /** Bitmap of the usage for the type. */ + usage: UsageFlags; + /** Whether the type has public or private accessibility */ + access: AccessFlags; + /** Full qualified namespace. */ + namespace: string; +} + +export interface SdkEnumType extends SdkTypeBase { + kind: "enum"; + name: string; + /** Whether name is created by TCGC. */ + isGeneratedName: boolean; + /** Full qualified namespace. */ + namespace: string; + valueType: SdkBuiltInType; + values: SdkEnumValueType[]; + isFixed: boolean; + isFlags: boolean; + /** Bitmap of the usage for the type. */ + usage: UsageFlags; + /** Whether the type has public or private accessibility */ + access: AccessFlags; + /** Unique ID for the current type. */ + crossLanguageDefinitionId: string; + /** API versions supported for current type. */ + apiVersions: string[]; + isUnionAsEnum: boolean; +} + +export interface SdkEnumValueType< + TValueType extends SdkTypeBase = SdkBuiltInType, +> extends SdkTypeBase { + kind: "enumvalue"; + name: string; + value: string | number; + enumType: SdkEnumType; + valueType: TValueType; + /** Unique ID for the current type. */ + crossLanguageDefinitionId: string; +} + +export interface SdkConstantType extends SdkTypeBase { + kind: "constant"; + value: string | number | boolean; + valueType: SdkBuiltInType; + name: string; + /** Whether name is created by TCGC. */ + isGeneratedName: boolean; +} + +export interface SdkUnionType extends SdkTypeBase { + name: string; + /** Whether name is created by TCGC. */ + isGeneratedName: boolean; + /** Full qualified namespace. */ + namespace: string; + kind: "union"; + variantTypes: TValueType[]; + /** Unique ID for the current type. */ + crossLanguageDefinitionId: string; + /** Whether the type has public or private accessibility. */ + access: AccessFlags; + /** Bitmap of the usage for the type. */ + usage: UsageFlags; + /** Info to distinguish between different union variants. */ + discriminatedOptions?: DiscriminatedOptions; +} + +export interface DiscriminatedOptions { + /** How is the discriminated union serialized. */ + envelope: "object" | "none"; + /** Name of the discriminator property. */ + discriminatorPropertyName: string; + /** Name of the property envelopping the data. `undefined` if envelope is "none" */ + envelopePropertyName?: string; +} + +export interface SdkModelType extends SdkTypeBase { + kind: "model"; + properties: SdkModelPropertyType[]; + name: string; + /** Whether name is created by TCGC. */ + isGeneratedName: boolean; + /** Full qualified namespace. */ + namespace: string; + /** Whether the type has public or private accessibility */ + access: AccessFlags; + /** Bitmap of the usage for the type. */ + usage: UsageFlags; + additionalProperties?: SdkType; + discriminatorValue?: string; + discriminatedSubtypes?: Record; + discriminatorProperty?: SdkModelPropertyType; + baseModel?: SdkModelType; + /** Unique ID for the current type. */ + crossLanguageDefinitionId: string; + /** API versions supported for current type. */ + apiVersions: string[]; + serializationOptions: SerializationOptions; +} + +/** + * Initialization info for a client. + */ +export interface SdkClientInitializationType extends SdkTypeBase { + kind: "clientinitialization"; + name: string; + /** Whether name is created by TCGC. */ + isGeneratedName: boolean; + /** Initialization parameters. */ + parameters: (SdkEndpointParameter | SdkCredentialParameter | SdkMethodParameter)[]; + /** How to initialize a client. */ + initializedBy: InitializedByFlags; +} + +/** + * Credential info. + */ +export interface SdkCredentialType extends SdkTypeBase { + kind: "credential"; + /** Auth scheme. Reuse TypeSpec Http types. */ + scheme: HttpAuth; +} + +/** + * Endpoint info. + */ +export interface SdkEndpointType extends SdkTypeBase { + kind: "endpoint"; + /** + * The server URL for the endpoint. + * If spec author does not specify the endpoint, we will use value "{endpoint}", and templateArguments will have one parameter called "endpoint" + */ + serverUrl: string; + /** Template arguments used in `serverUrl` string. */ + templateArguments: SdkPathParameter[]; +} + +export interface SdkModelPropertyTypeBase< + TType extends SdkTypeBase = SdkType, +> extends DecoratedType { + __raw?: ModelProperty; + /** Parameter type. */ + type: TType; + /** Parameter client name. */ + name: string; + /** Whether name is created by TCGC. */ + isGeneratedName: boolean; + /** Document for the type. */ + doc?: string; + /** Summary for the type. */ + summary?: string; + /** API versions supported for current type. */ + apiVersions: string[]; + /** Whether the type is on client level. */ + onClient: boolean; + /** Client level default value for the type. */ + clientDefaultValue?: unknown; + /** Whether the type is an API version parameter */ + isApiVersionParam: boolean; + /** Whether the type is optional. */ + optional: boolean; + /** Unique ID for the current type. */ + crossLanguageDefinitionId: string; + /** Visibility of the type. */ + visibility?: Visibility[]; + /** Whether the type has public or private accessibility */ + access: AccessFlags; + /** Whether this property could be flattened */ + flatten: boolean; + /** How to encode the property on wire. */ + encode?: ArrayKnownEncoding; +} + +export type ArrayKnownEncoding = + | "pipeDelimited" + | "spaceDelimited" + | "commaDelimited" + | "newlineDelimited"; + +/** + * Options to show how to serialize a model/property. + * A model/property that is used in multiple operations with different wire format could have multiple options set. For example, a model could be serialized as JSON in one operation and as XML in another operation. + * A model/property that has no special serialization logic will have no options set. For example, a property that is used in a HTTP query parameter will have no serialization options set. + * A model/property that is used as binary payloads will also have no options set. For example, a property that is used as a HTTP request body with `"image/png` content type. + */ +export interface SerializationOptions { + json?: JsonSerializationOptions; + xml?: XmlSerializationOptions; + multipart?: MultipartOptions; + binary?: BinarySerializationOptions; +} + +/** + * For Json serialization. + * The name will come from explicit setting of `@encodedName("application/json", "NAME")` or original model/property name. + */ +export interface JsonSerializationOptions { + name: string; +} + +/** + * For Xml serialization. + * The `name`/`itemsName` will come from explicit setting of `@encodedName("application/xml", "NAME")` or `@xml.Name("NAME")` or original model/property name. + * Other properties come from `@xml.attribute`, `@xml.ns`, `@xml.unwrapped`. + * The `itemsName` and `itemsNs` are used for array items. + * If `unwrapped` is `true`, `itemsName` should always be same as the `name`. If `unwrapped` is `false`, `itemsName` could have different name. + */ +export interface XmlSerializationOptions { + name: string; + attribute?: boolean; + ns?: { + namespace: string; + prefix: string; + }; + unwrapped?: boolean; + + itemsName?: string; + itemsNs?: { + namespace: string; + prefix: string; + }; +} + +export interface BinarySerializationOptions { + /** Whether this is a file/stream input */ + isFile: boolean; + /** + * Whether the file contents should be represented as a string or raw byte stream. + * + * True if the `contents` property is a `string`, `false` if it is `bytes`. + * + * Emitters may choose to represent textual files as strings or streams of textual characters. + * If this property is `false`, emitters must expect that the contents may contain non-textual + * data. + * + * This property is only present when `isFile` is `true`. When undefined, it indicates the + * body is not a file type. + */ + isText?: boolean; + /** + * The list of inner media types of the file. In other words, what kind of files can be returned. + * + * This is determined by the `contentType` property of the file model. + * + * This property is only present when `isFile` is `true`. When undefined, it indicates the + * body is not a file type. + */ + contentTypes?: string[]; + /** + * The ModelProperty that represents the filename in the file model. + * + * This property is only present when `isFile` is `true`. When undefined, it indicates the + * body is not a file type. + */ + filename?: ModelProperty; +} + +/** + * Endpoint parameter type for the client. + */ +export interface SdkEndpointParameter extends SdkModelPropertyTypeBase< + SdkEndpointType | SdkUnionType +> { + kind: "endpoint"; + /** Whether do url encode for the endpoint string. */ + urlEncode: boolean; + /** Endpoint parameter is always on client level. */ + onClient: true; + /** + * @deprecated This property is deprecated. Use `type.templateArguments[x].serializedName` or `type.variantTypes[x].templateArguments[x].serializedName` instead. + */ + serializedName?: string; +} + +export interface SdkCredentialParameter extends SdkModelPropertyTypeBase< + SdkCredentialType | SdkUnionType +> { + kind: "credential"; + /** Credential parameter is always on client level. */ + onClient: true; +} + +export interface MultipartOptions { + /** Name of the part in the multipart payload. */ + name: string; + /** Whether this part is for file */ + isFilePart: boolean; + /** Whether this part is multi in request payload */ + isMulti: boolean; + /** Undefined if filename is not set explicitly in Typespec */ + filename?: SdkModelPropertyType; + /** Undefined if contentType is not set explicitly in Typespec */ + contentType?: SdkModelPropertyType; + /** Default content types defined in Typespec or calculated by Typespec complier */ + defaultContentTypes: string[]; + /** Part headers */ + headers: SdkHeaderParameter[]; +} + +export interface SdkModelPropertyType extends SdkModelPropertyTypeBase { + kind: "property"; + discriminator: boolean; + /** + * @deprecated This property is deprecated. Use `serializationOptions.xxx.name` instead. + */ + serializedName: string; + serializationOptions: SerializationOptions; + /** + * @deprecated This property is deprecated. Use `multipartOptions?.isFilePart` instead. + */ + isMultipartFileInput: boolean; + /** + * @deprecated This property is deprecated. Use `serializationOptions.multipart` instead. + */ + multipartOptions?: MultipartOptions; +} + +export type CollectionFormat = "multi" | "csv" | "ssv" | "tsv" | "pipes" | "simple" | "form"; + +/** + * Http header parameter. + */ +export interface SdkHeaderParameter extends SdkModelPropertyTypeBase { + kind: "header"; + collectionFormat?: CollectionFormat; + /** Name for the parameter in the payload */ + serializedName: string; + /** + * @deprecated This property is deprecated. Use `methodParameterSegments` instead. + * Corresponding method level parameter or model property for current parameter. + */ + correspondingMethodParams: (SdkMethodParameter | SdkModelPropertyType)[]; + /** + * Segments to indicate the complete path from method parameters to this HTTP parameter. + * Each inner array represents a complete path from method parameter to the final HTTP parameter. + * For body parameters with spread, there can be multiple paths. + */ + methodParameterSegments: (SdkMethodParameter | SdkModelPropertyType)[][]; +} + +/** + * Http query parameter. + */ +export interface SdkQueryParameter extends SdkModelPropertyTypeBase { + kind: "query"; + collectionFormat?: CollectionFormat; + /** Name for the parameter in the payload */ + serializedName: string; + /** + * @deprecated This property is deprecated. Use `methodParameterSegments` instead. + * Corresponding method level parameter or model property for current parameter. + */ + correspondingMethodParams: (SdkMethodParameter | SdkModelPropertyType)[]; + /** + * Segments to indicate the complete path from method parameters to this HTTP parameter. + * Each inner array represents a complete path from method parameter to the final HTTP parameter. + * For body parameters with spread, there can be multiple paths. + */ + methodParameterSegments: (SdkMethodParameter | SdkModelPropertyType)[][]; + explode: boolean; +} + +/** + * Http path parameter. + */ +export interface SdkPathParameter extends SdkModelPropertyTypeBase { + kind: "path"; + explode: boolean; + style: "simple" | "label" | "matrix" | "fragment" | "path"; + allowReserved: boolean; + /** Name for the parameter in the payload */ + serializedName: string; + /** + * @deprecated This property is deprecated. Use `methodParameterSegments` instead. + * Corresponding method level parameter or model property for current parameter. + */ + correspondingMethodParams: (SdkMethodParameter | SdkModelPropertyType)[]; + /** + * Segments to indicate the complete path from method parameters to this HTTP parameter. + * Each inner array represents a complete path from method parameter to the final HTTP parameter. + * For body parameters with spread, there can be multiple paths. + */ + methodParameterSegments: (SdkMethodParameter | SdkModelPropertyType)[][]; +} + +/** + * Http cookie parameter. + */ +export interface SdkCookieParameter extends SdkModelPropertyTypeBase { + kind: "cookie"; + /** Name for the parameter in the payload */ + serializedName: string; + /** + * @deprecated This property is deprecated. Use `methodParameterSegments` instead. + * Corresponding method level parameter or model property for current parameter. + */ + correspondingMethodParams: (SdkMethodParameter | SdkModelPropertyType)[]; + /** + * Segments to indicate the complete path from method parameters to this HTTP parameter. + * Each inner array represents a complete path from method parameter to the final HTTP parameter. + * For body parameters with spread, there can be multiple paths. + */ + methodParameterSegments: (SdkMethodParameter | SdkModelPropertyType)[][]; +} + +/** + * Metadata about a streaming operation body or response. + * Present when the body/response is a streaming type (e.g. JsonlStream, SSEStream). + */ +export interface SdkStreamMetadata { + /** The type of the property decorated with `@body` (e.g. string, bytes). */ + bodyType: SdkType; + /** The stream model type itself (e.g. HttpStream, JsonlStream, SSEStream). */ + originalType: SdkType; + /** The payload model type being streamed (e.g. Thing from JsonlStream). */ + streamType: SdkType; + /** Content types associated with this stream (e.g. ["application/jsonl"], ["text/event-stream"]). */ + contentTypes: string[]; +} + +/** + * Http body parameter. + */ +export interface SdkBodyParameter extends SdkModelPropertyTypeBase { + kind: "body"; + /** Name for the parameter in the payload */ + serializedName: string; + contentTypes: string[]; + defaultContentType: string; + /** + * @deprecated This property is deprecated. Use `methodParameterSegments` instead. + * Corresponding method level parameter or model property for current parameter. + */ + correspondingMethodParams: (SdkMethodParameter | SdkModelPropertyType)[]; + /** + * Segments to indicate the complete path from method parameters to this HTTP parameter. + * Each inner array represents a complete path from method parameter to the final HTTP parameter. + * For body parameters with spread, there can be multiple paths. + */ + methodParameterSegments: (SdkMethodParameter | SdkModelPropertyType)[][]; + /** Stream metadata, present when the body is a streaming type (e.g. JsonlStream, SSEStream). */ + streamMetadata?: SdkStreamMetadata; + /** Options to show how to serialize the body. */ + serializationOptions: SerializationOptions; +} + +export type SdkHttpParameter = + | SdkQueryParameter + | SdkPathParameter + | SdkBodyParameter + | SdkHeaderParameter + | SdkCookieParameter; + +export interface SdkMethodParameter extends SdkModelPropertyTypeBase { + kind: "method"; +} + +export interface SdkServiceResponseHeader extends SdkModelPropertyTypeBase { + __raw: ModelProperty; + kind: "responseheader"; + serializedName: string; +} + +export interface SdkMethodResponse { + kind: "method"; + type?: SdkType; + /** + * An array of properties to fetch {result} from the {response} model. Note that this property is only for LRO and paging pattens. + */ + resultSegments?: SdkModelPropertyType[]; + /** + * Indicates whether the response type is optional. Set to true when the operation has at least one HTTP response without a body. + * This allows distinguishing between responses without a body and responses with a body of type `Type | null`. + */ + optional?: boolean; + /** Stream metadata, present when the response is a streaming type (e.g. JsonlStream, SSEStream). */ + streamMetadata?: SdkStreamMetadata; +} + +export interface SdkServiceResponse { + type?: SdkType; + headers: SdkServiceResponseHeader[]; + /** API versions supported for current type. */ + apiVersions: string[]; +} + +interface SdkHttpResponseBase extends SdkServiceResponse { + __raw: HttpOperationResponse; + kind: "http"; + contentTypes?: string[]; + defaultContentType?: string; + description?: string; + /** Stream metadata, present when the response is a streaming type (e.g. JsonlStream, SSEStream). */ + streamMetadata?: SdkStreamMetadata; + /** Options to show how to deserialize the response body. */ + serializationOptions: SerializationOptions; +} + +export interface SdkHttpResponse extends SdkHttpResponseBase { + statusCodes: number | HttpStatusCodeRange; +} + +export interface SdkHttpErrorResponse extends SdkHttpResponseBase { + statusCodes: number | HttpStatusCodeRange | "*"; +} + +interface SdkServiceOperationBase {} + +/** + * Http operation. + */ +export interface SdkHttpOperation extends SdkServiceOperationBase { + __raw: HttpOperation; + kind: "http"; + /** Route path. */ + path: string; + /** Route URI template string. */ + uriTemplate: string; + /** Http verb. */ + verb: HttpVerb; + /** Parameter lists. */ + parameters: (SdkPathParameter | SdkQueryParameter | SdkHeaderParameter | SdkCookieParameter)[]; + /** Body parameter. */ + bodyParam?: SdkBodyParameter; + /** Normal responses. */ + responses: SdkHttpResponse[]; + /** Error responses */ + exceptions: SdkHttpErrorResponse[]; + /** Operation usage examples. */ + examples?: SdkHttpOperationExample[]; +} + +/** + * We eventually will include other kinds of service operations, i.e. grpc. For now, it's just Http. + */ + +export type SdkServiceOperation = SdkHttpOperation; + +export interface SdkServiceMethodBase< + TServiceOperation extends SdkServiceOperation, +> extends DecoratedType { + __raw?: Operation; + name: string; + /** Whether the type has public or private accessibility */ + access: AccessFlags; + /** API versions supported for current type. */ + apiVersions: string[]; + /** Document for the type. */ + doc?: string; + /** Summary for the type. */ + summary?: string; + /** Unique ID for the current type. */ + crossLanguageDefinitionId: string; + /** Method's underlying protocol operation. */ + operation: TServiceOperation; + /** Method's parameters. */ + parameters: SdkMethodParameter[]; + /** Method's normal responses. */ + response: SdkMethodResponse; + /** Method's error responses. */ + exception?: SdkMethodResponse; + /** Whether generate convenient API for this method. */ + generateConvenient: boolean; + /** Whether generate protocol API for this method. */ + generateProtocol: boolean; + /** Whether this method is overridded. */ + isOverride: boolean; +} + +/** + * Basic method. + */ +export interface SdkBasicServiceMethod< + TServiceOperation extends SdkServiceOperation, +> extends SdkServiceMethodBase { + kind: "basic"; +} + +/** + * Paging operation info. + */ +export interface SdkPagingServiceMethodOptions { + /** Paging info. */ + pagingMetadata: SdkPagingServiceMetadata; +} + +/** + * Paging operation metadata. + */ +export interface SdkPagingServiceMetadata { + /** Paging metadata from TypeSpec core library. */ + __raw?: PagingOperation; + + /** Segments to indicate how to get next page link value from response. */ + nextLinkSegments?: (SdkServiceResponseHeader | SdkModelPropertyType)[]; + /** Method used to get next page. If not defined, use the initial method. */ + nextLinkOperation?: SdkServiceMethod; + /** HTTP verb to use for the next link operation. Defaults to "GET" if not specified. */ + nextLinkVerb?: "GET" | "POST"; + /** Segments to indicate how to get parameters that are needed to be injected into next page link. */ + nextLinkReInjectedParametersSegments?: (SdkMethodParameter | SdkModelPropertyType)[][]; + /** Segments to indicate how to set continuation token for next page request. */ + continuationTokenParameterSegments?: (SdkMethodParameter | SdkModelPropertyType)[]; + /** Segments to indicate how to get continuation token value from response. */ + continuationTokenResponseSegments?: (SdkServiceResponseHeader | SdkModelPropertyType)[]; + /** Segments to indicate how to get page items from response. */ + pageItemsSegments?: SdkModelPropertyType[]; + /** Denotes which parameter is the page size parameter */ + pageSizeParameterSegments?: (SdkMethodParameter | SdkModelPropertyType)[]; +} + +/** + * Paging method. + */ +export interface SdkPagingServiceMethod + extends + SdkServiceMethodBase, + SdkPagingServiceMethodOptions { + kind: "paging"; +} + +export type SdkServiceMethod = + | SdkBasicServiceMethod + | SdkPagingServiceMethod; + +export type SdkMethod = + SdkServiceMethod; + +/** + * Represents a client package, containing all clients, operations, and types. + */ +export interface SdkPackage { + /** First level clients of the package. */ + clients: SdkClientType[]; + /** All used models in the package. */ + models: SdkModelType[]; + /** All used enumerations in the package. */ + enums: SdkEnumType[]; + /** All used unions or nullable types in the package. */ + unions: (SdkUnionType | SdkNullableType)[]; + /** Unique ID for the package. */ + crossLanguagePackageId: string; + /** Hash of API-affecting elements for cross-language SDK comparison. */ + crossLanguageVersion: string; + /** Hierarchical structure for the package based on namespaces. */ + namespaces: SdkNamespace[]; + /** License details for client code comments or license file generation. */ + licenseInfo?: LicenseInfo; + /** Metadata for the package. */ + metadata: { + /** + * @deprecated Use `apiVersions` instead. This property will be removed in a future release. + * + * The version of the package. + * If undefined, the package is not versioned. + * If `all`, the package is versioned with all versions. + * If a string, the package is versioned with the specified version. + */ + apiVersion?: string; + /** + * The version map of the package. + * Key is the service namespace full qualified name, value is the version. + * If value is undefined, the package is not versioned. + * If value is a string, the service is versioned with the specified version. + */ + apiVersions?: Map; + }; +} + +/** + * License details for client code comments or license file generation. + */ +export interface LicenseInfo { + /** License name. */ + name: string; + /** Company name. */ + company: string; + /** License document link. */ + link: string; + /** Header comments. */ + header: string; + /** License file content. */ + description: string; +} + +/** + * Represents a namespace in the package, containing all clients, operations, and types. + */ +export interface SdkNamespace extends DecoratedType { + __raw?: Namespace; + /** Namespace name. */ + name: string; + /** Namespace full qualified name. */ + fullName: string; + /** Clients under this namespace. */ + clients: SdkClientType[]; + /** Models used in package under this namespace. */ + models: SdkModelType[]; + /** Enumerations used in package under this namespace. */ + enums: SdkEnumType[]; + /** Unions or nullable types used in package under this namespace. */ + unions: (SdkUnionType | SdkNullableType)[]; + /** Nested namespaces under this namespace. */ + namespaces: SdkNamespace[]; +} + +export type SdkHttpPackage = SdkPackage; + +export type LanguageScopes = "dotnet" | "java" | "python" | "javascript" | "go" | string; + +interface SdkExampleBase { + kind: string; + name: string; + doc: string; + filePath: string; + rawExample: any; +} + +export interface SdkHttpOperationExample extends SdkExampleBase { + kind: "http"; + parameters: SdkHttpParameterExampleValue[]; + responses: SdkHttpResponseExampleValue[]; +} + +export interface SdkHttpParameterExampleValue { + parameter: SdkHttpParameter; + value: SdkExampleValue; +} + +export interface SdkHttpResponseExampleValue { + response: SdkHttpResponse; + statusCode: number; + headers: SdkHttpResponseHeaderExampleValue[]; + bodyValue?: SdkExampleValue; +} + +export interface SdkHttpResponseHeaderExampleValue { + header: SdkServiceResponseHeader; + value: SdkExampleValue; +} + +export type SdkExampleValue = + | SdkStringExampleValue + | SdkNumberExampleValue + | SdkBooleanExampleValue + | SdkNullExampleValue + | SdkUnknownExampleValue + | SdkArrayExampleValue + | SdkDictionaryExampleValue + | SdkUnionExampleValue + | SdkModelExampleValue; + +interface SdkExampleValueBase { + kind: string; + type: SdkType; + value: unknown; +} + +export interface SdkStringExampleValue extends SdkExampleValueBase { + kind: "string"; + type: + | SdkBuiltInType + | SdkDateTimeType + | SdkDurationType + | SdkEnumType + | SdkEnumValueType + | SdkConstantType; + value: string; +} + +export interface SdkNumberExampleValue extends SdkExampleValueBase { + kind: "number"; + type: + | SdkBuiltInType + | SdkDateTimeType + | SdkDurationType + | SdkEnumType + | SdkEnumValueType + | SdkConstantType; + value: number; +} + +export interface SdkBooleanExampleValue extends SdkExampleValueBase { + kind: "boolean"; + type: SdkBuiltInType | SdkConstantType; + value: boolean; +} + +export interface SdkNullExampleValue extends SdkExampleValueBase { + kind: "null"; + type: SdkNullableType; + value: null; +} + +export interface SdkUnknownExampleValue extends SdkExampleValueBase { + kind: "unknown"; + type: SdkBuiltInType; + value: unknown; +} + +export interface SdkArrayExampleValue extends SdkExampleValueBase { + kind: "array"; + type: SdkArrayType; + value: SdkExampleValue[]; +} + +export interface SdkDictionaryExampleValue extends SdkExampleValueBase { + kind: "dict"; + type: SdkDictionaryType; + value: Record; +} + +export interface SdkUnionExampleValue extends SdkExampleValueBase { + kind: "union"; + type: SdkUnionType; + value: unknown; +} + +export interface SdkModelExampleValue extends SdkExampleValueBase { + kind: "model"; + type: SdkModelType; + value: Record; + additionalPropertiesValue?: Record; +} diff --git a/packages/http-client-generator-core/src/internal-utils.ts b/packages/http-client-generator-core/src/internal-utils.ts new file mode 100644 index 00000000000..fb8afefb38e --- /dev/null +++ b/packages/http-client-generator-core/src/internal-utils.ts @@ -0,0 +1,1322 @@ +import { + BooleanLiteral, + compilerAssert, + createDiagnosticCollector, + Diagnostic, + Enum, + getDeprecationDetails, + getDoc, + getLifecycleVisibilityEnum, + getNamespaceFullName, + getSummary, + getVisibilityForClass, + ignoreDiagnostics, + Interface, + isNeverType, + isNullType, + isTemplateDeclaration, + isVoidType, + listServices, + Model, + ModelProperty, + Namespace, + Numeric, + NumericLiteral, + Operation, + Program, + StringLiteral, + Type, + Union, + Value, +} from "@typespec/compiler"; +import { + unsafe_mutateSubgraphWithNamespace, + unsafe_MutatorWithNamespace, + unsafe_Realm, +} from "@typespec/compiler/experimental"; +import { $ } from "@typespec/compiler/typekit"; +import { + Authentication, + getHeaderFieldOptions, + getPathParamOptions, + getQueryParamOptions, + HttpOperation, + HttpOperationResponseContent, + HttpPayloadBody, + HttpServer, + isHeader, + isPathParam, + isQueryParam, +} from "@typespec/http"; +import { + getAddedOnVersions, + getRemovedOnVersions, + getVersioningMutators, + getVersions, +} from "@typespec/versioning"; +import { + getAlternateType, + getClientDocExplicit, + getClientLocation, + getIsApiVersion, + getLegacyHierarchyBuilding, + getOverriddenClientMethod, + getParamAlias, + getUsageOverride, +} from "./decorators.js"; +import { + DecoratorInfo, + ExternalTypeInfo, + SdkBuiltInType, + SdkClient, + SdkClientType, + SdkEnumType, + SdkHeaderParameter, + SdkMethodParameter, + SdkServiceOperation, + SdkType, + TCGCContext, +} from "./interfaces.js"; +import { createDiagnostic, createStateSymbol, reportDiagnostic } from "./lib.js"; +import { + getCrossLanguageDefinitionId, + getHttpOperationWithCache, + isApiVersion, +} from "./public-utils.js"; +import { getClientTypeWithDiagnostics } from "./types.js"; + +export interface TCGCEmitterOptions extends BrandedSdkEmitterOptionsInterface { + "emitter-name"?: string; +} + +export interface UnbrandedSdkEmitterOptionsInterface { + "generate-protocol-methods"?: boolean; + "generate-convenience-methods"?: boolean; + "api-version"?: string; + license?: { + name: string; + company?: string; + link?: string; + header?: string; + description?: string; + }; +} + +export interface BrandedSdkEmitterOptionsInterface extends UnbrandedSdkEmitterOptionsInterface { + "examples-dir"?: string; + namespace?: string; +} + +export const AllScopes = Symbol.for("@typespec/http-client-generator-core/all-scopes"); + +export const clientNameKey = createStateSymbol("clientName"); +export const clientNamespaceKey = createStateSymbol("clientNamespace"); +export const negationScopesKey = createStateSymbol("negationScopes"); +export const scopeKey = createStateSymbol("scope"); +export const clientKey = createStateSymbol("client"); +export const clientLocationKey = createStateSymbol("clientLocation"); +export const omitOperation = createStateSymbol("omitOperation"); +export const overrideKey = createStateSymbol("override"); +export const usageKey = createStateSymbol("usage"); +export const legacyHierarchyBuildingKey = createStateSymbol("legacyHierarchyBuilding"); + +export function hasExplicitClient(context: TCGCContext): boolean { + return listScopedDecoratorData(context, clientKey).size > 0; +} + +export function listScopedDecoratorData( + context: TCGCContext, + key: symbol, + languageScope?: string | typeof AllScopes, +): Map { + const scope = languageScope ?? context.emitterName; + const retval: Map = new Map(); + for (const [type, data] of context.program.stateMap(key).entries()) { + if (data[scope]) { + // positive scope case + retval.set(type, data[scope]); + } else if (data[negationScopesKey]) { + // negative scope case + if (data[negationScopesKey].includes(scope)) { + // if the scope is negated, we should not include it + continue; + } else { + // if the scope is not negated, we should include it + retval.set(type, data[AllScopes]); + } + } else if (data[AllScopes]) { + // all scopes case + retval.set(type, data[AllScopes]); + } + } + return retval; +} + +export function getScopedDecoratorData( + context: TCGCContext, + key: symbol, + target: Type, + languageScope?: string | typeof AllScopes, +): any { + const retval: Record = context.program.stateMap(key).get(target); + if (retval === undefined) return retval; + if (languageScope === AllScopes) { + return retval[languageScope]; + } + if (languageScope === undefined || typeof languageScope === "string") { + const scope = languageScope ?? context.emitterName; + if (scope in retval) return retval[scope]; + + // if the scope is negated, we should return undefined + // if the scope is not negated, we should return the value for AllScopes + const negationScopes = retval[negationScopesKey]; + if (negationScopes !== undefined && negationScopes.includes(scope)) { + return undefined; + } + } + return retval[AllScopes]; // in this case it applies to all languages +} + +/** + * Parse a scope string to extract negation scopes and positive scopes. + * Supports two syntax patterns: + * 1. !(scope1, scope2,...) - Grouped negation + * 2. !scope1, !scope2, scope3, ... - Individual negation with positive scopes + * + * @param scope The scope string to parse + * @returns A tuple of [negationScopes, positiveScopes] where each can be undefined if not present + */ +export function parseScopes(scope?: string): [string[]?, string[]?] { + if (scope === undefined) { + return [undefined, undefined]; + } + + // handle !(scope1, scope2,...) syntax + const negationScopeRegex = /!\((.*?)\)/; + const negationScopeMatch = scope.match(negationScopeRegex); + if (negationScopeMatch) { + return [negationScopeMatch[1].split(",").map((s) => s.trim()), undefined]; + } + + // handle !scope1, !scope2, scope3, ... syntax + const splitScopes = scope.split(",").map((s) => s.trim()); + const negationScopes: string[] = []; + const scopes: string[] = []; + for (const s of splitScopes) { + if (s.startsWith("!")) { + negationScopes.push(s.slice(1)); + } else { + scopes.push(s); + } + } + return [negationScopes, scopes]; +} + +/** + * Check if a scope string is applicable to the given emitter name. + * Handles negation scopes like "!python" or "!(java, python)". + * + * @param scopeArg The scope string from the decorator argument + * @param emitterName The current emitter name + * @returns true if the decorator should be included, false otherwise + */ +function isScopeApplicable(scopeArg: string, emitterName: string): boolean { + const [negationScopes, positiveScopes] = parseScopes(scopeArg); + + // If there are positive scopes specified + if (positiveScopes !== undefined && positiveScopes.length > 0) { + // If the emitter matches any positive scope, include it + if (positiveScopes.includes(emitterName)) { + return true; + } + // If positive scopes specified but emitter doesn't match any, and no negation scopes + // then the decorator doesn't apply to this emitter + if (negationScopes === undefined || negationScopes.length === 0) { + return false; + } + } + + // If there are negation scopes + if (negationScopes !== undefined && negationScopes.length > 0) { + // If the emitter is in the negation list, exclude it + if (negationScopes.includes(emitterName)) { + return false; + } + // If not in negation list, include it (applies to all except negated scopes) + return true; + } + + // No scopes specified at all (empty string edge case) + return true; +} + +/** + * + * @param emitterName Full emitter name + * @returns The language of the emitter. I.e. "@azure-tools/typespec-csharp" will return "csharp" + */ +export function parseEmitterName( + program: Program, + emitterName?: string, +): [string, readonly Diagnostic[]] { + const diagnostics = createDiagnosticCollector(); + if (!emitterName) { + diagnostics.add( + createDiagnostic({ + code: "no-emitter-name", + format: {}, + target: program.getGlobalNamespaceType(), + }), + ); + return diagnostics.wrap("none"); + } + const regex = /(?:cadl|typespec|client|server)-([^\\/-]*)/; + const match = emitterName.match(regex); + if (!match || match.length < 2) return diagnostics.wrap("none"); + const language = match[1]; + if (["typescript", "ts"].includes(language)) return diagnostics.wrap("javascript"); + return diagnostics.wrap(language); +} + +/** + * Find the service namespace that contains the given operation. + * @param services Array of service namespaces + * @param operation The operation to find the service for + * @returns The service namespace that contains the operation + */ +export function findServiceForOperation(services: Namespace[], operation: Operation): Namespace { + // Follow the sourceOperation chain to find the original service namespace. + // This is needed when operations are defined using `is` in customization interfaces + // (e.g., `opB is ServiceB.Operations.opB`), where the operation's namespace is the + // customization namespace rather than the original service namespace. + let current: Operation | undefined = operation; + while (current) { + let namespace = current.namespace; + while (namespace) { + if (services.includes(namespace)) { + return namespace; + } + namespace = namespace.namespace; + } + current = current.sourceOperation; + } + // Fallback to the first service. This can happen when an operation is defined outside + // of any service namespace (e.g., in Azure.ResourceManager or other shared namespaces) + // and is imported into a client that combines multiple services. In such cases, + // we use the first service's api version as the default. + return services[0]; +} + +/** + * + * @param context + * @param type The type that we are adding api version information onto + * @param client The client or sub clients that contains the operation + * @param operation The operation that contains the api version parameter (needed for multi-service sub clients) + * @returns Whether the type is the api version parameter and the default value for the client + */ +export function updateWithApiVersionInformation( + context: TCGCContext, + type: ModelProperty, + client?: SdkClient, + operation?: Operation, +): { + isApiVersionParam: boolean; + clientDefaultValue?: string; +} { + const isApiVersionParam = isApiVersion(context, type); + if (!isApiVersionParam || !client) { + return { isApiVersionParam, clientDefaultValue: undefined }; + } + + // For single-service clients, use the cached value + if (client.services.length <= 1) { + return { + isApiVersionParam, + clientDefaultValue: context.__clientApiVersionDefaultValueCache.get(client), + }; + } + + // For multi-service clients/sub clients, we need to find the api version + // from the operation's specific service + if (operation) { + const service = findServiceForOperation(client.services, operation); + const packageVersions = context.getPackageVersions().get(service) || []; + return { + isApiVersionParam, + clientDefaultValue: + packageVersions.length > 0 ? packageVersions[packageVersions.length - 1] : undefined, + }; + } + + // No operation provided for multi-service client, return undefined + return { isApiVersionParam, clientDefaultValue: undefined }; +} + +export function filterApiVersionsWithDecorators( + context: TCGCContext, + type: Type, + apiVersions: string[], +): string[] { + const addedOnVersions = getAddedOnVersions(context.program, type)?.map((x) => x.value) ?? []; + const removedOnVersions = getRemovedOnVersions(context.program, type)?.map((x) => x.value) ?? []; + let added: boolean = addedOnVersions.length ? false : true; + let addedCounter = 0; + let removeCounter = 0; + const retval: string[] = []; + for (let i = 0; i < apiVersions.length; i++) { + const version = apiVersions[i]; + if (addedCounter < addedOnVersions.length && version === addedOnVersions[addedCounter]) { + added = true; + addedCounter++; + } + if (removeCounter < removedOnVersions.length && version === removedOnVersions[removeCounter]) { + added = false; + removeCounter++; + } + if (added) { + // only add version smaller than config + if ( + context.apiVersion === undefined || + context.apiVersion === "latest" || + context.apiVersion === "all" || + apiVersions.indexOf(context.apiVersion) >= i + ) { + retval.push(version); + } + } + } + return retval; +} + +/** + * + * @param context + * @param type + * @param client If it's associated with a client, meaning it's a param etc, we can see if it's available on that client + * @returns All api versions the type is available on + */ +export function getAvailableApiVersions( + context: TCGCContext, + type: Type, + wrapper?: Type, +): string[] { + let wrapperApiVersions: string[] = []; + if (wrapper) { + wrapperApiVersions = context.getApiVersionsForType(wrapper); + } + + const allApiVersions = + getVersions(context.program, type)[1] + ?.getVersions() + .map((x) => x.value) || []; + + const apiVersions = wrapperApiVersions.length ? wrapperApiVersions : allApiVersions; + if (!apiVersions) return []; + const explicitlyDecorated = filterApiVersionsWithDecorators(context, type, apiVersions); + if (explicitlyDecorated.length) { + context.setApiVersionsForType(type, explicitlyDecorated); + return explicitlyDecorated; + } + context.setApiVersionsForType(type, wrapperApiVersions); + return wrapperApiVersions; +} + +/** + * + * @param type + * @returns A unique id for each type so we can do set comparisons + */ +export function getHashForType(type: SdkType): string { + if (type.kind === "array" || type.kind === "dict") { + return `${type.kind}[${getHashForType(type.valueType)}]`; + } + if (type.kind === "enum" || type.kind === "model" || type.kind === "enumvalue") return type.name; + if (type.kind === "union") { + return type.variantTypes.map((x) => getHashForType(x)).join("|"); + } + return type.kind; +} + +interface DefaultSdkTypeBase { + __raw: Type; + deprecation?: string; + kind: TKind; + decorators: DecoratorInfo[]; + external?: ExternalTypeInfo; + doc?: string; + summary?: string; +} + +/** + * Helper function to return default values for encode etc + * @param type + */ +export function getSdkTypeBaseHelper( + context: TCGCContext, + type: Type, + kind: TKind, +): [DefaultSdkTypeBase, readonly Diagnostic[]] { + const diagnostics = createDiagnosticCollector(); + + const base: DefaultSdkTypeBase = { + __raw: type, + deprecation: getDeprecationDetails(context.program, type)?.message, + kind, + decorators: diagnostics.pipe(getTypeDecorators(context, type)), + doc: getClientDoc(context, type), + summary: getSummary(context.program, type), + }; + if ( + type.kind === "ModelProperty" || + type.kind === "Scalar" || + type.kind === "Model" || + type.kind === "Enum" || + type.kind === "Union" + ) { + const external = getAlternateType(context, type); + // Only set external if it's an ExternalTypeInfo (has 'identity' but not 'kind' property), not a regular Type + if (external && external.kind === "externalTypeInfo") { + base.external = external; + } + } + return diagnostics.wrap(base); +} + +export function getNamespacePrefix(namespace: Namespace): string { + return namespace ? getNamespaceFullName(namespace) + "." : ""; +} + +export function getTypeDecorators( + context: TCGCContext, + type: Type, +): [DecoratorInfo[], readonly Diagnostic[]] { + const diagnostics = createDiagnosticCollector(); + const retval: DecoratorInfo[] = []; + if ("decorators" in type) { + for (const decorator of type.decorators) { + // only process explicitly defined decorators + if (decorator.definition) { + const decoratorName = `${getNamespacePrefix(decorator.definition?.namespace)}${decorator.definition?.name}`; + // white list filtering + if ( + !context.decoratorsAllowList || + !context.decoratorsAllowList.some((x) => new RegExp(x).test(decoratorName)) + ) { + continue; + } + + const decoratorInfo: DecoratorInfo = { + name: decoratorName, + arguments: {}, + }; + for (let i = 0; i < decorator.args.length; i++) { + decoratorInfo.arguments[decorator.definition.parameters[i].name] = diagnostics.pipe( + getDecoratorArgValue(context, decorator.args[i].jsValue, type, decoratorName), + ); + } + + // Filter by scope - only include decorators that match the current emitter or have no scope + const scopeArg = decoratorInfo.arguments["scope"]; + if (scopeArg !== undefined && !isScopeApplicable(scopeArg, context.emitterName)) { + // Skip this decorator if its scope is not applicable to the current emitter + continue; + } + + retval.push(decoratorInfo); + } + } + } + return diagnostics.wrap(retval); +} + +function getDecoratorArgValue( + context: TCGCContext, + arg: + | Type + | Record + | Value + | unknown[] + | string + | number + | boolean + | Numeric + | null, + type: Type, + decoratorName: string, +): [any, readonly Diagnostic[]] { + const diagnostics = createDiagnosticCollector(); + if (typeof arg === "object" && arg !== null && "kind" in arg) { + if (arg.kind === "EnumMember") { + return diagnostics.wrap(diagnostics.pipe(getClientTypeWithDiagnostics(context, arg as any))); + } + if ( + arg.kind === "String" || + arg.kind === "Number" || + arg.kind === "Boolean" || + arg.kind === "Value" + ) { + return diagnostics.wrap(arg.value); + } + diagnostics.add( + createDiagnostic({ + code: "unsupported-generic-decorator-arg-type", + target: type, + format: { decoratorName }, + }), + ); + return diagnostics.wrap(undefined); + } + return diagnostics.wrap(arg); +} + +export function intOrFloat(value: number): "int32" | "float32" { + return value.toString().indexOf(".") === -1 ? "int32" : "float32"; +} + +/** + * In the core package, this always returns false. + * @param t + * @returns + */ +export function isAzureCoreTspModel(t: Type): boolean { + return false; +} + +export function isAcceptHeader(param: SdkHeaderParameter): boolean { + return param.kind === "header" && param.serializedName.toLowerCase() === "accept"; +} + +export function isContentTypeHeader(param: SdkHeaderParameter): boolean { + return param.kind === "header" && param.serializedName.toLowerCase() === "content-type"; +} + +export function isHttpOperation(context: TCGCContext, obj: any): obj is HttpOperation { + return obj?.kind === "Operation" && getHttpOperationWithCache(context, obj) !== undefined; +} + +export type TspLiteralType = StringLiteral | NumericLiteral | BooleanLiteral; + +/** A node in a context path that tracks the traversal position for naming anonymous types. */ +export interface ContextNode { + name: string; + // Type can be undefined to indicate "anonymous" context (e.g., when property type is a named union) + type: Model | Union | TspLiteralType | Operation | undefined; +} + +export function getNonNullOptions(type: Union): Type[] { + return [...type.variants.values()].map((x) => x.type).filter((t) => !isNullType(t)); +} + +export function getNullOption(type: Union): Type | undefined { + return [...type.variants.values()].map((x) => x.type).filter((t) => isNullType(t))[0]; +} + +/** + * Use this if you are trying to create a generated name for something without an original TypeSpec type. + * + * Otherwise, you should use the `getGeneratedName` function. + * @param context + */ +export function createGeneratedName( + context: TCGCContext, + type: Interface | Namespace | Operation, + suffix: string, +): string { + return `${getCrossLanguageDefinitionId(context, type).split(".").at(-1)}${suffix}`; +} + +export function isSubscriptionId(context: TCGCContext, parameter: { name: string }): boolean { + return false; +} + +export function isNeverOrVoidType(type: Type): boolean { + return isNeverType(type) || isVoidType(type); +} + +export function getAnyType( + context: TCGCContext, + type: Type, +): [SdkBuiltInType, readonly Diagnostic[]] { + const diagnostics = createDiagnosticCollector(); + return diagnostics.wrap({ + kind: "unknown", + name: "unknown", + encode: "string", + crossLanguageDefinitionId: "", + decorators: diagnostics.pipe(getTypeDecorators(context, type)), + }); +} + +export function getHttpOperationResponseHeaders( + response: HttpOperationResponseContent, +): ModelProperty[] { + const headers: ModelProperty[] = response.headers ? Object.values(response.headers) : []; + if (response.body?.contentTypeProperty) { + headers.push(response.body.contentTypeProperty); + } + return headers; +} + +export function removeVersionsLargerThanExplicitlySpecified( + context: TCGCContext, + versions: { value: string | number }[], +): void { + // filter with specific api version + if ( + context.apiVersion !== undefined && + context.apiVersion !== "latest" && + context.apiVersion !== "all" + ) { + const index = versions.findIndex((version) => version.value === context.apiVersion); + if (index >= 0) { + versions.splice(index + 1, versions.length - index - 1); + } + } +} + +export function filterPreviewVersion( + context: TCGCContext, + sdkVersionsEnum: SdkEnumType, + defaultApiVersion: string, +): void { + // if they explicitly set an api version, remove larger versions + removeVersionsLargerThanExplicitlySpecified(context, sdkVersionsEnum.values); + if (!context.previewStringRegex.test(defaultApiVersion)) { + sdkVersionsEnum.values = sdkVersionsEnum.values.filter((v) => { + if (typeof v.value !== "string") { + return true; + } + + // Fall back to regex check for backward compatibility + return !context.previewStringRegex.test(v.value); + }); + } +} + +export function twoParamsEquivalent( + context: TCGCContext, + param1?: ModelProperty, + param2?: ModelProperty, +): boolean { + if (!param1 || !param2) { + return false; + } + return ( + param1.type === param2.type && + (param1.name === param2.name || + getParamAlias(context, param1) === param2.name || + param1.name === getParamAlias(context, param2)) + ); +} + +/** + * If body is from spread, then it does not directly from a model property. + * @param httpBody + * @param parameters + * @returns + */ +export function isHttpBodySpread(httpBody: HttpPayloadBody): boolean { + return httpBody.bodyKind !== "file" && httpBody.property === undefined; +} + +/** + * If body is from simple spread, then we use the original model as body model. Else we return the body type directly. + * @param type + * @returns + */ +export function getHttpBodyType(httpBody: HttpPayloadBody): Type { + const type = httpBody.type; + if (isHttpBodySpread(httpBody) && type.kind === "Model") { + if (type.sourceModels.length === 1 && type.sourceModels[0].usage === "spread") { + const innerModel = type.sourceModels[0].model; + // for case: `op test(...Model):void;` + if (innerModel.name !== "" && innerModel.properties.size === type.properties.size) { + return innerModel; + } + // for case: `op test(@header h: string, @query q: string, ...Model): void;` + if ( + innerModel.sourceModels.length === 1 && + innerModel.sourceModels[0].usage === "spread" && + innerModel.sourceModels[0].model.name !== "" && + innerModel.sourceModels[0].model.properties.size === type.properties.size + ) { + return innerModel.sourceModels[0].model; + } + } + return type; + } + return type; +} + +export function isOnClient( + context: TCGCContext, + type: ModelProperty, + operation?: Operation, + versioning?: boolean, +): boolean { + const clientLocation = getClientLocation(context, type); + if ( + operation && + clientLocation === (getOverriddenClientMethod(context, operation) ?? operation) + ) { + // if the type has explicitly been moved to the operation, it is not on the client + return false; + } + // When using @override, @clientLocation might be on the override operation's parameter + // rather than on the original operation's parameter. Check the override's corresponding + // parameter for @clientLocation targeting the override operation. + if (operation) { + const override = getOverriddenClientMethod(context, operation); + if (override) { + for (const [, overrideParam] of override.parameters.properties) { + if ( + compareModelProperties(context.program, overrideParam, type) && + getClientLocation(context, overrideParam) === override + ) { + return false; + } + } + } + } + return ( + isSubscriptionId(context, type) || + (isApiVersion(context, type) && versioning) || + (operation !== undefined && getCorrespondingClientParam(context, type, operation) !== undefined) + ); +} + +export function getCorrespondingClientParam( + context: TCGCContext, + type: ModelProperty, + operation: Operation, +): SdkMethodParameter | undefined { + // When @clientLocation explicitly targets this operation, the parameter should stay at + // the method level and not be mapped to an existing client parameter. + const clientLocation = getClientLocation(context, type); + if ( + clientLocation && + clientLocation === (getOverriddenClientMethod(context, operation) ?? operation) + ) { + return undefined; + } + + const clientParams = []; + let client: SdkClient | undefined = context.getClientForOperation(operation); + while (client) { + const clientParamsForClient = context.__clientParametersCache.get(client); + if (clientParamsForClient) { + clientParams.push(...clientParamsForClient); + } + if (!client.parent) { + break; + } + client = client.parent; + } + const correspondingClientParam = clientParams?.find((x) => + twoParamsEquivalent(context, x.__raw, type), + ); + if (correspondingClientParam) { + // If the parameter is explicitly marked as not an API version parameter via @apiVersion(false), + // it should not be matched to a client API version parameter. + if (getIsApiVersion(context, type) === false && correspondingClientParam.isApiVersionParam) { + return undefined; + } + return correspondingClientParam; + } + return undefined; +} + +export function getValueTypeValue( + value: Value, +): string | boolean | null | number | Array | object | undefined { + switch (value.valueKind) { + case "ArrayValue": + return value.values.map((x) => getValueTypeValue(x)); + case "BooleanValue": + case "StringValue": + case "NullValue": + return value.value; + case "NumericValue": + return value.value.asNumber(); + case "EnumValue": + return value.value.value ?? value.value.name; + case "ObjectValue": + return Object.fromEntries( + [...value.properties.keys()].map((x) => [ + x, + getValueTypeValue(value.properties.get(x)!.value), + ]), + ); + default: + // TODO: handle scalar value + return undefined; + } +} + +export function hasNoneVisibility(context: TCGCContext, type: ModelProperty): boolean { + const lifecycle = getLifecycleVisibilityEnum(context.program); + const visibility = getVisibilityForClass(context.program, type, lifecycle); + return visibility.size === 0; +} + +function listAllNamespaces( + context: TCGCContext, + namespace: Namespace, + retval?: Namespace[], +): Namespace[] { + if (!retval) { + retval = []; + } + if (retval.includes(namespace)) return retval; + retval.push(namespace); + for (const ns of namespace.namespaces.values()) { + listAllNamespaces(context, ns, retval); + } + return retval; +} + +export function listAllUserDefinedNamespaces(context: TCGCContext): Namespace[] { + return listAllNamespaces(context, context.getMutatedGlobalNamespace()).filter((ns) => + $(context.program).type.isUserDefined(ns), + ); +} + +export function findRootSourceProperty(property: ModelProperty): ModelProperty { + while (property.sourceProperty) { + property = property.sourceProperty; + } + return property; +} + +export function getStreamAsBytes( + context: TCGCContext, + type: Type, +): [SdkBuiltInType, readonly Diagnostic[]] { + const diagnostics = createDiagnosticCollector(); + const unknownType: SdkBuiltInType = { + ...diagnostics.pipe(getSdkTypeBaseHelper(context, type, "bytes")), + name: "bytes", + encode: "bytes", + crossLanguageDefinitionId: "", + }; + return diagnostics.wrap(unknownType); +} + +function getVersioningMutator( + context: TCGCContext, + service: Namespace, + apiVersion?: string, +): unsafe_MutatorWithNamespace | undefined { + const versionMutator = getVersioningMutators(context.program, service); + if (!versionMutator) return undefined; + if (versionMutator.kind === "transient") { + return versionMutator.mutator; + } + const mutators = versionMutator.snapshots + .filter((snapshot) => apiVersion === snapshot.version.value) + .map((x) => x.mutator); + compilerAssert(mutators.length === 1, "One api version should not get multiple mutators"); + + return mutators[0]; +} + +export function handleVersioningMutationForGlobalNamespace(context: TCGCContext): Namespace { + const globalNamespace = context.program.getGlobalNamespaceType(); + + // First consider explicit clients + const servicesNs = new Set(); + listScopedDecoratorData(context, clientKey).forEach((v, k) => { + // See all explicit clients that in TypeSpec program + if (!unsafe_Realm.realmForType.has(k)) { + (v as SdkClient).services.forEach((s) => servicesNs.add(s)); + } + }); + + // Then see the original services + if (servicesNs.size === 0) { + listServices(context.program).map((v) => servicesNs.add(v.type)); + } + + // No service, thus no versioning mutation needed + if (servicesNs.size === 0) return globalNamespace; + + // Multi services' client should not honor the specific api-version set in config + if ( + servicesNs.size > 1 && + context.apiVersion !== undefined && + context.apiVersion !== "latest" && + context.apiVersion !== "all" + ) { + context.apiVersion = undefined; + } + + // Explicit all API version setting, thus no versioning mutation needed + if (context.apiVersion === "all") return globalNamespace; + + // Compose service mutators + const mutators: unsafe_MutatorWithNamespace[] = []; + + for (const serviceNs of servicesNs) { + const versions = getVersions(context.program, serviceNs)[1]?.getVersions(); + // If the service has no versioning, no mutation needed + if (!versions || versions.length === 0) return globalNamespace; + + // Single service needs to filter versions based on `apiVersion` config + if (servicesNs.size === 1) { + removeVersionsLargerThanExplicitlySpecified(context, versions); + } + + const versionsValues = versions.map((v) => v.value); + + // Fix apiVersion setting problem only if there's only one service + if (servicesNs.size === 1) { + if ( + context.apiVersion !== undefined && + context.apiVersion !== "latest" && + context.apiVersion !== "all" && + !versionsValues.includes(context.apiVersion) + ) { + reportDiagnostic(context.program, { + code: "api-version-undefined", + format: { version: context.apiVersion }, + target: serviceNs, + }); + context.apiVersion = versionsValues[versionsValues.length - 1]; + } + } + + // Get service mutator according to the version setting + const mutator = getVersioningMutator( + context, + serviceNs, + versionsValues[versionsValues.length - 1], + ); + if (mutator) mutators.push(mutator); + } + if (mutators.length === 0) return globalNamespace; + const subgraph = unsafe_mutateSubgraphWithNamespace(context.program, mutators, globalNamespace); + compilerAssert(subgraph.type.kind === "Namespace", "Should not have mutated to another type"); + compilerAssert(subgraph.realm !== null, "Should have a realm after mutation"); + context.__mutatedRealm = subgraph.realm; + return subgraph.type; +} + +export function resolveDuplicateGenearatedName( + context: TCGCContext, + type: Union | Model | TspLiteralType, + createName: string, +): string { + let duplicateCount = 1; + const rawCreateName = createName; + const generatedNames = [...context.__generatedNames.values()]; + while (generatedNames.includes(createName)) { + createName = `${rawCreateName}${duplicateCount++}`; + } + context.__generatedNames.set(type, createName); + return createName; +} + +export function resolveConflictGeneratedName(context: TCGCContext) { + const userDefinedNames = [...context.__referencedTypeCache.values()] + .filter((x) => !x.isGeneratedName) + .map((x) => x.name); + const generatedNames = [...context.__generatedNames.values()]; + + for (const sdkType of context.__referencedTypeCache.values()) { + if (sdkType.__raw && sdkType.isGeneratedName && userDefinedNames.includes(sdkType.name)) { + const rawName = sdkType.name; + let duplicateCount = 1; + let createName = `${rawName}${duplicateCount++}`; + while (userDefinedNames.includes(createName) || generatedNames.includes(createName)) { + createName = `${rawName}${duplicateCount++}`; + } + sdkType.name = createName; + context.__generatedNames.set(sdkType.__raw, createName); + generatedNames.push(createName); + } + } +} + +export function getClientDoc(context: TCGCContext, target: Type): string | undefined { + const clientDocExplicit = getClientDocExplicit(context, target); + const baseDoc = getDoc(context.program, target); + if (clientDocExplicit) { + switch (clientDocExplicit.mode) { + case "append": + return baseDoc + ? `${baseDoc}\n${clientDocExplicit.documentation}` + : clientDocExplicit.documentation; + case "replace": + return clientDocExplicit.documentation; + } + } + return baseDoc; +} + +export function compareModelProperties( + program: Program, + modelPropA: ModelProperty | undefined, + modelPropB: ModelProperty | undefined, +): boolean { + if (!modelPropA || !modelPropB) return false; + if (modelPropA.name !== modelPropB.name || modelPropA.type !== modelPropB.type) return false; + const aIsQuery = isQueryParam(program, modelPropA); + const aIsHeader = isHeader(program, modelPropA); + const aIsPath = isPathParam(program, modelPropA); + const bIsQuery = isQueryParam(program, modelPropB); + const bIsHeader = isHeader(program, modelPropB); + const bIsPath = isPathParam(program, modelPropB); + // Return false when both have explicit HTTP parameter kinds but they differ + const aHasHttpKind = aIsQuery || aIsHeader || aIsPath; + const bHasHttpKind = bIsQuery || bIsHeader || bIsPath; + if (aHasHttpKind && bHasHttpKind) { + if (aIsQuery !== bIsQuery || aIsHeader !== bIsHeader || aIsPath !== bIsPath) return false; + } + if ( + aIsQuery && + bIsQuery && + getQueryParamOptions(program, modelPropA)?.name !== + getQueryParamOptions(program, modelPropB)?.name + ) { + return false; + } + if ( + aIsHeader && + bIsHeader && + getHeaderFieldOptions(program, modelPropA)?.name !== + getHeaderFieldOptions(program, modelPropB)?.name + ) { + return false; + } + if ( + aIsPath && + bIsPath && + getPathParamOptions(program, modelPropA)?.name !== + getPathParamOptions(program, modelPropB)?.name + ) { + return false; + } + return true; +} + +export function* filterMapValuesIterator( + iterator: MapIterator, + predicate: (value: V) => boolean, +): MapIterator { + for (const value of iterator) { + if (predicate(value)) { + yield value; + } + } +} + +/** + * Find all entries in a scoped decorator state map where the target matches a specific value + */ +export function findEntriesWithTarget( + context: TCGCContext, + stateKey: symbol, + targetValue: TTarget, + sourceKind?: TSource["kind"], +): TSource[] { + const results: TSource[] = []; + + for (const [type, target] of listScopedDecoratorData(context, stateKey)) { + if (sourceKind && type.kind !== sourceKind) { + continue; + } + if (target === targetValue) { + results.push(type as TSource); + } + } + return results; +} + +/** + * Retrieves Long Running Operation (LRO) metadata for a given operation. + * + * In the core package, LRO metadata is not available (it requires Azure-specific libraries). + * Always returns undefined. + */ +export function getTcgcLroMetadata( + context: TCGCContext, + operation: Operation, + client: SdkClientType, +): undefined { + return undefined; +} + +export function getActualClientType(client: SdkClient): Namespace | Interface { + if (client.type) return client.type; + // For merged multi-service sub clients where type is cleared or sub client created by string client location, fall back to the first service + return client.services[0]; +} + +export function isSameServers(left: HttpServer[], right: HttpServer[]): boolean { + if (left.length !== right.length) { + return false; + } + for (let i = 0; i < left.length; i++) { + if (left[i].url !== right[i].url) { + return false; + } + } + return true; +} + +export function isSameAuth(left: Authentication, right: Authentication): boolean { + if (left.options.length !== right.options.length) { + return false; + } + for (let i = 0; i < left.options.length; i++) { + if (left.options[i].schemes.length !== right.options[i].schemes.length) { + return false; + } + for (let j = 0; j < left.options[i].schemes.length; j++) { + const leftScheme = left.options[i].schemes[j]; + const rightScheme = right.options[i].schemes[j]; + if (leftScheme.type !== rightScheme.type) { + return false; + } + switch (leftScheme.type) { + case "http": + compilerAssert(rightScheme.type === "http", "Unexpected auth scheme type mismatch"); + if (leftScheme.scheme !== rightScheme.scheme) { + return false; + } + break; + case "apiKey": + compilerAssert(rightScheme.type === "apiKey", "Unexpected auth scheme type mismatch"); + if (leftScheme.name !== rightScheme.name || leftScheme.in !== rightScheme.in) { + return false; + } + break; + case "oauth2": + compilerAssert(rightScheme.type === "oauth2", "Unexpected auth scheme type mismatch"); + if (leftScheme.flows.length !== rightScheme.flows.length) { + return false; + } + for (let k = 0; k < leftScheme.flows.length; k++) { + const leftFlow = leftScheme.flows[k]; + const rightFlow = rightScheme.flows[k]; + if (leftFlow.type !== rightFlow.type) { + return false; + } + if (leftFlow.scopes.length !== rightFlow.scopes.length) { + return false; + } + for (let l = 0; l < leftFlow.scopes.length; l++) { + if (leftFlow.scopes[l].value !== rightFlow.scopes[l].value) { + return false; + } + } + switch (leftFlow.type) { + case "authorizationCode": + compilerAssert( + rightFlow.type === "authorizationCode", + "Unexpected auth scheme type mismatch", + ); + if ( + leftFlow.authorizationUrl !== rightFlow.authorizationUrl || + leftFlow.tokenUrl !== rightFlow.tokenUrl || + leftFlow.refreshUrl !== rightFlow.refreshUrl + ) { + return false; + } + break; + case "clientCredentials": + compilerAssert( + rightFlow.type === "clientCredentials", + "Unexpected auth scheme type mismatch", + ); + if ( + leftFlow.tokenUrl !== rightFlow.tokenUrl || + leftFlow.refreshUrl !== rightFlow.refreshUrl + ) { + return false; + } + break; + case "implicit": + compilerAssert( + rightFlow.type === "implicit", + "Unexpected auth scheme type mismatch", + ); + if ( + leftFlow.authorizationUrl !== rightFlow.authorizationUrl || + leftFlow.refreshUrl !== rightFlow.refreshUrl + ) { + return false; + } + break; + case "password": + compilerAssert( + rightFlow.type === "password", + "Unexpected auth scheme type mismatch", + ); + if ( + leftFlow.authorizationUrl !== rightFlow.authorizationUrl || + leftFlow.refreshUrl !== rightFlow.refreshUrl + ) { + return false; + } + break; + } + } + break; + case "openIdConnect": + compilerAssert( + rightScheme.type === "openIdConnect", + "Unexpected auth scheme type mismatch", + ); + if (leftScheme.openIdConnectUrl !== rightScheme.openIdConnectUrl) { + return false; + } + break; + } + } + } + return true; +} + +export function isTypeNeedsHandling(context: TCGCContext, type: Type): boolean { + return ( + (context.__mutatedRealm === undefined && !unsafe_Realm.realmForType.has(type)) || + (context.__mutatedRealm !== undefined && context.__mutatedRealm.hasType(type)) + ); +} + +export function listOrphanTypes(context: TCGCContext): (Model | Enum | Union)[] { + if (context.__orphanTypesCache) return context.__orphanTypesCache; + const result: (Model | Enum | Union)[] = []; + const userDefinedNamespaces = listAllUserDefinedNamespaces(context); + for (const currNamespace of userDefinedNamespaces) { + const namespaces = [currNamespace]; + let currentIndex = 0; + while (currentIndex < namespaces.length) { + const namespace = namespaces[currentIndex]; + // orphan models + for (const model of namespace.models.values()) { + if (isTemplateDeclaration(model)) continue; + if (!getUsageOverride(context, model) && !getLegacyHierarchyBuilding(context, model)) + continue; + result.push(model); + } + // orphan enums + for (const enumType of namespace.enums.values()) { + if (!getUsageOverride(context, enumType)) continue; + result.push(enumType); + } + // orphan unions + for (const unionType of namespace.unions.values()) { + if (isTemplateDeclaration(unionType)) continue; + if (!getUsageOverride(context, unionType)) continue; + result.push(unionType); + } + namespaces.push(...namespace.namespaces.values()); + currentIndex++; + } + } + context.__orphanTypesCache = result; + return result; +} diff --git a/packages/http-client-generator-core/src/lib.ts b/packages/http-client-generator-core/src/lib.ts new file mode 100644 index 00000000000..70f7baedf8b --- /dev/null +++ b/packages/http-client-generator-core/src/lib.ts @@ -0,0 +1,560 @@ +import { createTypeSpecLibrary, JSONSchemaType, paramMessage } from "@typespec/compiler"; +import { + BrandedSdkEmitterOptionsInterface, + TCGCEmitterOptions, + UnbrandedSdkEmitterOptionsInterface, +} from "./internal-utils.js"; + +export const UnbrandedSdkEmitterOptions = { + "generate-protocol-methods": { + "generate-protocol-methods": { + type: "boolean", + nullable: true, + description: + "When set to `true`, the emitter will generate low-level protocol methods for each service operation if `@protocolAPI` is not set for an operation. Default value is `true`.", + }, + }, + "generate-convenience-methods": { + "generate-convenience-methods": { + type: "boolean", + nullable: true, + description: + "When set to `true`, the emitter will generate convenience methods for each service operation if `@convenientAPI` is not set for an operation. Default value is `true`.", + }, + }, + "api-version": { + "api-version": { + type: "string", + nullable: true, + description: + "Use this flag if you would like to generate the sdk only for a specific version. Default value is the latest version. Also accepts values `latest` and `all`.", + }, + }, + license: { + license: { + type: "object", + additionalProperties: false, + nullable: true, + required: ["name"], + properties: { + name: { + type: "string", + nullable: false, + description: + "License name. The config is required. Predefined license are: MIT License, Apache License 2.0, BSD 3-Clause License, MPL 2.0, GPL-3.0, LGPL-3.0. For other license, you need to configure all the other license config manually.", + }, + company: { + type: "string", + nullable: true, + description: "License company name. It will be used in copyright sentences.", + }, + link: { + type: "string", + nullable: true, + description: "License link.", + }, + header: { + type: "string", + nullable: true, + description: + "License header. It will be used in the header comment of generated client code.", + }, + description: { + type: "string", + nullable: true, + description: "License description. The full license text.", + }, + }, + description: "License information for the generated client code.", + }, + }, +} as const; + +const UnbrandedSdkEmitterOptionsInterfaceSchema: JSONSchemaType = + { + type: "object", + additionalProperties: false, + properties: { + ...UnbrandedSdkEmitterOptions["generate-protocol-methods"], + ...UnbrandedSdkEmitterOptions["generate-convenience-methods"], + ...UnbrandedSdkEmitterOptions["api-version"], + ...UnbrandedSdkEmitterOptions["license"], + }, + }; + +export const BrandedSdkEmitterOptions = { + "examples-dir": { + "examples-dir": { + type: "string", + nullable: true, + format: "absolute-path", + description: + "Specifies the directory where the emitter will look for example files. If the flag isn’t set, the emitter defaults to using an `examples` directory located at the project root.", + }, + }, + namespace: { + namespace: { + type: "string", + nullable: true, + description: + "Specifies the namespace you want to override for namespaces set in the spec. With this config, all namespace for the spec types will default to it.", + }, + }, +} as const; + +const BrandedSdkEmitterOptionsSchema: JSONSchemaType = { + type: "object", + additionalProperties: false, + properties: { + ...UnbrandedSdkEmitterOptionsInterfaceSchema.properties!, + ...BrandedSdkEmitterOptions["examples-dir"], + ...BrandedSdkEmitterOptions["namespace"], + }, +}; + +const TCGCEmitterOptionsSchema: JSONSchemaType = { + type: "object", + additionalProperties: false, + properties: { + "emitter-name": { + type: "string", + nullable: true, + description: "Set `emitter-name` to output TCGC code models for specific language's emitter.", + }, + ...BrandedSdkEmitterOptionsSchema.properties!, + }, +}; + +export const $lib = createTypeSpecLibrary({ + name: "@typespec/http-client-generator-core", + diagnostics: { + "client-service": { + severity: "warning", + messages: { + default: paramMessage`Client "${"name"}" is not inside a service namespace. Use @client({service: MyServiceNS})`, + }, + }, + "union-null": { + severity: "warning", + messages: { + default: "Cannot have a union containing only null types.", + }, + }, + "union-circular": { + severity: "warning", + messages: { + default: "Cannot have a union containing self.", + }, + }, + "invalid-access": { + severity: "error", + messages: { + default: `Access value must be "public" or "internal".`, + }, + }, + "invalid-usage": { + severity: "error", + messages: { + default: `Usage value must be one of: 2 (input), 4 (output), 256 (json), or 512 (xml).`, + }, + }, + "conflicting-multipart-model-usage": { + severity: "error", + messages: { + default: paramMessage`Model '${"modelName"}' cannot be used as both multipart/form-data input and regular body input. You can create a separate model with name 'model ${"modelName"}FormData' extends ${"modelName"} {}`, + }, + }, + "discriminator-not-constant": { + severity: "error", + messages: { + default: paramMessage`Discriminator ${"discriminator"} has to be constant`, + }, + }, + "discriminator-not-string": { + severity: "warning", + messages: { + default: paramMessage`Value of discriminator ${"discriminator"} has to be a string, not ${"discriminatorValue"}`, + }, + }, + "wrong-client-decorator": { + severity: "warning", + messages: { + default: "@client should decorate namespace or interface in client.tsp", + }, + }, + "unsupported-kind": { + severity: "warning", + messages: { + default: paramMessage`Unsupported kind ${"kind"}`, + }, + }, + "server-param-not-path": { + severity: "error", + messages: { + default: paramMessage`Template argument ${"templateArgumentName"} is not a path parameter, it is a ${"templateArgumentType"}. It has to be a path.`, + }, + }, + "unexpected-http-param-type": { + severity: "error", + messages: { + default: paramMessage`Expected parameter "${"paramName"}" to be of type "${"expectedType"}", but instead it is of type "${"actualType"}"`, + }, + }, + "multiple-response-types": { + severity: "warning", + messages: { + default: paramMessage`Multiple response types found in operation ${"operation"}. Some emitters might not support returning all of these response types`, + }, + }, + "no-corresponding-method-param": { + severity: "error", + messages: { + default: paramMessage`Missing HTTP operation parameter "${"paramName"}" in method "${"methodName"}". Please check the method definition.`, + }, + }, + "unsupported-protocol": { + severity: "error", + messages: { + default: "Currently we only support HTTP and HTTPS protocols", + }, + }, + "no-emitter-name": { + severity: "warning", + messages: { + default: "Can not find name for your emitter, please check your emitter name.", + }, + }, + "unsupported-generic-decorator-arg-type": { + severity: "warning", + messages: { + default: paramMessage`Can not parse the arg type for decorator "${"decoratorName"}".`, + }, + }, + "empty-client-name": { + severity: "warning", + messages: { + default: `Cannot pass an empty value to the @clientName decorator`, + }, + }, + "override-parameters-mismatch": { + severity: "error", + messages: { + default: paramMessage`Method "${"methodName"}" has different parameters definition from the override operation. Please check the parameter defined in the override operation: "${"checkParameter"}".`, + }, + }, + "duplicate-client-name": { + severity: "error", + messages: { + default: paramMessage`Client name: "${"name"}" is duplicated in language scope: "${"scope"}"`, + nonDecorator: paramMessage`Client name: "${"name"}" is defined somewhere causing naming conflicts in language scope: "${"scope"}"`, + }, + }, + "duplicate-client-name-warning": { + severity: "warning", + messages: { + default: paramMessage`Client name: "${"name"}" is duplicated in language scope: "${"scope"}"`, + nonDecorator: paramMessage`Client name: "${"name"}" is defined somewhere causing naming conflicts in language scope: "${"scope"}"`, + }, + }, + "client-name-ineffective": { + severity: "warning", + messages: { + default: paramMessage`Application of @clientName decorator to ${"name"} is not effective`, + override: paramMessage`Application of @clientName decorator to ${"name"} is not effective because it is applied to the override method. Please apply it on the original method definition "${"originalMethodName"}" instead.`, + }, + }, + "example-loading": { + severity: "warning", + messages: { + default: paramMessage`Skipped loading invalid example file: ${"filename"}. Error: ${"error"}`, + noDirectory: paramMessage`Skipping example loading from ${"directory"} because there was an error reading the directory.`, + noOperationId: paramMessage`Skipping example file ${"filename"} because it does not contain an operationId and/or title.`, + }, + }, + "duplicate-example-file": { + severity: "error", + messages: { + default: paramMessage`Example file ${"filename"} uses duplicate title '${"title"}' for operationId '${"operationId"}'`, + }, + }, + "example-value-no-mapping": { + severity: "warning", + messages: { + default: paramMessage`Value in example file '${"relativePath"}' does not follow its definition:\n${"value"}`, + }, + }, + "flatten-polymorphism": { + severity: "error", + messages: { + default: `Cannot flatten property of polymorphic type.`, + }, + }, + "conflict-access-override": { + severity: "warning", + messages: { + default: `@access override conflicts with the access calculated from operation or other @access override.`, + }, + }, + "duplicate-decorator": { + severity: "warning", + messages: { + default: paramMessage`Decorator ${"decoratorName"} cannot be used twice on the same declaration with same scope.`, + }, + }, + "empty-client-namespace": { + severity: "warning", + messages: { + default: `Cannot pass an empty value to the @clientNamespace decorator`, + }, + }, + "unexpected-pageable-operation-return-type": { + severity: "error", + messages: { + default: `The response object for the pageable operation is either not a paging model, or is not correctly decorated with @nextLink and @pageItems.`, + }, + }, + "invalid-alternate-type": { + severity: "error", + messages: { + default: paramMessage`Invalid alternate type. If the source type is Scalar, the alternate type must also be Scalar. Found alternate type kind: '${"kindName"}'`, + }, + }, + "invalid-initialized-by": { + severity: "error", + messages: { + default: paramMessage`Invalid 'initializedBy' value. ${"message"}`, + }, + }, + "invalid-deserializeEmptyStringAsNull-target-type": { + severity: "error", + messages: { + default: + "@deserializeEmptyStringAsNull can only be applied to `ModelProperty` of type 'string' or a `Scalar` derived from 'string'.", + }, + }, + "api-version-not-string": { + severity: "warning", + messages: { + default: `Api version must be a string or a string enum`, + }, + }, + "invalid-encode-for-collection-format": { + severity: "warning", + messages: { + default: + "Only encode of `ArrayEncoding.pipeDelimited` and `ArrayEncoding.spaceDelimited` is supported for collection format.", + }, + }, + "non-head-bool-response-decorator": { + severity: "warning", + messages: { + default: paramMessage`@responseAsBool decorator can only be used on HEAD operations. Will ignore decorator on ${"operationName"}.`, + }, + }, + "require-versioned-service": { + severity: "warning", + description: "Require a versioned service to use this decorator", + messages: { + default: paramMessage`Service "${"serviceName"}" must be versioned if you want to apply the "${"decoratorName"}" decorator`, + }, + }, + "missing-service-versions": { + severity: "warning", + description: "Missing service versions", + messages: { + default: paramMessage`The @clientApiVersions decorator is missing one or more versions defined in ${"serviceName"}. Client API must support all service versions to ensure compatibility. Missing versions: ${"missingVersions"}. Please update the client API to support all required service versions.`, + }, + }, + "invalid-client-doc-mode": { + severity: "error", + messages: { + default: paramMessage`Invalid mode '${"mode"}' for @clientDoc decorator. Valid values are "append" or "replace".`, + }, + }, + "multiple-param-alias": { + severity: "warning", + messages: { + default: paramMessage`Multiple param aliases applied to '${"originalName"}'. Only the first one '${"firstParamAlias"}' will be used.`, + }, + }, + "client-location-conflict": { + severity: "warning", + messages: { + default: + "@clientLocation with string target could not be used for multiple root clients scenario", + operationToOperation: + "`@clientLocation` cannot be used to move an operation to another operation. Operations can only be moved to interfaces or namespaces.", + modelPropertyToClientInitialization: paramMessage`There is already a parameter called '${"parameterName"}' in the client initialization.`, + modelPropertyToString: + "`@clientLocation` can only move model properties to interfaces or namespaces.", + }, + }, + "client-location-wrong-type": { + severity: "warning", + messages: { + default: + "`@clientLocation` could only move operation to the interface or namespace belong to the root namespace with `@service`.", + }, + }, + "legacy-hierarchy-building-conflict": { + severity: "warning", + messages: { + "property-type-mismatch": paramMessage`@hierarchyBuilding decorator: property '${"propertyName"}' on model '${"childModel"}' has type that does not match the same-named property supplied by the new base chain (rooted at '${"parentModel"}'). The property is dropped from '${"childModel"}' to satisfy the rebase rule (own properties are filtered against the new base chain by name). Consider aligning the types or removing the property from '${"childModel"}'.`, + }, + }, + "legacy-hierarchy-building-circular-reference": { + severity: "error", + messages: { + default: "@hierarchyBuilding decorator causes recursive base type reference.", + }, + }, + "missing-scope": { + severity: "warning", + messages: { + default: paramMessage`@scope decorator should be applied with ${"decoratorName"} since it is highly likely this is language-specific`, + }, + }, + "required-parameter-scoped-out": { + severity: "warning", + messages: { + default: paramMessage`Required parameter "${"paramName"}" is scoped out for emitter "${"scope"}". This may cause runtime errors unless the parameter is provided through other means (e.g., custom headers).`, + }, + }, + "external-library-version-mismatch": { + severity: "warning", + messages: { + default: paramMessage`External library version mismatch. There are multiple versions of ${"libraryName"}: ${"versionA"} and ${"versionB"}. Please unify the versions.`, + }, + }, + "external-type-on-model-property": { + severity: "warning", + messages: { + default: `@alternateType with external type information cannot be applied to model properties. Please apply it to the type definition itself (Scalar, Model, Enum, or Union) instead.`, + }, + }, + "invalid-mark-as-lro-target": { + severity: "warning", + messages: { + default: paramMessage`@markAsLro decorator can only be applied to operations that return a model. We will ignore this decorator.`, + }, + }, + "mark-as-lro-ineffective": { + severity: "warning", + messages: { + default: paramMessage`@markAsLro decorator is ineffective since this operation already returns real LRO metadata. Please remove the @markAsLro decorator.`, + }, + }, + "invalid-mark-as-pageable-target": { + severity: "warning", + messages: { + default: paramMessage`@markAsPageable decorator can only be applied to operations that return a model with a property decorated with @pageItems or a property named 'value'. We will ignore this decorator.`, + }, + }, + "mark-as-pageable-ineffective": { + severity: "warning", + messages: { + default: paramMessage`@markAsPageable decorator is ineffective since this operation is already marked as pageable with @list decorator. Please remove the @markAsPageable decorator.`, + }, + }, + "api-version-undefined": { + severity: "warning", + messages: { + default: paramMessage`The API version specified in the config: "${"version"}" is not defined in service versioning list. Fall back to the latest version.`, + }, + }, + "root-client-missing-service": { + severity: "error", + messages: { + default: "Root namespace decorated with @client must have service config.", + }, + }, + "invalid-client-service-multiple": { + severity: "error", + messages: { + default: "`@client` with multiple services is only allowed on `Namespace`.", + }, + }, + "inconsistent-multiple-service": { + severity: "error", + messages: { + default: "All services must have the same server and auth definitions.", + }, + }, + "inconsistent-multiple-service-dependency": { + severity: "warning", + messages: { + default: paramMessage`Services merged into client "${"clientName"}" depend on different versions of "${"dependencyName"}": ${"versions"}.`, + }, + }, + "client-option": { + severity: "warning", + messages: { + default: + "@clientOption is experimental and should only be used for temporary workarounds. This usage must be suppressed.", + }, + }, + "client-option-requires-scope": { + severity: "warning", + messages: { + default: + "@clientOption should be applied with a specific language scope since it is highly likely this is language-specific.", + }, + }, + "replace-parameter-not-found": { + severity: "error", + messages: { + default: paramMessage`Parameter "${"paramName"}" not found in operation "${"operationName"}".`, + }, + }, + "reorder-parameter-not-found": { + severity: "error", + messages: { + default: paramMessage`Parameter "${"paramName"}" specified in reorder list not found in operation "${"operationName"}".`, + }, + }, + "reorder-parameter-missing": { + severity: "error", + messages: { + default: paramMessage`Parameter "${"paramName"}" from operation "${"operationName"}" is missing in reorder list.`, + }, + }, + "add-parameter-duplicate": { + severity: "error", + messages: { + default: paramMessage`Parameter "${"paramName"}" already exists in operation "${"operationName"}".`, + }, + }, + "reorder-parameter-duplicate": { + severity: "error", + messages: { + default: paramMessage`Parameter "${"paramName"}" appears more than once in the reorder list for operation "${"operationName"}".`, + }, + }, + "remove-parameter-not-found": { + severity: "error", + messages: { + default: paramMessage`Parameter "${"paramName"}" not found in operation "${"operationName"}".`, + }, + }, + "nested-client-service-not-subset": { + severity: "error", + messages: { + default: + "Nested client's services must be a subset of the parent client's services. If no service is needed, omit the `service` property to inherit from the parent.", + }, + }, + "auto-merge-service-conflict": { + severity: "error", + messages: { + default: "Auto-merging service client must be empty.", + }, + }, + }, + emitter: { + options: TCGCEmitterOptionsSchema, + }, +}); + +const { reportDiagnostic, createDiagnostic, createStateSymbol } = $lib; + +export { createDiagnostic, createStateSymbol, reportDiagnostic }; diff --git a/packages/http-client-generator-core/src/license.ts b/packages/http-client-generator-core/src/license.ts new file mode 100644 index 00000000000..a5f6af93836 --- /dev/null +++ b/packages/http-client-generator-core/src/license.ts @@ -0,0 +1,158 @@ +import { LicenseInfo, TCGCContext } from "./interfaces.js"; + +export const licenseMap: { [key: string]: LicenseInfo } = { + "MIT License": { + name: "MIT License", + link: "https://mit-license.org", + company: "", + header: `Copyright (c) . All rights reserved. +Licensed under the MIT License.`, + description: `Copyright (c) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the “Software”), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE.`, + }, + "Apache License 2.0": { + name: "Apache License 2.0", + link: "https://www.apache.org/licenses/LICENSE-2.0", + company: "", + header: `Copyright (c) . All rights reserved. +Licensed under the Apache License, Version 2.0.`, + description: `Copyright (c) + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License.`, + }, + "BSD 3-Clause License": { + name: "BSD 3-Clause License", + link: "https://opensource.org/licenses/BSD-3-Clause", + company: "", + header: `Copyright (c) . All rights reserved. +Licensed under the BSD 3-Clause License.`, + description: `Copyright (c) + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the + names of its contributors may be used to endorse or promote products + derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.`, + }, + "MPL 2.0": { + name: "MPL 2.0", + link: "https://www.mozilla.org/en-US/MPL/2.0/", + company: "", + header: `Copyright (c) . All rights reserved. +Licensed under the Mozilla Public License, v. 2.0.`, + description: `Copyright (c) + +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. If a copy of the MPL was not distributed with this +file, You can obtain one at https://mozilla.org/MPL/2.0/.`, + }, + "GPL-3.0": { + name: "GPL-3.0", + link: "https://www.gnu.org/licenses/gpl-3.0.html", + company: "", + header: `Copyright (c) . All rights reserved. +Licensed under the version 3 of the GNU General Public License.`, + description: `Copyright (c) + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see .`, + }, + "LGPL-3.0": { + name: "LGPL-3.0", + link: "https://www.gnu.org/licenses/lgpl-3.0.html", + company: "", + header: `Copyright (c) . All rights reserved. +Licensed under the version 3 of the GNU Lesser General Public License.`, + description: `Copyright (c) + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Lesser General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public License +along with this program. If not, see .`, + }, +}; + +export function getLicenseInfo(context: TCGCContext): LicenseInfo | undefined { + if (!context.license) { + return undefined; + } + + // if license name is not preset in TCGC, we will use user's config + if (!Object.keys(licenseMap).includes(context.license.name)) { + return { + name: context.license.name, + company: context.license.company ?? "", + link: context.license.link ?? "", + header: context.license.header ?? "", + description: context.license.description ?? "", + }; + } + + // use preset license info if no user customization + const licenseInfo = licenseMap[context.license.name]; + return { + name: licenseInfo.name, + company: context.license.company ?? "", + link: context.license.link ?? licenseInfo.link, + header: + context.license.header ?? + licenseInfo.header.replace("", context.license.company ?? ""), + description: + context.license.description ?? + licenseInfo.description.replace("", context.license.company ?? ""), + }; +} diff --git a/packages/http-client-generator-core/src/linter.ts b/packages/http-client-generator-core/src/linter.ts new file mode 100644 index 00000000000..f66179b9c61 --- /dev/null +++ b/packages/http-client-generator-core/src/linter.ts @@ -0,0 +1,24 @@ +import { defineLinter } from "@typespec/compiler"; +import { noUnnamedTypesRule } from "./rules/no-unnamed-types.rule.js"; +import { propertyNameConflictRule } from "./rules/property-name-conflict.rule.js"; +import { requireClientSuffixRule } from "./rules/require-client-suffix.rule.js"; + +const rules = [requireClientSuffixRule, propertyNameConflictRule, noUnnamedTypesRule]; + +const csharpRules = [propertyNameConflictRule]; + +export const $linter = defineLinter({ + rules, + ruleSets: { + "best-practices:csharp": { + enable: { + ...Object.fromEntries( + csharpRules.map((rule) => [ + `@typespec/http-client-generator-core/${rule.name}`, + true, + ]), + ), + }, + }, + }, +}); diff --git a/packages/http-client-generator-core/src/media-types.ts b/packages/http-client-generator-core/src/media-types.ts new file mode 100644 index 00000000000..37c65b5136d --- /dev/null +++ b/packages/http-client-generator-core/src/media-types.ts @@ -0,0 +1,49 @@ +const json = "json"; +const xml = "xml"; +const application = "application"; +const text = "text"; + +export function parseMediaType(mediaType: string) { + if (mediaType) { + const parsed = + /(application|audio|font|example|image|message|model|multipart|text|video|x-(?:[0-9A-Za-z!#$%&'*+.^_`|~-]+))\/([0-9A-Za-z!#$%&'*.^_`|~-]+)\s*(?:\+([0-9A-Za-z!#$%&'*.^_`|~-]+))?\s*(?:;.\s*(\S*))?/g.exec( + mediaType, + ); + if (parsed) { + return { + type: parsed[1], + subtype: parsed[2], + suffix: parsed[3], + parameter: parsed[4], + }; + } + } + return undefined; +} + +export function isMediaTypeTextPlain(mediaType: string): boolean { + const mt = parseMediaType(mediaType); + + return mt ? mt.type === text && mt.subtype === "plain" : false; +} + +export function isMediaTypeOctetStream(mediaType: string): boolean { + const mt = parseMediaType(mediaType); + + return mt ? mt.type === application && mt.subtype === "octet-stream" : false; +} + +export function isMediaTypeJson(mediaType: string): boolean { + const mt = parseMediaType(mediaType); + return mt + ? (mt.subtype === json || mt.subtype === "jsonl" || mt.suffix === json) && + (mt.type === application || mt.type === text) + : false; +} + +export function isMediaTypeXml(mediaType: string): boolean { + const mt = parseMediaType(mediaType); + return mt + ? (mt.subtype === xml || mt.suffix === xml) && (mt.type === application || mt.type === text) + : false; +} diff --git a/packages/http-client-generator-core/src/methods.ts b/packages/http-client-generator-core/src/methods.ts new file mode 100644 index 00000000000..192809d0142 --- /dev/null +++ b/packages/http-client-generator-core/src/methods.ts @@ -0,0 +1,515 @@ +import { + compilerAssert, + createDiagnosticCollector, + Diagnostic, + getSummary, + ignoreDiagnostics, + isList, + ModelProperty, + Operation, +} from "@typespec/compiler"; +import { $ } from "@typespec/compiler/typekit"; +import { + getAccess, + getDisablePageable, + getMarkAsPageable, + getNextLinkVerb, + getOverriddenClientMethod, + getResponseAsBool, + isInScope, + listOperationsInClient, + shouldGenerateConvenient, + shouldGenerateProtocol, +} from "./decorators.js"; +import { getSdkHttpOperation } from "./http.js"; +import { + SdkArrayType, + SdkBuiltInType, + SdkClient, + SdkClientType, + SdkMethod, + SdkMethodParameter, + SdkMethodResponse, + SdkModelPropertyType, + SdkModelType, + SdkPagingServiceMethod, + SdkServiceMethod, + SdkServiceOperation, + SdkStreamMetadata, + SdkType, + TCGCContext, + UsageFlags, +} from "./interfaces.js"; +import { + createGeneratedName, + findRootSourceProperty, + getActualClientType, + getAvailableApiVersions, + getClientDoc, + getCorrespondingClientParam, + getHashForType, + getTypeDecorators, + isNeverOrVoidType, + isSubscriptionId, +} from "./internal-utils.js"; +import { createDiagnostic } from "./lib.js"; +import { + getCrossLanguageDefinitionId, + getHttpOperationWithCache, + getLibraryName, +} from "./public-utils.js"; +import { + getClientTypeWithDiagnostics, + getSdkBuiltInType, + getSdkModelPropertyType, + getSdkModelPropertyTypeBase, +} from "./types.js"; + +function getSdkServiceOperation( + context: TCGCContext, + operation: Operation, + methodParameters: SdkMethodParameter[], + client: SdkClientType, +): [TServiceOperation, readonly Diagnostic[]] { + const diagnostics = createDiagnosticCollector(); + const httpOperation = getHttpOperationWithCache(context, operation); + if (httpOperation) { + const sdkHttpOperation = diagnostics.pipe( + getSdkHttpOperation(context, httpOperation, methodParameters, client), + ) as TServiceOperation; + return diagnostics.wrap(sdkHttpOperation); + } + diagnostics.add( + createDiagnostic({ + code: "unsupported-protocol", + target: operation, + format: {}, + }), + ); + return diagnostics.wrap(undefined as any); +} + +function getPageSizeParameterSegments( + baseServiceMethod: SdkServiceMethod, +): (SdkModelPropertyType | SdkMethodParameter)[] { + function recurseToFindPageSizeParameterInModel( + param: SdkMethodParameter, + model: SdkModelType, + ): (SdkModelPropertyType | SdkMethodParameter)[] { + for (const prop of model.properties) { + if (prop.__raw && prop.__raw.decorators.find((d) => d.definition?.name === "@pageSize")) { + return [param, prop]; + } + if (prop.type.kind === "model") { + const nested = recurseToFindPageSizeParameterInModel(param, prop.type); + if (nested.length > 0) { + return nested; + } + } + } + return []; + } + for (const p of baseServiceMethod.parameters) { + if (p.__raw && p.__raw.decorators.find((d) => d.definition?.name === "@pageSize")) { + return [p]; + } + if (p.type.kind === "model") { + return recurseToFindPageSizeParameterInModel(p, p.type); + } + } + return []; +} + +function getSdkPagingServiceMethod( + context: TCGCContext, + operation: Operation, + client: SdkClientType, +): [SdkPagingServiceMethod, readonly Diagnostic[]] { + const diagnostics = createDiagnosticCollector(); + + const baseServiceMethod = diagnostics.pipe( + getSdkBasicServiceMethod(context, operation, client), + ); + + // If the response body type itself is nullable (e.g., {@body body: Type | null}), unwrap it for paging/LRO processing + let responseType = baseServiceMethod.response.type; + if (responseType?.kind === "nullable") { + responseType = responseType.type; + } + + // normal paging + if (isList(context.program, operation)) { + const pagingMetadata = $(context.program).operation.getPagingMetadata( + getOverriddenClientMethod(context, operation) ?? operation, + ); + + if (responseType?.__raw?.kind !== "Model" || responseType.kind !== "model" || !pagingMetadata) { + diagnostics.add( + createDiagnostic({ + code: "unexpected-pageable-operation-return-type", + target: operation, + format: { + operationName: operation.name, + }, + }), + ); + // return as page method with no paging info + return diagnostics.wrap({ + ...baseServiceMethod, + kind: "paging", + pagingMetadata: {}, + }); + } + + const resultSegments = mapFirstSegmentForResultSegments( + pagingMetadata.output.pageItems.path, + baseServiceMethod.response, + ); + const nextLinkSegments = mapFirstSegmentForResultSegments( + pagingMetadata.output.nextLink?.path, + baseServiceMethod.response, + ); + const continuationTokenResponseSegments = mapFirstSegmentForResultSegments( + pagingMetadata.output.continuationToken?.path, + baseServiceMethod.response, + ); + + baseServiceMethod.response.resultSegments = resultSegments?.map( + (resultSegment) => context.__modelPropertyCache.get(resultSegment)!, + ); + + context.__pagedResultSet.add(responseType); + // tcgc will let all paging method return a list of items + baseServiceMethod.response.type = diagnostics.pipe( + getClientTypeWithDiagnostics( + context, + pagingMetadata.output.pageItems.property.type, + operation, + ), + ); + + return diagnostics.wrap({ + ...baseServiceMethod, + kind: "paging", + pagingMetadata: { + __raw: pagingMetadata, + nextLinkSegments: nextLinkSegments?.map( + (segment) => + context.__responseHeaderCache.get(segment) ?? + context.__modelPropertyCache.get(segment)!, + ), + nextLinkVerb: getNextLinkVerb(context, operation), + continuationTokenParameterSegments: pagingMetadata.input.continuationToken?.path.map( + (r) => context.__methodParameterCache.get(r) ?? context.__modelPropertyCache.get(r)!, + ), + continuationTokenResponseSegments: continuationTokenResponseSegments?.map( + (segment) => + context.__responseHeaderCache.get(segment) ?? + context.__modelPropertyCache.get(segment)!, + ), + pageItemsSegments: baseServiceMethod.response.resultSegments, + pageSizeParameterSegments: getPageSizeParameterSegments(baseServiceMethod), + nextLinkReInjectedParametersSegments: undefined, + }, + }); + } else { + const markAsPageableInfo = getMarkAsPageable(context, operation); + if (markAsPageableInfo) { + const itemsProperty = diagnostics.pipe( + getSdkModelPropertyType(context, markAsPageableInfo.itemsProperty, operation), + ); + + // Set resultSegments to match the behavior of normal paging operations + baseServiceMethod.response.resultSegments = [itemsProperty]; + + if (responseType) { + context.__pagedResultSet.add(responseType); + } + // tcgc will let all paging method return a list of items + baseServiceMethod.response.type = diagnostics.pipe( + getClientTypeWithDiagnostics(context, markAsPageableInfo.itemsProperty.type, operation), + ); + + return diagnostics.wrap({ + ...baseServiceMethod, + kind: "paging", + pagingMetadata: { + __raw: undefined, // because in this case it is not a real paging operation + pageItemsSegments: baseServiceMethod.response.resultSegments, + }, + }); + } else { + compilerAssert(false, "Unexpected operation should be paged if calling this function"); + } + } +} + +function mapFirstSegmentForResultSegments( + resultSegments: ModelProperty[] | undefined, + response: SdkMethodResponse, +): ModelProperty[] | undefined { + if (resultSegments === undefined || response === undefined) return undefined; + // TCGC use Http response type as the return type + // For implicit body response, we need to locate the first segment in the response type + // Several cases: + // 1. `op test(): {items, nextLink}` + // 2. `op test(): {items, nextLink} & {a, b, c}` + // 3. `op test(): {@bodyRoot body: {items, nextLink}}` + const responseModel = + response.type?.kind === "model" + ? response.type + : response.type?.kind === "nullable" && response.type.type.kind === "model" + ? response.type.type + : undefined; + if (resultSegments.length > 0 && responseModel) { + for (let i = 0; i < resultSegments.length; i++) { + const segment = resultSegments[i]; + let current: SdkModelType | undefined = responseModel; + while (current) { + for (const property of current.properties ?? []) { + if ( + property.__raw && + findRootSourceProperty(property.__raw) === findRootSourceProperty(segment) + ) { + return [property.__raw, ...resultSegments.slice(i + 1)]; + } + } + current = current.baseModel; + } + } + } + return resultSegments; +} + +export function getPropertySegmentsFromModelOrParameters( + source: SdkModelType | SdkMethodParameter[], + predicate: (property: SdkMethodParameter | SdkModelPropertyType) => boolean, +): (SdkMethodParameter | SdkModelPropertyType)[] | undefined { + const queue: { model: SdkModelType; path: (SdkMethodParameter | SdkModelPropertyType)[] }[] = []; + + if (!Array.isArray(source)) { + if (source.baseModel) { + const baseResult = getPropertySegmentsFromModelOrParameters(source.baseModel, predicate); + if (baseResult) return baseResult; + } + } + + for (const prop of Array.isArray(source) ? source : source.properties.values()) { + if (predicate(prop)) { + return [prop]; + } + if (prop.type.kind === "model") { + queue.push({ model: prop.type, path: [prop] }); + } + } + + let queueIdx = 0; + while (queueIdx < queue.length) { + const { model, path } = queue[queueIdx++]; + for (const prop of model.properties.values()) { + if (predicate(prop)) { + return path.concat(prop); + } + if (prop.type.kind === "model") { + queue.push({ model: prop.type, path: path.concat(prop) }); + } + } + } + + return undefined; +} + + +function getSdkMethodResponse( + context: TCGCContext, + operation: Operation, + sdkOperation: SdkServiceOperation, + client: SdkClientType, +): SdkMethodResponse { + const responses = sdkOperation.responses; + + const allResponseBodies: SdkType[] = []; + let containsResponseWithoutBody = false; + responses.forEach((response) => { + if (response.type) { + allResponseBodies.push(response.type); + } else { + containsResponseWithoutBody = true; + } + }); + + const responseTypes = new Set(allResponseBodies.map((x) => getHashForType(x))); + let type: SdkType | undefined = undefined; + if (getResponseAsBool(context, operation)) { + type = getSdkBuiltInType(context, $(context.program).builtin.boolean); + } else { + if (responseTypes.size > 1) { + // return union of all the different types + type = { + __raw: operation, + kind: "union", + access: "public", + usage: UsageFlags.Output, + variantTypes: allResponseBodies, + name: createGeneratedName(context, operation, "UnionResponse"), + isGeneratedName: true, + namespace: client.namespace, + crossLanguageDefinitionId: `${getCrossLanguageDefinitionId(context, operation)}.UnionResponse`, + decorators: [], + }; + } else if (responseTypes.size === 1) { + type = allResponseBodies[0]; + } + } + + // Set optional property based on whether responses have bodies + // If type is undefined (no response), optional remains undefined + // For @responseAsBool, the boolean return is never optional — it's always true or false + let optional: boolean | undefined = undefined; + if (type !== undefined && !getResponseAsBool(context, operation)) { + // If we have a response type, set optional based on whether some responses lack bodies + optional = containsResponseWithoutBody; + } + + // Propagate stream metadata from HTTP responses to method response + let streamMetadata: SdkStreamMetadata | undefined; + for (const response of responses) { + if (response.streamMetadata) { + streamMetadata = response.streamMetadata; + break; + } + } + + return { + kind: "method", + type, + ...(optional !== undefined && { optional }), + ...(streamMetadata && { streamMetadata }), + }; +} + +export function getSdkBasicServiceMethod( + context: TCGCContext, + operation: Operation, + client: SdkClientType, +): [SdkServiceMethod, readonly Diagnostic[]] { + const diagnostics = createDiagnosticCollector(); + const methodParameters: SdkMethodParameter[] = []; + // we have to calculate apiVersions first, so that the information is put + // in __tspTypeToApiVersions before we call parameters since method wraps parameter + const apiVersions = getAvailableApiVersions( + context, + operation, + getActualClientType(client.__raw), + ); + + let clientParams = context.__clientParametersCache.get(client.__raw); + if (!clientParams) { + clientParams = []; + context.__clientParametersCache.set(client.__raw, clientParams); + } + + const override = getOverriddenClientMethod(context, operation); + const params = (override ?? operation).parameters.properties.values(); + + for (const param of params) { + if (isNeverOrVoidType(param.type)) continue; + // Skip parameters that are not in scope for this emitter + if (!isInScope(context, param)) continue; + const sdkMethodParam = diagnostics.pipe(getSdkMethodParameter(context, param, operation)); + if (sdkMethodParam.onClient) { + // add API version and subscription ID parameters to the client parameters + if (sdkMethodParam.isApiVersionParam) { + if (!clientParams.find((x) => x.isApiVersionParam)) { + clientParams.push(sdkMethodParam); + } + } else if (isSubscriptionId(context, param)) { + if (!clientParams.find((x) => isSubscriptionId(context, x))) { + clientParams.push(sdkMethodParam); + } + } + } else { + methodParameters.push(sdkMethodParam); + } + } + + const serviceOperation = diagnostics.pipe( + getSdkServiceOperation(context, operation, methodParameters, client), + ); + const response = getSdkMethodResponse(context, operation, serviceOperation, client); + const name = getLibraryName(context, operation); + return diagnostics.wrap({ + __raw: operation, + kind: "basic", + name, + access: getAccess(context, operation) ?? "public", + parameters: methodParameters, + doc: getClientDoc(context, operation), + summary: getSummary(context.program, operation), + operation: serviceOperation, + response, + apiVersions, + crossLanguageDefinitionId: getCrossLanguageDefinitionId(context, operation), + decorators: diagnostics.pipe(getTypeDecorators(context, operation)), + generateConvenient: shouldGenerateConvenient(context, operation), + generateProtocol: shouldGenerateProtocol(context, operation), + isOverride: override !== undefined, + }); +} + +function getSdkServiceMethod( + context: TCGCContext, + operation: Operation, + client: SdkClientType, +): [SdkServiceMethod, readonly Diagnostic[]] { + // `@disablePageable` disables paging even for operations with @list + const pagingDisabled = getDisablePageable(context, operation); + const paging = + !pagingDisabled && + (isList(context.program, operation) || getMarkAsPageable(context, operation)); + if (paging) { + return getSdkPagingServiceMethod(context, operation, client); + } + return getSdkBasicServiceMethod(context, operation, client); +} + +export function getSdkMethodParameter( + context: TCGCContext, + type: ModelProperty, + operation?: Operation, +): [SdkMethodParameter, readonly Diagnostic[]] { + const diagnostics = createDiagnosticCollector(); + + let property = context.__methodParameterCache?.get(type); + + if (!property) { + // for parameter that has elevated to client or parent client, we will use the client parameter directly + if (operation) { + const correspondingClientParam = getCorrespondingClientParam(context, type, operation); + if (correspondingClientParam) return diagnostics.wrap(correspondingClientParam); + } + + property = { + ...diagnostics.pipe(getSdkModelPropertyTypeBase(context, type, operation)), + kind: "method", + }; + + context.__methodParameterCache.set(type, property); + } + return diagnostics.wrap(property); +} + +export function createSdkMethods( + context: TCGCContext, + client: SdkClient, + sdkClientType: SdkClientType, +): [SdkMethod[], readonly Diagnostic[]] { + const diagnostics = createDiagnosticCollector(); + const retval: SdkMethod[] = []; + for (const operation of listOperationsInClient(context, client)) { + retval.push( + diagnostics.pipe(getSdkServiceMethod(context, operation, sdkClientType)), + ); + } + return diagnostics.wrap(retval); +} diff --git a/packages/http-client-generator-core/src/package.ts b/packages/http-client-generator-core/src/package.ts new file mode 100644 index 00000000000..3b91a402d09 --- /dev/null +++ b/packages/http-client-generator-core/src/package.ts @@ -0,0 +1,203 @@ +import { + createDiagnosticCollector, + Diagnostic, + getNamespaceFullName, + ignoreDiagnostics, +} from "@typespec/compiler"; + +import { prepareClientAndOperationCache } from "./cache.js"; +import { createSdkClientType } from "./clients.js"; +import { listClients } from "./decorators.js"; +import { + SdkClientType, + SdkEnumType, + SdkModelType, + SdkNamespace, + SdkNullableType, + SdkPackage, + SdkServiceOperation, + SdkType, + SdkUnionType, + TCGCContext, +} from "./interfaces.js"; +import { + filterApiVersionsWithDecorators, + getActualClientType, + getTypeDecorators, +} from "./internal-utils.js"; +import { getLicenseInfo } from "./license.js"; +import { getCrossLanguagePackageId, getNamespaceFromType } from "./public-utils.js"; +import { getAllReferencedTypes, handleAllTypes } from "./types.js"; + +export async function createSdkPackage( + context: TCGCContext, +): Promise<[SdkPackage, readonly Diagnostic[]]> { + const diagnostics = createDiagnosticCollector(); + populateApiVersionInformation(context); + diagnostics.pipe(handleAllTypes(context)); + const crossLanguagePackageId = diagnostics.pipe(getCrossLanguagePackageId(context)); + const allReferencedTypes = getAllReferencedTypes(context); + const versions = context.getPackageVersions(); + + // Create apiVersions map for multiple services + const apiVersionsMap = new Map(); + for (const [namespace, versionList] of versions.entries()) { + const fullName = getNamespaceFullName(namespace); + const latestVersion = versionList.at(-1); + if (latestVersion) { + // When apiVersion config is "all" for single service, store "all" in the map as well + const versionValue = + context.apiVersion === "all" && versions.size === 1 ? "all" : latestVersion; + apiVersionsMap.set(fullName, versionValue); + } + } + + const sdkPackage: SdkPackage = { + clients: listClients(context).map((c) => diagnostics.pipe(createSdkClientType(context, c))), + models: allReferencedTypes.filter((x): x is SdkModelType => x.kind === "model"), + enums: allReferencedTypes.filter((x): x is SdkEnumType => x.kind === "enum"), + unions: allReferencedTypes.filter( + (x): x is SdkUnionType | SdkNullableType => x.kind === "union" || x.kind === "nullable", + ), + crossLanguagePackageId, + crossLanguageVersion: "", // Placeholder, computed after package is fully built + namespaces: [], + licenseInfo: getLicenseInfo(context), + metadata: { + apiVersion: + context.apiVersion === "all" && versions.size === 1 + ? "all" + : versions.size === 1 + ? [...versions.values()][0].at(-1) + : undefined, + apiVersions: apiVersionsMap, + }, + }; + organizeNamespaces(context, sdkPackage); + + // Compute cross-language version hash from source files + sdkPackage.crossLanguageVersion = await computeCrossLanguageVersion(context); + + return diagnostics.wrap(sdkPackage); +} + +function organizeNamespaces( + context: TCGCContext, + sdkPackage: SdkPackage, +) { + const clients = [...sdkPackage.clients]; + let clientIdx = 0; + while (clientIdx < clients.length) { + const client = clients[clientIdx++]; + getSdkNamespace(context, sdkPackage, client).clients.push(client); + if (client.children && client.children.length > 0) { + clients.push(...client.children); + } + } + for (const model of sdkPackage.models) { + getSdkNamespace(context, sdkPackage, model).models.push(model); + } + for (const enumType of sdkPackage.enums) { + getSdkNamespace(context, sdkPackage, enumType).enums.push(enumType); + } + for (const unionType of sdkPackage.unions) { + getSdkNamespace(context, sdkPackage, unionType).unions.push(unionType); + } +} + +function getSdkNamespace( + context: TCGCContext, + sdkPackage: SdkPackage, + type: SdkType | SdkClientType, +) { + if (!("namespace" in type)) { + return sdkPackage; + } + + const namespace = type.namespace; + const segments = namespace.split("."); + let current: SdkPackage | SdkNamespace = sdkPackage; + let fullName = ""; + for (const segment of segments) { + fullName = fullName === "" ? segment : `${fullName}.${segment}`; + const ns: SdkNamespace | undefined = current.namespaces.find( + (ns) => ns.name === segment, + ); + if (ns === undefined) { + const rawNamespace = getNamespaceFromType(type.__raw); + const newNs = { + __raw: rawNamespace, + name: segment, + fullName, + clients: [], + models: [], + enums: [], + unions: [], + namespaces: [], + decorators: rawNamespace ? ignoreDiagnostics(getTypeDecorators(context, rawNamespace)) : [], + }; + current.namespaces.push(newNs); + current = newNs; + } else { + current = ns; + } + } + return current; +} + +function populateApiVersionInformation(context: TCGCContext): void { + if (context.__rawClientsCache === undefined) { + prepareClientAndOperationCache(context); + } + + // Get the package versions map once (this handles both single and multi-service scenarios) + const packageVersions = context.getPackageVersions(); + + for (const client of context.__rawClientsCache!.values()) { + const clientType = getActualClientType(client); + + // Multiple service case. Set empty result. + if (client.services.length > 1) { + context.setApiVersionsForType(clientType, []); + context.__clientApiVersionDefaultValueCache.set(client, undefined); + } else { + const versions = filterApiVersionsWithDecorators( + context, + clientType, + packageVersions.get(client.services[0]) || [], + ); + context.setApiVersionsForType(clientType, versions); + + context.__clientApiVersionDefaultValueCache.set(client, versions[versions.length - 1]); + } + } +} + +/** + * Computes a cross-language version hash from all API-affecting elements in the package. + * The hash is a SHA256 digest truncated to 12 hex characters. + * + * Creates a normalized API snapshot capturing: + * - Clients, methods, and parameters (with optionality and types) + * - Models and properties (with optionality and types) + * - Enums and their values + * - Unions + * - HTTP operation details (verb, path, parameter locations) + */ +async function computeCrossLanguageVersion(context: TCGCContext): Promise { + // Concatenate all source file contents + const content = [...context.program.sourceFiles.values()] + .filter((script) => { + const locationContext = context.program.getSourceFileLocationContext(script.file); + return locationContext.type === "project"; + }) + .map((script) => script.file.text) + .join(""); + + // Hash the combined content using Web Crypto API (browser-compatible) + const encoded = new TextEncoder().encode(content); + const hashBuffer = await globalThis.crypto.subtle.digest("SHA-256", encoded); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + const hex = hashArray.map((b) => b.toString(16).padStart(2, "0")).join(""); + return hex.substring(0, 12); +} diff --git a/packages/http-client-generator-core/src/public-utils.ts b/packages/http-client-generator-core/src/public-utils.ts new file mode 100644 index 00000000000..f13e13abbb9 --- /dev/null +++ b/packages/http-client-generator-core/src/public-utils.ts @@ -0,0 +1,701 @@ +import { + Diagnostic, + Enum, + EnumMember, + Interface, + Model, + ModelProperty, + Namespace, + Operation, + Scalar, + Type, + Union, + UnionVariant, + createDiagnosticCollector, + getEffectiveModelType, + getFriendlyName, + getNamespaceFullName, + ignoreDiagnostics, + isGlobalNamespace, + isService, + resolveEncodedName, +} from "@typespec/compiler"; +import { + HttpOperation, + Visibility, + getHttpOperation, + getServers, + isMetadata, + isVisible, +} from "@typespec/http"; +import { getOperationId } from "@typespec/openapi"; +import { Version, getVersions } from "@typespec/versioning"; +import { pascalCase } from "change-case"; +import { getClientLocation, getClientNameOverride, getIsApiVersion } from "./decorators.js"; +import { + DecoratedType, + SdkBodyParameter, + SdkClient, + SdkClientType, + SdkCookieParameter, + SdkHeaderParameter, + SdkHttpOperation, + SdkHttpOperationExample, + SdkMethodParameter, + SdkModelPropertyType, + SdkPathParameter, + SdkQueryParameter, + SdkServiceMethod, + SdkServiceOperation, + SdkType, + TCGCContext, +} from "./interfaces.js"; +import { + AllScopes, + ContextNode, + TspLiteralType, + hasNoneVisibility, + listAllUserDefinedNamespaces, + removeVersionsLargerThanExplicitlySpecified, + resolveDuplicateGenearatedName, +} from "./internal-utils.js"; + +/** + * Return the default api version for a versioned service. Will return undefined if one does not exist + * @param program + * @param serviceNamespace + * @returns + */ +export function getDefaultApiVersion( + context: TCGCContext, + serviceNamespace: Namespace, +): Version | undefined { + try { + const versions = getVersions(context.program, serviceNamespace)[1]!.getVersions(); + removeVersionsLargerThanExplicitlySpecified(context, versions); + // follow versioning principals of the versioning library and return last in list + return versions[versions.length - 1]; + } catch (e) { + return undefined; + } +} + +/** + * Return whether a parameter is the Api Version parameter of a client + * @param program + * @param parameter + * @returns + */ +export function isApiVersion(context: TCGCContext, type: ModelProperty): boolean { + // author's customization is the highest priority + const override = getIsApiVersion(context, type); + if (override !== undefined) { + return override; + } + // if the service is not versioning, then no api version parameter + const versionEnumSets = [...context.getPackageVersionEnum().values()]; + if (versionEnumSets.length === 0) { + return false; + } + // if the parameter type is the version enum, then it is api version + if (versionEnumSets.some((versionEnum) => type.type === versionEnum)) { + return true; + } + // otherwise, only consider name-based matching for http metadata parameters + // (header/query/path/cookie/statusCode) or server URL template parameters. + // A regular body model property whose name happens to be `apiVersion`/`api-version` + // should not be treated as an api version parameter. + if (!isMetadata(context.program, type) && !isServerUrlTemplateParam(context, type)) { + return false; + } + return ( + type.name.toLowerCase().includes("apiversion") || + type.name.toLowerCase().includes("api-version") + ); +} + +/** + * Return whether a model property is a server URL template parameter (i.e., a + * path-segment variable declared in the `@server` decorator's parameter model). + * These parameters are not annotated with HTTP metadata decorators, but they + * represent URL template variables and should still be eligible for API-version + * name matching. + */ +function isServerUrlTemplateParam(context: TCGCContext, type: ModelProperty): boolean { + for (const ns of listAllServiceNamespaces(context)) { + const servers = getServers(context.program, ns); + if (servers) { + for (const server of servers) { + for (const param of server.parameters.values()) { + if (param === type) { + return true; + } + } + } + } + } + return false; +} + +/** + * If the given type is an anonymous model, returns a named model with same shape. + * The finding logic will ignore all the properties of header/query/path/status-code metadata, + * as well as the properties that are not visible in the given visibility if provided. + * If the model found is also anonymous, the input type is returned unchanged. + * + * @param context + * @param type + * @returns + */ +export function getEffectivePayloadType( + context: TCGCContext, + type: Model, + visibility?: Visibility, +): Model { + const program = context.program; + + // if a type has name, we should resolve the name + // this logic is for template cases, for e.g., + // model Catalog is TrackedResource{} + // model Deployment is TrackedResource{} + // when pass them to getEffectiveModelType, we will get two different types + // with the same name "TrackedResource" which will loose original infomation + if (type.name) { + return type; + } + const effective = getEffectiveModelType( + program, + type, + (t) => + !isMetadata(context.program, t) && + !hasNoneVisibility(context, t) && + (visibility === undefined || isVisible(program, t, visibility)), + ); + if (effective.name) { + return effective; + } + return type; +} + +/** + * Get the library and wire name of a model property. Takes `@clientName` and `@encodedName` into account + * @param context + * @param property + * @returns a tuple of the library and wire name for a model property + */ +export function getPropertyNames(context: TCGCContext, property: ModelProperty): [string, string] { + return [getLibraryName(context, property), getWireName(context, property)]; +} + +/** + * Get the library name of a property / parameter / operation / model / enum. Takes projections into account + * + * Returns name in the following order of priority + * 1. language emitter name, i.e. @clientName("csharpSpecificName", "csharp") => "csharpSpecificName" + * 2. client name, i.e. @clientName(""clientName") => "clientName" + * 3. deprecated projected name + * 4. friendly name, i.e. @friendlyName("friendlyName") => "friendlyName" + * 5. name in typespec + * + * @param context + * @param type + * @returns the library name for a typespec type + */ +export function getLibraryName( + context: TCGCContext, + type: Type & { name?: string | symbol }, + scope?: string | typeof AllScopes, +): string { + // 1. check if there's a client name + const emitterSpecificName = getClientNameOverride(context, type, scope); + if (emitterSpecificName && emitterSpecificName !== type.name) return emitterSpecificName; + + // 2. check if there's a friendly name, if so return friendly name + const friendlyName = getFriendlyName(context.program, type); + if (friendlyName) return friendlyName; + + // 3. if type is derived from template and name is the same as template, add template parameters' name as suffix + if ( + typeof type.name === "string" && + type.name !== "" && + (type.kind === "Model" || type.kind === "Union") && + type.templateMapper?.args + ) { + const generatedName = context.__generatedNames.get(type); + if (generatedName) return generatedName; + return resolveDuplicateGenearatedName( + context, + type, + type.name + + type.templateMapper.args + .filter( + (arg): arg is Model | Enum => + "kind" in arg && + (arg.kind === "Model" || arg.kind === "Enum" || arg.kind === "Union") && + arg.name !== undefined && + arg.name.length > 0, + ) + .map((arg) => pascalCase(arg.name)) + .join(""), + ); + } + + return typeof type.name === "string" ? type.name : ""; +} + +/** + * Get the serialized name of a type. + * @param context + * @param type + * @returns + */ +export function getWireName(context: TCGCContext, type: Type & { name: string }) { + const encodedName = resolveEncodedName(context.program, type, "application/json"); + if (encodedName !== type.name) return encodedName; + return type.name; +} + +/** + * Helper function to return cross language definition id for a type + * @param type + * @returns + */ +export function getCrossLanguageDefinitionId( + context: TCGCContext, + type: + | Union + | Model + | Enum + | Scalar + | ModelProperty + | Operation + | Namespace + | Interface + | EnumMember + | UnionVariant, + operation?: Operation, + appendNamespace: boolean = true, +): string { + let retval: string = typeof type.name === "symbol" ? "anonymous" : type.name || "anonymous"; + let namespace = + type.kind === "ModelProperty" + ? type.model?.namespace + : type.kind === "EnumMember" + ? type.enum?.namespace + : type.kind === "UnionVariant" + ? type.union?.namespace + : type.namespace; + switch (type.kind) { + // Enum and Scalar will always have a name + case "Union": + case "Model": + if (type.name) { + break; + } + // Use the naming context stack to determine the path for this anonymous type + const contextPath = [...context.__namingContextPath]; + const namingPart = contextPath.slice(findLastNonAnonymousNode(contextPath)); + if ( + namingPart[0]?.type?.kind === "Model" || + namingPart[0]?.type?.kind === "Union" || + namingPart[0]?.type?.kind === "Operation" + ) { + namespace = namingPart[0]?.type?.namespace; + } + retval = + namingPart + .map((x) => { + if (x.type?.kind === "Model" || x.type?.kind === "Union") { + const name = x.type.name; + return typeof name === "symbol" ? x.name : name || x.name; + } + return x.name || "anonymous"; + }) + .join(".") + + "." + + retval; + break; + case "ModelProperty": + if (type.model) { + // operation parameter case + if (type.model === operation?.parameters) { + retval = `${getCrossLanguageDefinitionId(context, operation, undefined, false)}.${retval}`; + } else { + // Use cached SDK model's crossLanguageDefinitionId if available to avoid stack context issues + const cachedSdkModel = context.__referencedTypeCache.get(type.model); + if (cachedSdkModel?.crossLanguageDefinitionId) { + // Cached ID already includes namespace, return directly + return `${cachedSdkModel.crossLanguageDefinitionId}.${retval}`; + } + retval = `${getCrossLanguageDefinitionId(context, type.model, operation, false)}.${retval}`; + } + } + break; + case "Operation": + if (type.interface) { + retval = `${getCrossLanguageDefinitionId(context, type.interface, undefined, false)}.${retval}`; + } + break; + case "EnumMember": + if (type.enum) { + retval = `${getCrossLanguageDefinitionId(context, type.enum, operation, false)}.${retval}`; + } + break; + case "UnionVariant": + if (type.union) { + retval = `${getCrossLanguageDefinitionId(context, type.union, operation, false)}.${retval}`; + } + break; + } + if (appendNamespace && namespace && getNamespaceFullName(namespace)) { + retval = `${getNamespaceFullName(namespace)}.${retval}`; + } + return retval; +} + +/** + * Helper function return the cross langauge package id for a package + */ +export function getCrossLanguagePackageId(context: TCGCContext): [string, readonly Diagnostic[]] { + const diagnostics = createDiagnosticCollector(); + const serviceNamespaces = listAllServiceNamespaces(context); + if (serviceNamespaces.length === 0) return diagnostics.wrap(""); + return diagnostics.wrap(getNamespaceFullName(serviceNamespaces[0])); +} + +/** + * Create a name for anonymous model + * @param context + * @param type + */ +export function getGeneratedName( + context: TCGCContext, + type: Model | Union | TspLiteralType, + _operation?: Operation, +): string { + const generatedName = context.__generatedNames.get(type); + if (generatedName) return generatedName; + + // Use the naming context stack to determine the path for this anonymous type + const contextPath = [...context.__namingContextPath]; + const createdName = buildNameFromContextPaths(context, type, contextPath); + return createdName; +} + +function findLastNonAnonymousNode(contextPath: ContextNode[]): number { + let lastNonAnonymousModelNodeIndex = contextPath.length - 1; + while (lastNonAnonymousModelNodeIndex >= 0) { + const currType = contextPath[lastNonAnonymousModelNodeIndex].type; + // If type is undefined, treat as anonymous (continue looking) + if ( + currType && + (currType.kind === "Model" || currType.kind === "Union" || currType.kind === "Operation") && + currType.name + ) { + // it's non anonymous node + break; + } else { + --lastNonAnonymousModelNodeIndex; + } + } + return lastNonAnonymousModelNodeIndex; +} + +/** + * The logic is basically three steps: + * 1. find the last nonanonymous model node, this node can be operation node or model node which is not anonymous + * 2. build the name from the last nonanonymous model node to the end of the path + * 3. simplely handle duplication with adding number suffix + * @param contextPaths + * @returns + */ +function buildNameFromContextPaths( + context: TCGCContext, + type: Union | Model | TspLiteralType, + contextPath: ContextNode[], +): string { + // fallback: when no context path, use "Anonymous" + type kind with deduplicating suffix + if (contextPath.length === 0) { + return resolveDuplicateGenearatedName(context, type, `Anonymous${type.kind}`); + } + + // 1. find the last non-anonymous model node + const lastNonAnonymousNodeIndex = findLastNonAnonymousNode(contextPath); + // 2. build name + // When all nodes are anonymous (e.g. types inside orphan unions), lastNonAnonymousNodeIndex is -1. + // Use 0 as the start index to avoid accessing contextPath[-1]. + let createName: string = ""; + for (let j = Math.max(0, lastNonAnonymousNodeIndex); j < contextPath.length; j++) { + const currContextPathType = contextPath[j]?.type; + if ( + currContextPathType?.kind === "String" || + currContextPathType?.kind === "Number" || + currContextPathType?.kind === "Boolean" + ) { + // constant type + createName = `${createName}${pascalCase(contextPath[j].name)}`; + } else if (!currContextPathType?.name || currContextPathType.kind === "Operation") { + // is anonymous node or operation node + createName = `${createName}${pascalCase(contextPath[j].name)}`; + } else { + // is non-anonymous node, use type name + createName = `${createName}${currContextPathType!.name!}`; + } + } + // 3. simplely handle duplication + createName = resolveDuplicateGenearatedName(context, type, createName); + return createName; +} + +export function getHttpOperationWithCache( + context: TCGCContext, + operation: Operation, +): HttpOperation { + if (context.__httpOperationCache?.has(operation)) { + return context.__httpOperationCache.get(operation)!; + } + const httpOperation = ignoreDiagnostics(getHttpOperation(context.program, operation)); + context.__httpOperationCache!.set(operation, httpOperation); + return httpOperation; +} + +/** + * Get the examples for a given http operation. + */ +export function getHttpOperationExamples( + context: TCGCContext, + operation: HttpOperation, +): SdkHttpOperationExample[] { + return context.__httpOperationExamples.get(operation) ?? []; +} + +/** + * Judge whether a type is a paged result model. + * + * @param context TCGC context + * @param t Any TCGC types + * @returns + */ +export function isPagedResultModel(context: TCGCContext, t: SdkType): boolean { + return context.__pagedResultSet.has(t); +} + +/** + * Find corresponding http parameter list for a client initialization parameter, a service method parameter or a property of a service method parameter. + * + * @param method + * @param param + * @returns + */ +export function getHttpOperationParameter( + method: SdkServiceMethod, + param: SdkMethodParameter | SdkModelPropertyType, +): + | SdkPathParameter + | SdkQueryParameter + | SdkHeaderParameter + | SdkCookieParameter + | SdkBodyParameter + | SdkModelPropertyType + | undefined { + const operation = method.operation; + // BFS to find the corresponding http parameter. + // An http parameter will be mapped to a method/client parameter, several method/client parameters (body spread case), or one property of a method property (metadata on property case). + // So, when we try to find which http parameter a parameter or property corresponds to, we compare the `correspondingMethodParams` list directly. + // If a method parameter is spread case, then we need to find the cooresponding http body parameter's property. + for (const p of operation.parameters) { + // eslint-disable-next-line @typescript-eslint/no-deprecated + for (const cp of p.correspondingMethodParams) { + if (cp === param) { + return p; + } + } + } + if (operation.bodyParam) { + // eslint-disable-next-line @typescript-eslint/no-deprecated + for (const cp of operation.bodyParam.correspondingMethodParams) { + if (cp === param) { + if (operation.bodyParam.type.kind === "model" && operation.bodyParam.type !== param.type) { + return operation.bodyParam.type.properties.find( + (p) => p.kind === "property" && p.name === param.name, + ) as SdkModelPropertyType | undefined; + } + return operation.bodyParam; + } + } + } + return undefined; +} + +/** + * Find corresponding http parameter list for a client initialization parameter. + * + * @param method + * @param param + * @returns + */ +export function getHttpOperationParametersForClientParameter( + client: SdkClientType, + param: SdkMethodParameter | SdkModelPropertyType, +): ( + | SdkPathParameter + | SdkQueryParameter + | SdkHeaderParameter + | SdkCookieParameter + | SdkBodyParameter + | SdkModelPropertyType +)[] { + const result = []; + for (const method of client.methods) { + const httpParam = getHttpOperationParameter(method, param); + if (httpParam) { + result.push(httpParam); + } + } + return result; +} + +/** + * Currently, listServices can only be called from a program instance. This doesn't work well if we're doing mutation, + * because we want to just mutate the global namespace once, then find all of the services in the program, since we aren't + * able to explicitly tell listServices to iterate over our specific mutated global namespace. We're going to use this function + * instead to list all of the services in the global namespace. + * + * See https://github.com/microsoft/typespec/issues/6247 + * + * @param context + */ +export function listAllServiceNamespaces(context: TCGCContext): Namespace[] { + const serviceNamespaces: Namespace[] = []; + for (const ns of listAllUserDefinedNamespaces(context)) { + if (isService(context.program, ns)) { + serviceNamespaces.push(ns); + } + } + return serviceNamespaces; +} + +/** + * Calculate the operation ID for a given operation. + * + * @param context TCGC context + * @param operation + * @param honorRenaming + * @returns + */ +export function resolveOperationId( + context: TCGCContext, + operation: Operation, + honorRenaming: boolean = false, +) { + const { program } = context; + // if @operationId was specified use that value + const explicitOperationId = getOperationId(program, operation); + if (explicitOperationId) { + return explicitOperationId; + } + + const operationName = honorRenaming ? getLibraryName(context, operation) : operation.name; + + let operationInterface: Interface | undefined = operation.interface; + let operationNamespace: Namespace | undefined = operation.namespace; + + const clientLocation = getClientLocation(context, operation); + + if (clientLocation) { + if (typeof clientLocation === "string") { + return `${clientLocation}_${operationName}`; + } + if (clientLocation.kind === "Interface") { + operationInterface = clientLocation; + } else { + operationInterface = undefined; + operationNamespace = clientLocation; + } + } + + if (operationInterface) { + return `${honorRenaming ? getLibraryName(context, operationInterface) : operationInterface.name}_${operationName}`; + } + if ( + operationNamespace === undefined || + isGlobalNamespace(program, operationNamespace) || + isService(program, operationNamespace) + ) { + return operationName; + } + + return `${honorRenaming ? getLibraryName(context, operationNamespace) : operationNamespace.name}_${operationName}`; +} + +/** + * Get the path of a client in the client hierarchy. + * For root clients, this returns just the client name. + * For sub clients, this returns the full path like "RootClient.SubClient.NestedClient". + * + * @param client The SdkClientType to get the path for + * @returns The client path string + */ +export function getClientPath( + client: SdkClientType, +): string { + const parts: string[] = [client.name]; + let current = client.parent; + while (current) { + parts.unshift(current.name); + current = current.parent; + } + return parts.join("."); +} + +/** + * Judge whether a model's property is an HTTP metadata. + * @param context TCGC context + * @param property + * @returns + */ +export function isHttpMetadata(context: TCGCContext, property: SdkModelPropertyType): boolean { + return property.__raw !== undefined && isMetadata(context.program, property.__raw); +} + +export function getNamespaceFromType(type: Type | SdkClient | undefined): Namespace | undefined { + if (type === undefined) { + return undefined; + } + if (type.kind === "SdkClient") { + const rawType = type.type; + if (rawType === undefined) { + return undefined; + } + if (rawType.kind === "Namespace") { + return rawType; + } + return rawType.namespace; + } + if ("namespace" in type) { + return type.namespace; + } + return undefined; +} + +const CLIENT_OPTION_DECORATOR_NAME = "TypeSpec.ClientGenerator.Core.@clientOption"; + +/** + * Get the value of a client option by key from a decorated SDK type. + * + * @param type - A decorated SDK type (model, enum, operation, property, client, namespace, etc.) + * @param key - The name of the client option to look up + * @returns The option value, or `undefined` if the option is not set + * + * @example + * ```typescript + * const sdkModel = context.sdkPackage.models.find(m => m.name === "MyModel"); + * const value = getClientOptions(sdkModel, "enableFeatureFoo"); + * ``` + */ +export function getClientOptions(type: T, key: string): unknown { + const option = type.decorators + .filter((d) => d.name === CLIENT_OPTION_DECORATOR_NAME) + .find((d) => d.arguments.name === key); + return option?.arguments.value; +} diff --git a/packages/http-client-generator-core/src/rules/no-unnamed-types.rule.ts b/packages/http-client-generator-core/src/rules/no-unnamed-types.rule.ts new file mode 100644 index 00000000000..2f9ef828e2e --- /dev/null +++ b/packages/http-client-generator-core/src/rules/no-unnamed-types.rule.ts @@ -0,0 +1,102 @@ +import { createRule, Model, paramMessage, Union } from "@typespec/compiler"; +import { createTCGCContext } from "../context.js"; +import { + isSdkBuiltInKind, + SdkEnumType, + SdkModelType, + SdkNullableType, + SdkType, + SdkUnionType, + UsageFlags, +} from "../interfaces.js"; +import { handleAllTypes } from "../types.js"; + +export const noUnnamedTypesRule = createRule({ + name: "no-unnamed-types", + description: "Requires types to be named rather than defined anonymously or inline.", + severity: "warning", + url: "https://azure.github.io/typespec-azure/docs/libraries/typespec-client-generator-core/rules/no-unnamed-types", + messages: { + default: paramMessage`Anonymous ${"type"} with generated name "${"generatedName"}" detected. Define this ${"type"} separately with a proper name to improve code readability and reusability.`, + }, + create(context) { + const tcgcContext = createTCGCContext( + context.program, + "@azure-tools/typespec-client-generator-core", + { + mutateNamespace: false, + }, + ); + // Run the type-handling pass to populate __referencedTypeCache so we can + // determine which types are referenced and how they are used in the final output. + handleAllTypes(tcgcContext); + return { + model: (model: Model) => { + const createdModel = tcgcContext.__referencedTypeCache.get(model); + if ( + createdModel && + createdModel.kind === "model" && + createdModel.properties.length > 0 && + createdModel.usage !== UsageFlags.None && + (createdModel.usage & UsageFlags.LroInitial) === 0 && + (createdModel.usage & UsageFlags.MultipartFormData) === 0 && + createdModel.isGeneratedName + ) { + context.reportDiagnostic({ + target: model, + format: { + type: "model", + generatedName: createdModel.name, + }, + }); + } + }, + union: (union: Union) => { + const createdUnion = tcgcContext.__referencedTypeCache.get(union); + const unionToCheck = getUnionType(createdUnion); + if ( + unionToCheck && + unionToCheck.usage !== UsageFlags.None && + unionToCheck.isGeneratedName && + !allVariantsBuiltIn(unionToCheck) + ) { + // report diagnostic for unions and nullable unions + context.reportDiagnostic({ + target: union, + format: { + type: "union", + generatedName: unionToCheck.name, + }, + }); + } + }, + }; + }, +}); + +function getUnionType( + union: SdkModelType | SdkEnumType | SdkNullableType | SdkUnionType | undefined, +): SdkModelType | SdkEnumType | SdkNullableType | SdkUnionType | undefined { + if (!union) { + return undefined; + } + if (union.kind === "nullable") { + const inner = union.type; + if (inner.kind === "union" || inner.kind === "model" || inner.kind === "enum") { + return inner; + } + return undefined; + } + return union; +} + +function allVariantsBuiltIn( + union: SdkModelType | SdkEnumType | SdkNullableType | SdkUnionType, +): boolean { + if (union.kind !== "union") { + return false; + } + return union.variantTypes.every((variant) => { + return isSdkBuiltInKind(variant.kind); + }); +} diff --git a/packages/http-client-generator-core/src/rules/property-name-conflict.rule.ts b/packages/http-client-generator-core/src/rules/property-name-conflict.rule.ts new file mode 100644 index 00000000000..bbd30311176 --- /dev/null +++ b/packages/http-client-generator-core/src/rules/property-name-conflict.rule.ts @@ -0,0 +1,37 @@ +import { ModelProperty, createRule, paramMessage } from "@typespec/compiler"; +import { createTCGCContext } from "../context.js"; +import { getLibraryName } from "../public-utils.js"; + +export const propertyNameConflictRule = createRule({ + name: "property-name-conflict", + description: "Avoid naming conflicts between a property and a model of the same name.", + severity: "warning", + url: "https://azure.github.io/typespec-azure/docs/libraries/typespec-client-generator-core/rules/property-name-conflict", + messages: { + default: paramMessage`Property '${"propertyName"}' having the same name as its enclosing model will cause problems with C# code generation. Consider renaming the property directly or using the @clientName("newName", "csharp") decorator to rename the property for C#.`, + }, + create(context) { + const tcgcContext = createTCGCContext( + context.program, + "@azure-tools/typespec-client-generator-core", + { + mutateNamespace: false, + }, + ); + return { + modelProperty: (property: ModelProperty) => { + const model = property.model; + if (!model) return; + const modelName = getLibraryName(tcgcContext, model, "csharp").toLocaleLowerCase(); + const propertyName = getLibraryName(tcgcContext, property, "csharp").toLocaleLowerCase(); + if (propertyName === modelName) { + context.reportDiagnostic({ + format: { propertyName }, + target: property, + }); + } + return; + }, + }; + }, +}); diff --git a/packages/http-client-generator-core/src/rules/require-client-suffix.rule.ts b/packages/http-client-generator-core/src/rules/require-client-suffix.rule.ts new file mode 100644 index 00000000000..004be75ad3b --- /dev/null +++ b/packages/http-client-generator-core/src/rules/require-client-suffix.rule.ts @@ -0,0 +1,46 @@ +import { createRule, Interface, Namespace, paramMessage } from "@typespec/compiler"; +import { createTCGCContext } from "../context.js"; +import { getClient } from "../decorators.js"; + +export const requireClientSuffixRule = createRule({ + name: "require-client-suffix", + description: "Client names should end with 'Client'.", + severity: "warning", + url: "https://azure.github.io/typespec-azure/docs/libraries/typespec-client-generator-core/rules/require-client-suffix", + messages: { + default: paramMessage`Client name "${"name"}" must end with Client. Use @client({name: "...Client"}`, + }, + create(context) { + const tcgcContext = createTCGCContext( + context.program, + "@azure-tools/typespec-client-generator-core", + { + mutateNamespace: false, + }, + ); + return { + namespace: (namespace: Namespace) => { + const sdkClient = getClient(tcgcContext, namespace); + if (sdkClient && sdkClient.parent === undefined && !sdkClient.name.endsWith("Client")) { + context.reportDiagnostic({ + target: namespace, + format: { + name: sdkClient.name, + }, + }); + } + }, + interface: (interfaceType: Interface) => { + const sdkClient = getClient(tcgcContext, interfaceType); + if (sdkClient && sdkClient.parent === undefined && !sdkClient.name.endsWith("Client")) { + context.reportDiagnostic({ + target: interfaceType, + format: { + name: sdkClient.name, + }, + }); + } + }, + }; + }, +}); diff --git a/packages/http-client-generator-core/src/testing/index.ts b/packages/http-client-generator-core/src/testing/index.ts new file mode 100644 index 00000000000..74082f8780d --- /dev/null +++ b/packages/http-client-generator-core/src/testing/index.ts @@ -0,0 +1,6 @@ +import { createTestLibrary } from "@typespec/compiler/testing"; + +export const HttpClientGeneratorCoreTestLibrary = createTestLibrary({ + name: "@typespec/http-client-generator-core", + packageRoot: new URL("../../../", import.meta.url).href, +}); diff --git a/packages/http-client-generator-core/src/tsp-index.ts b/packages/http-client-generator-core/src/tsp-index.ts new file mode 100644 index 00000000000..083095acbaa --- /dev/null +++ b/packages/http-client-generator-core/src/tsp-index.ts @@ -0,0 +1,82 @@ +import { + $access, + $alternateType, + $apiVersion, + $client, + $clientApiVersions, + $clientDefaultValue, + $clientDoc, + $clientInitialization, + $clientLocation, + $clientName, + $clientNamespace, + $clientOption, + $convenientAPI, + $deserializeEmptyStringAsNull, + $disablePageable, + $flattenProperty, + $legacyHierarchyBuilding, + $markAsLro, + $markAsPageable, + $nextLinkVerb, + $operationGroup, + $override, + $paramAlias, + $protocolAPI, + $responseAsBool, + $scope, + $usage, + $useSystemTextJsonConverter, +} from "./decorators.js"; +import { addParameter, removeParameter, reorderParameters, replaceParameter } from "./functions.js"; + +export { $lib } from "./lib.js"; +export { $onValidate } from "./validate.js"; + +/** @internal */ +export const $decorators = { + "TypeSpec.ClientGenerator.Core": { + clientName: $clientName, + convenientAPI: $convenientAPI, + protocolAPI: $protocolAPI, + client: $client, + // eslint-disable-next-line @typescript-eslint/no-deprecated + operationGroup: $operationGroup, + usage: $usage, + access: $access, + override: $override, + useSystemTextJsonConverter: $useSystemTextJsonConverter, + clientInitialization: $clientInitialization, + paramAlias: $paramAlias, + apiVersion: $apiVersion, + clientNamespace: $clientNamespace, + alternateType: $alternateType, + scope: $scope, + clientApiVersions: $clientApiVersions, + deserializeEmptyStringAsNull: $deserializeEmptyStringAsNull, + responseAsBool: $responseAsBool, + clientDoc: $clientDoc, + clientLocation: $clientLocation, + clientOption: $clientOption, + }, + + "TypeSpec.ClientGenerator.Core.Legacy": { + hierarchyBuilding: $legacyHierarchyBuilding, + flattenProperty: $flattenProperty, + markAsLro: $markAsLro, + markAsPageable: $markAsPageable, + disablePageable: $disablePageable, + nextLinkVerb: $nextLinkVerb, + clientDefaultValue: $clientDefaultValue, + }, +}; + +/** @internal */ +export const $functions: Record> = { + "TypeSpec.ClientGenerator.Core": { + replaceParameter: replaceParameter, + removeParameter: removeParameter, + addParameter: addParameter, + reorderParameters: reorderParameters, + }, +}; diff --git a/packages/http-client-generator-core/src/types.ts b/packages/http-client-generator-core/src/types.ts new file mode 100644 index 00000000000..32cec90ad22 --- /dev/null +++ b/packages/http-client-generator-core/src/types.ts @@ -0,0 +1,2748 @@ +import { + BooleanLiteral, + Diagnostic, + EncodeData, + Enum, + EnumMember, + IntrinsicScalarName, + IntrinsicType, + Model, + ModelProperty, + Namespace, + NumericLiteral, + Operation, + Scalar, + StringLiteral, + Tuple, + Type, + Union, + createDiagnosticCollector, + getDiscriminator, + getEncode, + getLifecycleVisibilityEnum, + getSummary, + getVisibilityForClass, + ignoreDiagnostics, + isArrayModelType, + isErrorModel, + isNeverType, + isTemplateDeclaration, + resolveEncodedName, +} from "@typespec/compiler"; +import { + Authentication, + HttpOperationFileBody, + HttpOperationMultipartBody, + HttpPayloadBody, + Visibility, + getAuthentication, + getServers, + isHeader, + isOrExtendsHttpFile, + isStatusCode, +} from "@typespec/http"; +import { getStreamMetadata } from "@typespec/http/experimental"; +import { getStreamOf, isStream } from "@typespec/streams"; +import { + getAccess, + getAccessOverride, + getAlternateType, + getClientDefaultValue, + getClientNamespace, + getExplicitClientApiVersions, + getLegacyHierarchyBuilding, + getOverriddenClientMethod, + getUsageOverride, + isInScope, + listClients, + listOperationsInClient, + listSubClients, + shouldFlattenProperty, + shouldGenerateConvenient, +} from "./decorators.js"; +import { + AccessFlags, + ArrayKnownEncoding, + SdkArrayType, + SdkBuiltInKinds, + SdkBuiltInType, + SdkClientType, + SdkConstantType, + SdkCredentialParameter, + SdkCredentialType, + SdkDateTimeType, + SdkDictionaryType, + SdkDurationType, + SdkEnumType, + SdkEnumValueType, + SdkHeaderParameter, + SdkHttpOperation, + SdkModelPropertyType, + SdkModelPropertyTypeBase, + SdkModelType, + SdkNullableType, + SdkTupleType, + SdkType, + SdkUnionType, + TCGCContext, + UsageFlags, + isSdkIntKind, +} from "./interfaces.js"; +import { + ContextNode, + createGeneratedName, + filterPreviewVersion, + getAvailableApiVersions, + getClientDoc, + getHttpBodyType, + getHttpOperationResponseHeaders, + getNonNullOptions, + getNullOption, + getSdkTypeBaseHelper, + getTypeDecorators, + hasNoneVisibility, + intOrFloat, + isHttpBodySpread, + isNeverOrVoidType, + isOnClient, + listOrphanTypes, + resolveConflictGeneratedName, + updateWithApiVersionInformation, +} from "./internal-utils.js"; +import { createDiagnostic } from "./lib.js"; +import { + getCrossLanguageDefinitionId, + getEffectivePayloadType, + getGeneratedName, + getHttpOperationWithCache, + getLibraryName, + getPropertyNames, +} from "./public-utils.js"; + +import { $ } from "@typespec/compiler/typekit"; +import { getNs, isAttribute, isUnwrapped } from "@typespec/xml"; +import { pascalCase } from "change-case"; +import pluralize from "pluralize"; +import { getSdkHttpParameter } from "./http.js"; +import { isMediaTypeJson, isMediaTypeTextPlain, isMediaTypeXml } from "./media-types.js"; + +/** + * Represents a union that can be treated as an enum. + * This is a local definition for union-as-enum conversion. + */ +interface UnionEnumVariant { + value: T; + type: import("@typespec/compiler").UnionVariant | EnumMember; +} + +interface UnionEnumBase { + kind: K; + union: Union; + members: Map>; + include: UnionEnumBase; + flattenedMembers: Map>; + open: boolean; + nullable: boolean; +} + +export type UnionEnum = UnionEnumBase<"string", string> | UnionEnumBase<"number", number>; + +/** + * Stub for getUnionAsEnum. In the core package this is a no-op. + * The Azure wrapper package provides the full implementation. + */ +function getUnionAsEnum( + union: Union, +): [UnionEnum | undefined, readonly import("@typespec/compiler").Diagnostic[]] { + return getUnionAsEnumInternal(union, new Set()); +} + +function getUnionAsEnumInternal( + union: Union, + visited: Set, +): [UnionEnum | undefined, readonly import("@typespec/compiler").Diagnostic[]] { + if (visited.has(union)) { + return [undefined, []]; + } + visited.add(union); + const diagnostics = createDiagnosticCollector(); + + const kinds = new Set(); + const members = new Map>(); + const flattenedMembers = new Map>(); + let open = false; + let nullable = false; + + for (const variant of union.variants.values()) { + switch (variant.type.kind) { + case "Intrinsic": + if (variant.type.name === "null") { + nullable = true; + } else { + return diagnostics.wrap(undefined); + } + break; + case "String": + kinds.add("string"); + members.set(variant.name, { value: variant.type.value, type: variant }); + break; + case "Number": + kinds.add("number"); + members.set(variant.name, { value: variant.type.value, type: variant }); + break; + case "Union": { + const parentUnion = diagnostics.pipe(getUnionAsEnumInternal(variant.type, visited)); + if (parentUnion !== undefined) { + kinds.add(parentUnion.kind); + if (parentUnion.open) open = true; + if (parentUnion.nullable) nullable = true; + for (const [key, value] of parentUnion.flattenedMembers) { + flattenedMembers.set(key, value); + } + } + break; + } + case "Enum": + for (const member of variant.type.members.values()) { + kinds.add(typeof member.value === "number" ? "number" : "string"); + flattenedMembers.set(member.name, { value: member.value ?? member.name, type: member }); + } + break; + case "Scalar": { + const scalar = variant.type; + switch (scalar.name) { + case "string": + kinds.add("string"); + open = true; + break; + case "float": + case "float32": + case "float64": + case "integer": + case "int8": + case "int16": + case "int32": + case "int64": + case "uint8": + case "uint16": + case "uint32": + case "uint64": + kinds.add("number"); + open = true; + break; + default: + return diagnostics.wrap(undefined); + } + break; + } + default: + return diagnostics.wrap(undefined); + } + } + + for (const [key, value] of members) { + flattenedMembers.set(key, value); + } + + if (kinds.size > 1 || diagnostics.diagnostics.length > 0) { + return diagnostics.wrap(undefined); + } + + if (flattenedMembers.size === 0) { + return [undefined, []]; + } + + return [ + { + kind: [...kinds][0], + union, + members, + flattenedMembers, + open, + nullable, + } as any, + [], + ]; +} + +/** + * Push a naming context node onto the stack. The stack is read by getGeneratedName/getCrossLanguageDefinitionId + * to determine names for anonymous types without DFS. + */ +function pushNamingContext(context: TCGCContext, name: string, type: ContextNode["type"]): void { + context.__namingContextPath.push({ name, type }); +} + +function popNamingContext(context: TCGCContext): void { + context.__namingContextPath.pop(); +} + +export function getTypeSpecBuiltInType( + context: TCGCContext, + kind: IntrinsicScalarName, +): SdkBuiltInType { + const global = context.getMutatedGlobalNamespace(); // since other build in types have been mutated, we need to use the mutated global namespace + const typeSpecNamespace = global.namespaces!.get("TypeSpec"); + const type = typeSpecNamespace!.scalars.get(kind)!; + + return getSdkBuiltInType(context, type) as SdkBuiltInType; +} + +function getUnknownType(context: TCGCContext, type: Type): [SdkBuiltInType, readonly Diagnostic[]] { + const diagnostics = createDiagnosticCollector(); + const unknownType: SdkBuiltInType = { + ...diagnostics.pipe(getSdkTypeBaseHelper(context, type, "unknown")), + name: getLibraryName(context, type), + encode: undefined, + crossLanguageDefinitionId: "", + }; + return diagnostics.wrap(unknownType); +} + +/** + * Add encoding info onto an sdk type. Since the encoding decorator + * decorates the ModelProperty, we add the encoding info onto the property's internal + * type. + * @param context sdk context + * @param type the original typespec type. Used to grab the encoding decorator off of + * @param propertyType the type of the property, i.e. the internal type that we add the encoding info onto + */ +export function addEncodeInfo( + context: TCGCContext, + type: ModelProperty | Scalar, + propertyType: SdkType, + defaultContentType?: string, +): [void, readonly Diagnostic[]] { + const diagnostics = createDiagnosticCollector(); + const alternateType = getAlternateType(context, type); + if ( + alternateType && + (alternateType?.kind === "Scalar" || alternateType?.kind === "ModelProperty") + ) { + type = alternateType; + } + const innerType = propertyType.kind === "nullable" ? propertyType.type : propertyType; + const encodeData = getEncode(context.program, type); + if (innerType.kind === "duration") { + if (!encodeData || !encodeData.encoding) return diagnostics.wrap(undefined); + innerType.encode = encodeData.encoding; + innerType.wireType = diagnostics.pipe( + getClientTypeWithDiagnostics(context, encodeData.type), + ) as SdkBuiltInType; + } + if (innerType.kind === "utcDateTime" || innerType.kind === "offsetDateTime") { + if (encodeData && encodeData.encoding) { + innerType.encode = encodeData.encoding; + innerType.wireType = diagnostics.pipe( + getClientTypeWithDiagnostics(context, encodeData.type), + ) as SdkBuiltInType; + } else if (type.kind === "ModelProperty" && isHeader(context.program, type)) { + innerType.encode = "rfc7231"; + } + } + if (innerType.kind === "bytes") { + if (encodeData && encodeData.encoding) { + innerType.encode = encodeData.encoding; + } else if (type.kind === "Scalar" || !defaultContentType) { + // for scalar bytes without specific encode, or no specific content type, fallback to base64 + innerType.encode = "base64"; + } else if ( + !isMediaTypeJson(defaultContentType) && + !isMediaTypeXml(defaultContentType) && + !isMediaTypeTextPlain(defaultContentType) + ) { + // for model property bytes with specific content type, will change to bytes for non-text content type + innerType.encode = "bytes"; + } + } + if (isSdkIntKind(innerType.kind)) { + // only integer type is allowed to be encoded as string + if (encodeData) { + if (encodeData?.encoding) { + (innerType as any).encode = encodeData.encoding; + } + if (encodeData?.type) { + // if we specify the encoding type in the decorator, we set the `.encode` string + // to the kind of the encoding type + (innerType as any).encode = getSdkBuiltInType(context, encodeData.type).kind; + } + } + } + return diagnostics.wrap(undefined); +} + +/** + * Mapping of typespec scalar kinds to the built in kinds exposed in the SDK + * @param context the TCGC context + * @param scalar the original typespec scalar + * @returns the corresponding sdk built in kind + */ +function getScalarKind(context: TCGCContext, scalar: Scalar): IntrinsicScalarName | "unknown" { + if (context.program.checker.isStdType(scalar)) { + return scalar.name; + } + + // for those scalar defined as `scalar newThing;`, + // the best we could do here is return as a `any` type with a name and namespace and let the generator figure what this is + if (scalar.baseScalar === undefined) { + return "unknown"; + } + + return getScalarKind(context, scalar.baseScalar); +} + +/** + * This function converts a Scalar into SdkBuiltInType. + * @param context + * @param type + * @param kind + * @returns + */ +function getSdkBuiltInTypeWithDiagnostics( + context: TCGCContext, + type: Scalar, + kind: SdkBuiltInKinds, +): [SdkBuiltInType, readonly Diagnostic[]] { + const diagnostics = createDiagnosticCollector(); + const stdType = { + ...diagnostics.pipe(getSdkTypeBaseHelper(context, type, kind)), + name: getLibraryName(context, type), + baseType: + type.baseScalar && !context.program.checker.isStdType(type) // we only calculate the base type when this type has a base type and this type is not a std type because for std types there is no point of calculating its base type. + ? diagnostics.pipe(getSdkBuiltInTypeWithDiagnostics(context, type.baseScalar, kind)) + : undefined, + crossLanguageDefinitionId: getCrossLanguageDefinitionId(context, type), + }; + addEncodeInfo(context, type, stdType); + return diagnostics.wrap(stdType); +} + +/** + * This function calculates the encode and wireType for a datetime or duration type. + * We always first try to get the `@encode` decorator on this type and returns it if any. + * If we did not get anything from the encode, we try to get the baseType's encode and wireType. + * @param context + * @param encodeData + * @param baseType + * @returns + */ +function getEncodeInfoForDateTimeOrDuration( + context: TCGCContext, + encodeData: EncodeData | undefined, + baseType: SdkDateTimeType | SdkDurationType | undefined, +): [string | undefined, SdkBuiltInType | undefined] { + const encode = encodeData?.encoding; + const wireType = encodeData?.type + ? (getClientType(context, encodeData.type) as SdkBuiltInType) + : undefined; + + // if we get something from the encode + if (encode || wireType) { + return [encode, wireType]; + } + + // if we did not get anything from the encode, try the baseType + return [baseType?.encode, baseType?.wireType]; +} + +/** + * This function converts a Scalar into SdkDateTimeType. + * @param context + * @param type + * @param kind + * @returns + */ +function getSdkDateTimeType( + context: TCGCContext, + type: Scalar, + kind: "utcDateTime" | "offsetDateTime", +): [SdkDateTimeType, readonly Diagnostic[]] { + const diagnostics = createDiagnosticCollector(); + const baseType = + type.baseScalar && !context.program.checker.isStdType(type) // we only calculate the base type when this type has a base type and this type is not a std type because for std types there is no point of calculating its base type. + ? diagnostics.pipe(getSdkDateTimeType(context, type.baseScalar, kind)) + : undefined; + const [encode, wireType] = getEncodeInfoForDateTimeOrDuration( + context, + getEncode(context.program, type), + baseType, + ); + return diagnostics.wrap({ + ...diagnostics.pipe(getSdkTypeBaseHelper(context, type, kind)), + name: getLibraryName(context, type), + encode: encode ?? "rfc3339", + wireType: wireType ?? getTypeSpecBuiltInType(context, "string"), + baseType: baseType, + crossLanguageDefinitionId: getCrossLanguageDefinitionId(context, type), + }); +} + +function getSdkDateTimeOrDurationOrBuiltInType( + context: TCGCContext, + type: Scalar, +): [SdkDateTimeType | SdkDurationType | SdkBuiltInType, readonly Diagnostic[]] { + // follow the extends hierarchy to determine the final kind of this type + const kind = getScalarKind(context, type); + + if (kind === "utcDateTime" || kind === "offsetDateTime") { + return getSdkDateTimeType(context, type, kind); + } + if (kind === "duration") { + return getSdkDurationTypeWithDiagnostics(context, type, kind); + } + // handle the std types of typespec + return getSdkBuiltInTypeWithDiagnostics(context, type, kind); +} + +function getSdkTypeForLiteral( + context: TCGCContext, + type: NumericLiteral | StringLiteral | BooleanLiteral, +): SdkBuiltInType { + let kind: SdkBuiltInKinds; + + if (type.kind === "String") { + kind = "string"; + } else if (type.kind === "Boolean") { + kind = "boolean"; + } else { + kind = intOrFloat(type.value); + } + return getTypeSpecBuiltInType(context, kind); +} + +function getSdkTypeForIntrinsic(context: TCGCContext, type: IntrinsicType): SdkBuiltInType { + const kind = "unknown"; + const diagnostics = createDiagnosticCollector(); + return { + ...diagnostics.pipe(getSdkTypeBaseHelper(context, type, kind)), + name: getLibraryName(context, type), + crossLanguageDefinitionId: "", + encode: kind, + }; +} + +export function getSdkBuiltInType( + context: TCGCContext, + type: Scalar | IntrinsicType | NumericLiteral | StringLiteral | BooleanLiteral, +): SdkDateTimeType | SdkDurationType | SdkBuiltInType { + switch (type.kind) { + case "Scalar": + return ignoreDiagnostics(getSdkDateTimeOrDurationOrBuiltInType(context, type)); + case "Intrinsic": + return getSdkTypeForIntrinsic(context, type); + case "String": + case "Number": + case "Boolean": + return getSdkTypeForLiteral(context, type); + } +} + +export function getSdkDurationType(context: TCGCContext, type: Scalar): SdkDurationType { + return ignoreDiagnostics(getSdkDurationTypeWithDiagnostics(context, type, "duration")); +} + +/** + * This function converts a Scalar into SdkDurationType. + * @param context + * @param type + * @param kind + * @returns + */ +function getSdkDurationTypeWithDiagnostics( + context: TCGCContext, + type: Scalar, + kind: "duration", +): [SdkDurationType, readonly Diagnostic[]] { + const diagnostics = createDiagnosticCollector(); + const baseType = + type.baseScalar && !context.program.checker.isStdType(type) // we only calculate the base type when this type has a base type and this type is not a std type because for std types there is no point of calculating its base type. + ? diagnostics.pipe(getSdkDurationTypeWithDiagnostics(context, type.baseScalar, kind)) + : undefined; + const [encode, wireType] = getEncodeInfoForDateTimeOrDuration( + context, + getEncode(context.program, type), + baseType, + ); + return diagnostics.wrap({ + ...diagnostics.pipe(getSdkTypeBaseHelper(context, type, kind)), + name: getLibraryName(context, type), + encode: encode ?? "ISO8601", + wireType: wireType ?? getTypeSpecBuiltInType(context, "string"), + baseType: baseType, + crossLanguageDefinitionId: getCrossLanguageDefinitionId(context, type), + }); +} + +export function getSdkArrayOrDict( + context: TCGCContext, + type: Model, + operation?: Operation, +): (SdkDictionaryType | SdkArrayType) | undefined { + return ignoreDiagnostics(getSdkArrayOrDictWithDiagnostics(context, type, operation)); +} + +export function getSdkArrayOrDictWithDiagnostics( + context: TCGCContext, + type: Model, + operation?: Operation, +): [(SdkDictionaryType | SdkArrayType) | undefined, readonly Diagnostic[]] { + const diagnostics = createDiagnosticCollector(); + // if model with both indexer and properties or name should be a model with additional properties + if (type.indexer !== undefined && type.properties.size === 0) { + if (!isNeverType(type.indexer.key)) { + let sdkType = context.__arrayDictionaryCache.get(type); + if (!sdkType) { + const name = type.indexer.key.name; + if (name === "string" && type.name === "Record") { + // model MyModel is Record<> {} should be model with additional properties + if (type.sourceModel?.kind === "Model" && type.sourceModel?.name === "Record") { + return diagnostics.wrap(undefined); + } else { + // other cases are dict + sdkType = { + ...diagnostics.pipe(getSdkTypeBaseHelper(context, type, "dict")), + keyType: diagnostics.pipe( + getClientTypeWithDiagnostics(context, type.indexer.key, operation), + ), + valueType: diagnostics.pipe(getUnknownType(context, type)), // set unknown for cache + }; + } + } else if (name === "integer") { + // only array's index key name is integer + sdkType = { + ...diagnostics.pipe(getSdkTypeBaseHelper(context, type, "array")), + name: getLibraryName(context, type), + valueType: diagnostics.pipe(getUnknownType(context, type)), // set unknown for cache + crossLanguageDefinitionId: getCrossLanguageDefinitionId(context, type, operation), + }; + } else { + // additional properties case + return diagnostics.wrap(undefined); + } + context.__arrayDictionaryCache.set(type, sdkType!); + // Singularize the current naming context for array/dict value types + const currentCtx = context.__namingContextPath.at(-1); + if (currentCtx) { + popNamingContext(context); + pushNamingContext( + context, + pluralize.singular(currentCtx.name), + type.indexer.value! as ContextNode["type"], + ); + } + sdkType!.valueType = diagnostics.pipe( + getClientTypeWithDiagnostics(context, type.indexer.value!, operation), + ); + if (currentCtx) { + popNamingContext(context); + pushNamingContext(context, currentCtx.name, currentCtx.type); + } + } + return diagnostics.wrap(sdkType); + } + } + return diagnostics.wrap(undefined); +} + +export function getSdkTuple( + context: TCGCContext, + type: Tuple, + operation?: Operation, +): SdkTupleType { + return ignoreDiagnostics(getSdkTupleWithDiagnostics(context, type, operation)); +} + +export function getSdkTupleWithDiagnostics( + context: TCGCContext, + type: Tuple, + operation?: Operation, +): [SdkTupleType, readonly Diagnostic[]] { + const diagnostics = createDiagnosticCollector(); + return diagnostics.wrap({ + ...diagnostics.pipe(getSdkTypeBaseHelper(context, type, "tuple")), + valueTypes: type.values.map((x) => + diagnostics.pipe(getClientTypeWithDiagnostics(context, x, operation)), + ), + }); +} + +export function getSdkUnion(context: TCGCContext, type: Union, operation?: Operation): SdkType { + return ignoreDiagnostics(getSdkUnionWithDiagnostics(context, type, operation)); +} + +export function getSdkUnionWithDiagnostics( + context: TCGCContext, + type: Union, + operation?: Operation, +): [SdkType, readonly Diagnostic[]] { + let retval: SdkType | undefined = context.__referencedTypeCache.get(type); + const diagnostics = createDiagnosticCollector(); + + if (!retval) { + const nonNullOptions = getNonNullOptions(type); + const nullOption = getNullOption(type); + + if (nonNullOptions.length === 0) { + // union with only `null`, report diagnostic and fall back to empty union + diagnostics.add(createDiagnostic({ code: "union-null", target: type })); + retval = diagnostics.pipe(getEmptyUnionType(context, type, operation)); + updateReferencedTypeMap(context, type, retval); + } else if (checkUnionCircular(type)) { + // union with circular ref, report diagnostic and fall back to empty union + diagnostics.add(createDiagnostic({ code: "union-circular", target: type })); + retval = diagnostics.pipe(getEmptyUnionType(context, type, operation)); + updateReferencedTypeMap(context, type, retval); + } else { + const namespace = getClientNamespace(context, type); + // if a union is `type | null`, then we will return a nullable wrapper type of the type + if (nonNullOptions.length === 1 && nullOption !== undefined) { + retval = { + ...diagnostics.pipe(getSdkTypeBaseHelper(context, type, "nullable")), + name: getLibraryName(context, type) || getGeneratedName(context, type, operation), + isGeneratedName: !type.name, + crossLanguageDefinitionId: getCrossLanguageDefinitionId(context, type), + type: diagnostics.pipe(getUnknownType(context, type)), + access: "public", + usage: UsageFlags.None, + namespace, + }; + updateReferencedTypeMap(context, type, retval); + retval.type = diagnostics.pipe( + getClientTypeWithDiagnostics(context, nonNullOptions[0], operation), + ); + } else if ( + // judge if the union can be converted to enum + // if language does not need flatten union as enum + // filter the case that union is composed of union or enum + context.flattenUnionAsEnum || + ![...type.variants.values()].some((variant) => { + return variant.type.kind === "Union" || variant.type.kind === "Enum"; + }) + ) { + const unionAsEnum = diagnostics.pipe(getUnionAsEnum(type)); + if (unionAsEnum) { + // union as enum case + retval = diagnostics.pipe( + getSdkUnionEnumWithDiagnostics(context, unionAsEnum, operation), + ); + if (nullOption !== undefined) { + retval = { + ...diagnostics.pipe(getSdkTypeBaseHelper(context, type, "nullable")), + name: getLibraryName(context, type) || getGeneratedName(context, type, operation), + isGeneratedName: !type.name, + crossLanguageDefinitionId: getCrossLanguageDefinitionId(context, type), + type: retval, + access: "public", + usage: UsageFlags.None, + namespace, + }; + } + updateReferencedTypeMap(context, type, retval); + } + } + + // other cases + if (retval === undefined) { + retval = { + ...diagnostics.pipe(getSdkTypeBaseHelper(context, type, "union")), + name: getLibraryName(context, type) || getGeneratedName(context, type, operation), + isGeneratedName: nullOption !== undefined ? true : !type.name, // if nullable, always set inner union type as generated name + namespace, + variantTypes: [], + crossLanguageDefinitionId: getCrossLanguageDefinitionId(context, type, operation), + access: "public", + usage: UsageFlags.None, + }; + const discriminatedOptions = $(context.program).union.getDiscriminatedUnion(type)?.options; + if (discriminatedOptions) { + const envelope = discriminatedOptions.envelope ?? "object"; + retval.discriminatedOptions = { + envelope, + discriminatorPropertyName: discriminatedOptions.discriminatorPropertyName ?? "kind", + envelopePropertyName: + envelope === "none" + ? undefined + : (discriminatedOptions.envelopePropertyName ?? "value"), + }; + } + if (nullOption !== undefined) { + retval = { + ...diagnostics.pipe(getSdkTypeBaseHelper(context, type, "nullable")), + name: getLibraryName(context, type) || getGeneratedName(context, type, operation), + isGeneratedName: !type.name, + crossLanguageDefinitionId: getCrossLanguageDefinitionId(context, type), + type: retval, + access: "public", + usage: UsageFlags.None, + namespace, + }; + } + updateReferencedTypeMap(context, type, retval); + const variantTypes = nonNullOptions.map((x) => + diagnostics.pipe(getClientTypeWithDiagnostics(context, x, operation)), + ); + if (retval.kind === "nullable" && retval.type.kind === "union") { + retval.type.variantTypes = variantTypes; + } else if (retval.kind === "union") { + retval.variantTypes = variantTypes; + } + } + } + } + + return diagnostics.wrap(retval); +} + +function getEmptyUnionType( + context: TCGCContext, + type: Union, + operation?: Operation, +): [SdkUnionType, readonly Diagnostic[]] { + const diagnostics = createDiagnosticCollector(); + const namespace = getClientNamespace(context, type); + + return diagnostics.wrap({ + ...diagnostics.pipe(getSdkTypeBaseHelper(context, type, "union")), + name: getLibraryName(context, type) || getGeneratedName(context, type, operation), + isGeneratedName: !type.name, + namespace, + clientNamespace: namespace, + variantTypes: [], + crossLanguageDefinitionId: getCrossLanguageDefinitionId(context, type, operation), + access: "public", + usage: UsageFlags.None, + }); +} +function checkUnionCircular(type: Union): boolean { + const visited = new Set(); + const stack = [type]; + while (stack.length > 0) { + const current = stack.pop()!; + if (visited.has(current)) { + return true; + } + visited.add(current); + for (const variant of current.variants.values()) { + if (variant.type.kind === "Union") { + stack.push(variant.type); + } + } + } + return false; +} + +function getSdkConstantWithDiagnostics( + context: TCGCContext, + type: StringLiteral | NumericLiteral | BooleanLiteral, + operation?: Operation, +): [SdkConstantType, readonly Diagnostic[]] { + const diagnostics = createDiagnosticCollector(); + switch (type.kind) { + case "Number": + case "String": + case "Boolean": + const valueType = getSdkTypeForLiteral(context, type); + return diagnostics.wrap({ + ...diagnostics.pipe(getSdkTypeBaseHelper(context, type, "constant")), + value: type.value, + valueType, + name: getGeneratedName(context, type, operation), + isGeneratedName: true, + }); + } +} + +export function getSdkConstant( + context: TCGCContext, + type: StringLiteral | NumericLiteral | BooleanLiteral, + operation?: Operation, +): SdkConstantType { + return ignoreDiagnostics(getSdkConstantWithDiagnostics(context, type, operation)); +} + +function addDiscriminatorToModelType( + context: TCGCContext, + type: Model, + model: SdkModelType, +): [undefined, readonly Diagnostic[]] { + const discriminator = getDiscriminator(context.program, type); + const diagnostics = createDiagnosticCollector(); + if (discriminator) { + let discriminatorType: SdkType | undefined = undefined; + for (let i = 0; i < model.properties.length; i++) { + const property = model.properties[i]; + if (property.kind === "property" && property.__raw?.name === discriminator.propertyName) { + discriminatorType = property.type; + break; + } + } + + let discriminatorProperty; + for (const childModel of type.derivedModels) { + if (isTemplateDeclaration(childModel)) continue; + pushNamingContext(context, childModel.name ?? "", childModel); + const childModelSdkType = diagnostics.pipe(getSdkModelWithDiagnostics(context, childModel)); + popNamingContext(context); + for (const property of childModelSdkType.properties) { + if (property.kind === "property") { + if (property.__raw?.name === discriminator?.propertyName) { + if (property.type.kind !== "constant" && property.type.kind !== "enumvalue") { + diagnostics.add( + createDiagnostic({ + code: "discriminator-not-constant", + target: childModel, + format: { discriminator: property.name }, + }), + ); + } else if (typeof property.type.value !== "string") { + diagnostics.add( + createDiagnostic({ + code: "discriminator-not-string", + target: type, + format: { + discriminator: property.name, + discriminatorValue: String(property.type.value), + }, + }), + ); + } else { + // map string value type to enum value type + if (property.type.kind === "constant" && discriminatorType?.kind === "enum") { + for (const value of discriminatorType.values) { + if (value.value === property.type.value) { + property.type = value; + } + } + } + childModelSdkType.discriminatorValue = property.type.value as string; + property.discriminator = true; + if (model.discriminatedSubtypes === undefined) { + model.discriminatedSubtypes = {}; + } + model.discriminatedSubtypes[property.type.value as string] = childModelSdkType; + discriminatorProperty = property; + } + } + } + } + } + for (let i = 0; i < model.properties.length; i++) { + const property = model.properties[i]; + if (property.kind === "property" && property.__raw?.name === discriminator.propertyName) { + property.discriminator = true; + model.discriminatorProperty = property; + return diagnostics.wrap(undefined); + } + } + + if (discriminatorProperty) { + if (discriminatorProperty.type.kind === "constant") { + discriminatorType = { ...discriminatorProperty.type.valueType }; + } else if (discriminatorProperty.type.kind === "enumvalue") { + discriminatorType = discriminatorProperty.type.enumType; + } + } else { + discriminatorType = getTypeSpecBuiltInType(context, "string"); + } + const name = discriminatorProperty ? discriminatorProperty.name : discriminator.propertyName; + const discriminatorPropertyType: SdkModelPropertyType = { + kind: "property", + doc: `Discriminator property for ${model.name}.`, + optional: false, + discriminator: true, + serializedName: discriminatorProperty + ? discriminatorProperty.serializedName // eslint-disable-line @typescript-eslint/no-deprecated + : discriminator.propertyName, + serializationOptions: {}, + type: discriminatorType!, + name, + isGeneratedName: false, + onClient: false, + apiVersions: discriminatorProperty + ? getAvailableApiVersions(context, discriminatorProperty.__raw!, type) + : model.apiVersions, + isApiVersionParam: false, + isMultipartFileInput: false, // discriminator property cannot be a file + flatten: false, // discriminator properties can not be flattened + crossLanguageDefinitionId: `${model.crossLanguageDefinitionId}.${name}`, + decorators: [], + access: "public", + }; + model.properties.splice(0, 0, discriminatorPropertyType); + model.discriminatorProperty = discriminatorPropertyType; + } + return diagnostics.wrap(undefined); +} + +export function getSdkModel( + context: TCGCContext, + type: Model, + operation?: Operation, +): SdkModelType { + return ignoreDiagnostics(getSdkModelWithDiagnostics(context, type, operation)); +} + +export function getSdkModelWithDiagnostics( + context: TCGCContext, + type: Model, + operation?: Operation, +): [SdkModelType, readonly Diagnostic[]] { + const diagnostics = createDiagnosticCollector(); + let sdkType = context.__referencedTypeCache.get(type) as SdkModelType | undefined; + + if (!sdkType) { + const name = getLibraryName(context, type) || getGeneratedName(context, type, operation); + sdkType = { + ...diagnostics.pipe(getSdkTypeBaseHelper(context, type, "model")), + name: name, + isGeneratedName: !type.name, + namespace: getClientNamespace(context, type), + properties: [], + additionalProperties: undefined, // going to set additional properties in the next few lines when we look at base model + access: "public", + usage: UsageFlags.None, + crossLanguageDefinitionId: getCrossLanguageDefinitionId(context, type, operation), + apiVersions: getAvailableApiVersions(context, type, type.namespace), + serializationOptions: {}, + }; + updateReferencedTypeMap(context, type, sdkType); + + // model MyModel is Record<> {} should be model with additional properties + if (type.sourceModel?.kind === "Model" && type.sourceModel?.name === "Record") { + pushNamingContext( + context, + "AdditionalProperty", + type.sourceModel!.indexer!.value! as ContextNode["type"], + ); + sdkType.additionalProperties = diagnostics.pipe( + getClientTypeWithDiagnostics(context, type.sourceModel!.indexer!.value!, operation), + ); + popNamingContext(context); + } + // model MyModel { ...Record<>} should be model with additional properties + if (type.indexer) { + pushNamingContext(context, "AdditionalProperty", type.indexer.value as ContextNode["type"]); + sdkType.additionalProperties = diagnostics.pipe( + getClientTypeWithDiagnostics(context, type.indexer.value, operation), + ); + popNamingContext(context); + } + + // properties should be generated first since base model's discriminator handling is depend on derived model's properties + if (operation) { + const requestBody = getHttpOperationWithCache(context, operation).parameters.body; + const multipartResponseBodies = getHttpOperationWithCache(context, operation) + .responses.map((response) => response.responses.map((r) => r.body)) + .flatMap((x) => x) + .filter((x) => x?.bodyKind === "multipart"); + if ( + requestBody && + requestBody.bodyKind === "multipart" && + getHttpBodyType(requestBody) === type + ) { + // handle multipart request body model properties + diagnostics.pipe( + addMultipartPropertiesToModelType(context, sdkType, requestBody, operation), + ); + } else if (multipartResponseBodies.length > 0) { + // handle multipart response body model properties + multipartResponseBodies.map((body) => + getHttpBodyType(body) === type + ? diagnostics.pipe( + addMultipartPropertiesToModelType(context, sdkType!, body, operation), + ) + : undefined, + ); + } else { + // handle normal model properties + diagnostics.pipe(addPropertiesToModelType(context, type, sdkType, operation)); + } + } else { + // handle normal model properties + diagnostics.pipe(addPropertiesToModelType(context, type, sdkType, operation)); + } + const rawBaseModel = getLegacyHierarchyBuilding(context, type) || type.baseModel; + if (rawBaseModel) { + sdkType.baseModel = context.__referencedTypeCache.get(rawBaseModel) as + | SdkModelType + | undefined; + + if (sdkType.baseModel === undefined) { + // Use "AdditionalProperty" label for Record base models + const baseModelLabel = + rawBaseModel.name === "Record" ? "AdditionalProperty" : (rawBaseModel.name ?? ""); + pushNamingContext(context, baseModelLabel, rawBaseModel); + const baseModel = diagnostics.pipe( + getClientTypeWithDiagnostics(context, rawBaseModel, operation), + ) as SdkDictionaryType | SdkModelType; + popNamingContext(context); + if (baseModel.kind === "dict") { + // model MyModel extends Record<> {} should be model with additional properties + sdkType.additionalProperties = baseModel.valueType; + } else { + sdkType.baseModel = baseModel; + } + } + } + diagnostics.pipe(addDiscriminatorToModelType(context, type, sdkType)); + updateReferencedTypeMap(context, type, sdkType); + } + return diagnostics.wrap(sdkType); +} + +function getSdkEnumValueType( + context: TCGCContext, + values: (string | number | undefined)[], +): [SdkBuiltInType, readonly Diagnostic[]] { + const diagnostics = createDiagnosticCollector(); + let kind: "string" | "int32" | "float32" = "string"; + for (const value of values) { + if (typeof value === "number") { + kind = intOrFloat(value); + if (kind === "float32") { + break; + } + } else if (typeof value === "string") { + kind = "string"; + break; + } + } + + return diagnostics.wrap(getTypeSpecBuiltInType(context, kind!)); +} + +function getUnionAsEnumValueType( + context: TCGCContext, + union: Union, +): [SdkBuiltInType | undefined, readonly Diagnostic[]] { + const diagnostics = createDiagnosticCollector(); + const nonNullOptions = getNonNullOptions(union); + for (const option of nonNullOptions) { + if (option.kind === "Union") { + const ret = diagnostics.pipe(getUnionAsEnumValueType(context, option)); + if (ret) return diagnostics.wrap(ret); + } else if (option.kind === "Scalar") { + const ret = diagnostics.pipe(getClientTypeWithDiagnostics(context, option)) as SdkBuiltInType; + return diagnostics.wrap(ret); + } + } + + return diagnostics.wrap(undefined); +} + +export function getSdkEnumValue( + context: TCGCContext, + enumType: SdkEnumType, + type: EnumMember, +): SdkEnumValueType { + return ignoreDiagnostics(getSdkEnumValueWithDiagnostics(context, enumType, type)); +} + +function getSdkEnumValueWithDiagnostics( + context: TCGCContext, + enumType: SdkEnumType, + type: EnumMember, +): [SdkEnumValueType, readonly Diagnostic[]] { + const diagnostics = createDiagnosticCollector(); + return diagnostics.wrap({ + ...diagnostics.pipe(getSdkTypeBaseHelper(context, type, "enumvalue")), + name: getLibraryName(context, type), + value: type.value ?? type.name, + enumType, + valueType: enumType.valueType, + crossLanguageDefinitionId: getCrossLanguageDefinitionId(context, type), + }); +} + +export function getSdkEnum(context: TCGCContext, type: Enum, operation?: Operation): SdkEnumType { + return ignoreDiagnostics(getSdkEnumWithDiagnostics(context, type, operation)); +} + +function getSdkEnumWithDiagnostics( + context: TCGCContext, + type: Enum, + operation?: Operation, +): [SdkEnumType, readonly Diagnostic[]] { + const diagnostics = createDiagnosticCollector(); + let sdkType = context.__referencedTypeCache.get(type) as SdkEnumType | undefined; + if (!sdkType) { + sdkType = { + ...diagnostics.pipe(getSdkTypeBaseHelper(context, type, "enum")), + name: getLibraryName(context, type), + isGeneratedName: false, + namespace: getClientNamespace(context, type), + valueType: diagnostics.pipe( + getSdkEnumValueType( + context, + [...type.members.values()].map((v) => v.value), + ), + ), + values: [], + isFixed: true, // enums are always fixed after we switch to use union to represent extensible enum + isFlags: false, + usage: UsageFlags.None, // We will add usage as we loop through the operations + access: "public", // Dummy value until we update models map + crossLanguageDefinitionId: getCrossLanguageDefinitionId(context, type, operation), + apiVersions: getAvailableApiVersions(context, type, type.namespace), + isUnionAsEnum: false, + }; + for (const member of type.members.values()) { + sdkType.values.push( + diagnostics.pipe(getSdkEnumValueWithDiagnostics(context, sdkType, member)), + ); + } + } + updateReferencedTypeMap(context, type, sdkType); + + return diagnostics.wrap(sdkType); +} + +function getSdkUnionEnumValues( + context: TCGCContext, + type: UnionEnum, + enumType: SdkEnumType, +): [SdkEnumValueType[], readonly Diagnostic[]] { + const diagnostics = createDiagnosticCollector(); + const values: SdkEnumValueType[] = []; + for (const member of type.flattenedMembers.values()) { + const name = getLibraryName(context, member.type); + values.push({ + ...diagnostics.pipe(getSdkTypeBaseHelper(context, member.type, "enumvalue")), + name: name ? name : `${member.value}`, + value: member.value, + valueType: enumType.valueType, + enumType, + crossLanguageDefinitionId: getCrossLanguageDefinitionId(context, member.type), + }); + } + return diagnostics.wrap(values); +} + +export function getSdkUnionEnum(context: TCGCContext, type: UnionEnum, operation?: Operation) { + return ignoreDiagnostics(getSdkUnionEnumWithDiagnostics(context, type, operation)); +} + +export function getSdkUnionEnumWithDiagnostics( + context: TCGCContext, + type: UnionEnum, + operation?: Operation, +): [SdkEnumType, readonly Diagnostic[]] { + const diagnostics = createDiagnosticCollector(); + const union = type.union; + const name = getLibraryName(context, type.union) || getGeneratedName(context, union, operation); + const sdkType: SdkEnumType = { + ...diagnostics.pipe(getSdkTypeBaseHelper(context, type.union, "enum")), + name, + isGeneratedName: !type.union.name, + namespace: getClientNamespace(context, type.union), + valueType: + diagnostics.pipe(getUnionAsEnumValueType(context, type.union)) ?? + diagnostics.pipe( + getSdkEnumValueType( + context, + [...type.flattenedMembers.values()].map((v) => v.value), + ), + ), + values: [], + isFixed: !type.open, + isFlags: false, + usage: UsageFlags.None, // We will add usage as we loop through the operations + access: "public", // Dummy value until we update models map + crossLanguageDefinitionId: getCrossLanguageDefinitionId(context, union, operation), + apiVersions: getAvailableApiVersions(context, type.union, type.union.namespace), + isUnionAsEnum: true, + }; + sdkType.values = diagnostics.pipe(getSdkUnionEnumValues(context, type, sdkType)); + return diagnostics.wrap(sdkType); +} + +export function getClientTypeWithDiagnostics( + context: TCGCContext, + type: Type, + operation?: Operation, +): [SdkType, readonly Diagnostic[]] { + const diagnostics = createDiagnosticCollector(); + let retval: SdkType | undefined; + switch (type.kind) { + case "String": + case "Number": + case "Boolean": + retval = diagnostics.pipe(getSdkConstantWithDiagnostics(context, type)); + break; + case "Tuple": + retval = diagnostics.pipe(getSdkTupleWithDiagnostics(context, type, operation)); + break; + case "Model": + const modelAlternateType = getSdkTypeFromAlternateType(context, type, operation); + if (modelAlternateType) { + retval = modelAlternateType; + break; + } + retval = diagnostics.pipe(getSdkArrayOrDictWithDiagnostics(context, type, operation)); + if (retval === undefined) { + retval = diagnostics.pipe(getSdkModelWithDiagnostics(context, type, operation)); + } + break; + case "Intrinsic": + retval = getSdkTypeForIntrinsic(context, type); + break; + case "Scalar": + const scalarAlternateType = getAlternateType(context, type); + retval = diagnostics.pipe( + getSdkDateTimeOrDurationOrBuiltInType( + context, + scalarAlternateType && scalarAlternateType.kind === "Scalar" ? scalarAlternateType : type, + ), + ); + break; + case "Enum": + const enumAlternateType = getSdkTypeFromAlternateType(context, type, operation); + if (enumAlternateType) { + retval = enumAlternateType; + break; + } + retval = diagnostics.pipe(getSdkEnumWithDiagnostics(context, type, operation)); + break; + case "Union": + const unionAlternateType = getSdkTypeFromAlternateType(context, type, operation); + if (unionAlternateType) { + retval = unionAlternateType; + break; + } + retval = diagnostics.pipe(getSdkUnionWithDiagnostics(context, type, operation)); + break; + case "ModelProperty": + const alternateType = getSdkTypeFromAlternateType(context, type, operation); + retval = + alternateType || + diagnostics.pipe(getClientTypeWithDiagnostics(context, type.type, operation)); + addEncodeInfo(context, type, retval); + break; + case "UnionVariant": + const unionType = diagnostics.pipe( + getClientTypeWithDiagnostics(context, type.union, operation), + ); + if (unionType.kind === "enum") { + retval = unionType.values.find((x) => x.name === getLibraryName(context, type))!; + } else { + retval = diagnostics.pipe(getClientTypeWithDiagnostics(context, type.type, operation)); + } + break; + case "EnumMember": + const enumType = diagnostics.pipe(getSdkEnumWithDiagnostics(context, type.enum, operation)); + retval = diagnostics.pipe(getSdkEnumValueWithDiagnostics(context, enumType, type)); + break; + default: + retval = diagnostics.pipe(getUnknownType(context, type)); + diagnostics.add( + createDiagnostic({ code: "unsupported-kind", target: type, format: { kind: type.kind } }), + ); + } + return diagnostics.wrap(retval); +} + +export function getClientType(context: TCGCContext, type: Type, operation?: Operation): SdkType { + return ignoreDiagnostics(getClientTypeWithDiagnostics(context, type, operation)); +} + +export function isReadOnly(property: SdkModelPropertyTypeBase) { + if ( + property.visibility && + property.visibility.includes(Visibility.Read) && + property.visibility.length === 1 + ) { + return true; + } + return false; +} + +function getSdkVisibility(context: TCGCContext, type: ModelProperty): Visibility[] | undefined { + const lifecycle = getLifecycleVisibilityEnum(context.program); + const visibility = getVisibilityForClass(context.program, type, lifecycle); + if (visibility) { + const result: Visibility[] = []; + if (lifecycle.members.get("Read") && visibility.has(lifecycle.members.get("Read")!)) { + result.push(Visibility.Read); + } + if (lifecycle.members.get("Create") && visibility.has(lifecycle.members.get("Create")!)) { + result.push(Visibility.Create); + } + if (lifecycle.members.get("Update") && visibility.has(lifecycle.members.get("Update")!)) { + result.push(Visibility.Update); + } + if (lifecycle.members.get("Delete") && visibility.has(lifecycle.members.get("Delete")!)) { + result.push(Visibility.Delete); + } + if (lifecycle.members.get("Query") && visibility.has(lifecycle.members.get("Query")!)) { + result.push(Visibility.Query); + } + return result; + } + return undefined; +} + +function getSdkCredentialType( + context: TCGCContext, + client: SdkClientType, + authentication: Authentication, +): SdkCredentialType | SdkUnionType { + const credentialTypes: SdkCredentialType[] = []; + for (const option of authentication.options) { + for (const scheme of option.schemes) { + credentialTypes.push({ + // Multiple services only deal with the first server config + __raw: client.__raw.services[0], + kind: "credential", + scheme: scheme, + decorators: [], + }); + } + } + if (credentialTypes.length > 1) { + // Multiple services only deal with the first server config + const service = client.__raw.services[0]; + return { + __raw: service, + kind: "union", + variantTypes: credentialTypes, + name: createGeneratedName(context, service, "CredentialUnion"), + isGeneratedName: true, + namespace: client.namespace, + clientNamespace: client.namespace, + crossLanguageDefinitionId: `${client.crossLanguageDefinitionId}.CredentialUnion`, + decorators: [], + access: "public", + usage: UsageFlags.None, + } as SdkUnionType; + } + return credentialTypes[0]; +} + +export function getSdkCredentialParameter( + context: TCGCContext, + client: SdkClientType, +): SdkCredentialParameter | undefined { + // Multiple services only deal with the first server config + const service = client.__raw.services[0]; + const auth = getAuthentication(context.program, service); + if (!auth) return undefined; + return { + type: getSdkCredentialType(context, client, auth), + kind: "credential", + name: "credential", + isGeneratedName: true, + doc: "Credential used to authenticate requests to the service.", + apiVersions: client.apiVersions, + onClient: true, + optional: false, + isApiVersionParam: false, + crossLanguageDefinitionId: `${client.crossLanguageDefinitionId}.credential`, + decorators: [], + access: "public", + flatten: false, + }; +} + +export function getSdkModelPropertyTypeBase( + context: TCGCContext, + type: ModelProperty, + operation?: Operation, +): [SdkModelPropertyTypeBase, readonly Diagnostic[]] { + const diagnostics = createDiagnosticCollector(); + // get api version info so we can cache info about its api versions before we get to property type level + const apiVersions = getAvailableApiVersions(context, type, operation || type.model); + const propertyType = + getSdkTypeFromAlternateType(context, type, operation) || + diagnostics.pipe(getClientTypeWithDiagnostics(context, type.type, operation)); + diagnostics.pipe(addEncodeInfo(context, type, propertyType)); + const name = getPropertyNames(context, type)[0]; + const onClient = isOnClient(context, type, operation, apiVersions.length > 0); + let encode: ArrayKnownEncoding | undefined = undefined; + + const encodeData = getEncode(context.program, type); + // We only support array encoding at property level for now + if (encodeData?.encoding === "ArrayEncoding.pipeDelimited") { + encode = "pipeDelimited"; + } else if (encodeData?.encoding === "ArrayEncoding.spaceDelimited") { + encode = "spaceDelimited"; + } else if (encodeData?.encoding === "ArrayEncoding.commaDelimited") { + encode = "commaDelimited"; + } else if (encodeData?.encoding === "ArrayEncoding.newlineDelimited") { + encode = "newlineDelimited"; + } + const clientDefaultValue = getClientDefaultValue(context, type); + return diagnostics.wrap({ + __raw: type, + doc: getClientDoc(context, type), + summary: getSummary(context.program, type), + apiVersions, + type: propertyType, + name, + isGeneratedName: false, + optional: type.optional, + ...updateWithApiVersionInformation( + context, + type, + operation ? context.getClientForOperation(operation) : undefined, + operation, + ), + onClient, + crossLanguageDefinitionId: getCrossLanguageDefinitionId(context, type, operation), + decorators: diagnostics.pipe(getTypeDecorators(context, type)), + visibility: getSdkVisibility(context, type), + access: getAccess(context, type), + flatten: shouldFlattenProperty(context, type), + encode, + ...(clientDefaultValue !== undefined && { clientDefaultValue }), + }); +} + +function isFilePart(context: TCGCContext, type: SdkType): boolean { + if (type.kind === "array") { + // HttpFile[] or HttpPart<{@body body: bytes}>[] + return isFilePart(context, type.valueType); + } else if (type.kind === "bytes") { + // Http or HttpPart<{@body body: bytes}> + return true; + } else if (type.kind === "model") { + if (type.__raw && isOrExtendsHttpFile(context.program, type.__raw)) { + // Http or HttpPart<{@body body: File}> + return true; + } + } + return false; +} + +export function getSdkModelPropertyType( + context: TCGCContext, + type: ModelProperty, + operation?: Operation, +): [SdkModelPropertyType, readonly Diagnostic[]] { + const diagnostics = createDiagnosticCollector(); + + let property = context.__modelPropertyCache?.get(type); + + if (!property) { + property = { + ...diagnostics.pipe(getSdkModelPropertyTypeBase(context, type, operation)), + kind: "property", + optional: type.optional, + discriminator: false, + serializedName: getPropertyNames(context, type)[1], + isMultipartFileInput: false, + serializationOptions: {}, + }; + context.__modelPropertyCache.set(type, property); + } + return diagnostics.wrap(property); +} + +function addPropertiesToModelType( + context: TCGCContext, + type: Model, + sdkType: SdkModelType, + operation?: Operation, +): [void, readonly Diagnostic[]] { + const diagnostics = createDiagnosticCollector(); + for (const property of type.properties.values()) { + if ( + isStatusCode(context.program, property) || + isNeverOrVoidType(property.type) || + hasNoneVisibility(context, property) || + !isInScope(context, property) + ) { + continue; + } + // For naming context, pass undefined for named unions so that anonymous types + // inside the union get named relative to the property (e.g., "TestModelProp") + // rather than relative to the union (e.g., "TestNullable1"). + // For models, we still want to pass the type so nested anonymous types + // get named relative to the intermediate model (e.g., "BP2ForB"). + const propType = property.type; + const contextType = + propType.kind === "Union" && propType.name ? undefined : (propType as ContextNode["type"]); + pushNamingContext(context, property.name, contextType); + const clientProperty = diagnostics.pipe(getSdkModelPropertyType(context, property, operation)); + popNamingContext(context); + sdkType.properties.push(clientProperty); + } + return diagnostics.wrap(undefined); +} + +function addMultipartPropertiesToModelType( + context: TCGCContext, + sdkType: SdkModelType, + body: HttpOperationMultipartBody, + operation: Operation, +): [void, readonly Diagnostic[]] { + const diagnostics = createDiagnosticCollector(); + for (const part of body.parts) { + // skip properties that are out of scope + if (!isInScope(context, part.property!)) { + continue; + } + const clientProperty = diagnostics.pipe( + getSdkModelPropertyType(context, part.property!, operation), + ); + + // set the type of the client property based on the part body type + const bodyType = getHttpBodyType(part.body); + // Push naming context for the part so nested types get proper names + pushNamingContext(context, pascalCase(part.name!), bodyType as ContextNode["type"]); + if (clientProperty.type.kind === "array") { + clientProperty.type.valueType = diagnostics.pipe( + getClientTypeWithDiagnostics(context, bodyType, operation), + ); + } else { + clientProperty.type = diagnostics.pipe( + getClientTypeWithDiagnostics(context, bodyType, operation), + ); + } + popNamingContext(context); + + // re-apply encoding with the part's default content type so that e.g. bytes + // in multipart/form-data gets encode "bytes" instead of the default "base64" + const partDefaultContentType = + part.body.contentTypes.length > 0 ? part.body.contentTypes[0] : undefined; + const typeToEncode = + clientProperty.type.kind === "array" ? clientProperty.type.valueType : clientProperty.type; + diagnostics.pipe(addEncodeInfo(context, part.property!, typeToEncode, partDefaultContentType)); + + clientProperty.serializationOptions.multipart = { + isFilePart: isFilePart(context, clientProperty.type), + isMulti: part.multi, + filename: part.filename + ? diagnostics.pipe(getSdkModelPropertyType(context, part.filename, operation)) + : undefined, + contentType: part.body.contentTypeProperty + ? diagnostics.pipe( + getSdkModelPropertyType(context, part.body.contentTypeProperty, operation), + ) + : undefined, + defaultContentTypes: part.body.contentTypes, + name: part.name!, + headers: part.headers.map((header) => { + return diagnostics.pipe( + getSdkHttpParameter(context, header.property), + ) as SdkHeaderParameter; + }), + }; + + clientProperty.serializedName = clientProperty.serializationOptions.multipart.name; // eslint-disable-line @typescript-eslint/no-deprecated + clientProperty.isMultipartFileInput = clientProperty.serializationOptions.multipart.isFilePart; // eslint-disable-line @typescript-eslint/no-deprecated + clientProperty.multipartOptions = clientProperty.serializationOptions.multipart; // eslint-disable-line @typescript-eslint/no-deprecated + + sdkType.properties.push(clientProperty); + } + return diagnostics.wrap(undefined); +} + +function updateReferencedTypeMap(context: TCGCContext, type: Type, sdkType: SdkType) { + if ( + sdkType.kind !== "model" && + sdkType.kind !== "enum" && + sdkType.kind !== "union" && + sdkType.kind !== "nullable" + ) { + return; + } + context.__referencedTypeCache!.set(type, sdkType); +} + +interface PropagationOptions { + seenTypes?: Set; + propagation?: boolean; + skipFirst?: boolean; + // this is used to prevent propagation usage from subtype to base type's other subtypes + ignoreSubTypeStack?: boolean[]; + isOverride?: boolean; +} + +export function updateUsageOrAccess( + context: TCGCContext, + value: UsageFlags | AccessFlags, + type?: SdkType, + options?: PropagationOptions, +): [void, readonly Diagnostic[]] { + const diagnostics = createDiagnosticCollector(); + options = options ?? {}; + options.seenTypes = options.seenTypes ?? new Set(); + options.propagation = options?.propagation ?? true; + options.ignoreSubTypeStack = options.ignoreSubTypeStack ?? []; + + if (!type) return diagnostics.wrap(undefined); + if (options.seenTypes.has(type)) { + options.skipFirst = false; + return diagnostics.wrap(undefined); // avoid circular references + } + + if (type.kind === "array" || type.kind === "dict") { + diagnostics.pipe(updateUsageOrAccess(context, value, type.valueType, options)); + return diagnostics.wrap(undefined); + } + if (type.kind === "enumvalue") { + diagnostics.pipe(updateUsageOrAccess(context, value, type.enumType, options)); + return diagnostics.wrap(undefined); + } + + if ( + type.kind !== "model" && + type.kind !== "enum" && + type.kind !== "union" && + type.kind !== "nullable" + ) { + return diagnostics.wrap(undefined); + } + + // For external types, only allow External usage flag to be set and propagated. + // All other usage (Input/Output/Json) and access propagation are blocked. + if (type.external && value !== UsageFlags.External) { + return diagnostics.wrap(undefined); + } + + if (options.ignoreSubTypeStack.length === 0 || !options.ignoreSubTypeStack.at(-1)) { + options.seenTypes.add(type); + } + + if (!options.skipFirst) { + if (typeof value === "number") { + // usage set is always additive + type.usage |= value; + } else { + // access set + if (options.isOverride) { + // when a type has access set to public, it could not be override to internal + if (value === "internal" && type.access === "public" && type.__accessSet) { + diagnostics.add( + createDiagnostic({ + code: "conflict-access-override", + target: type.__raw!, + }), + ); + } else { + type.access = value; + } + } else { + if (!type.__accessSet || type.access !== "public") { + type.access = value; + } + } + type.__accessSet = true; + } + } else { + options.skipFirst = false; + if (typeof value !== "number") { + type.__accessSet = true; + } + } + + if (type.kind === "enum") return diagnostics.wrap(undefined); + if (type.kind === "union") { + for (const unionType of type.variantTypes) { + diagnostics.pipe(updateUsageOrAccess(context, value, unionType, options)); + } + return diagnostics.wrap(undefined); + } + if (type.kind === "nullable") { + diagnostics.pipe(updateUsageOrAccess(context, value, type.type, options)); + return diagnostics.wrap(undefined); + } + + if (!options.propagation) return diagnostics.wrap(undefined); + if (type.baseModel) { + options.ignoreSubTypeStack.push(true); + if ( + context.disableUsageAccessPropagationToBase && + type.baseModel.discriminatorProperty === undefined // For models with discriminators, we should not disable propagation + ) { + options.skipFirst = true; + } + diagnostics.pipe(updateUsageOrAccess(context, value, type.baseModel, options)); + options.ignoreSubTypeStack.pop(); + } + if ( + type.discriminatedSubtypes && + (options.ignoreSubTypeStack.length === 0 || !options.ignoreSubTypeStack.at(-1)) + ) { + for (const discriminatedSubtype of Object.values(type.discriminatedSubtypes)) { + options.ignoreSubTypeStack.push(false); + diagnostics.pipe(updateUsageOrAccess(context, value, discriminatedSubtype, options)); + options.ignoreSubTypeStack.pop(); + } + } + if (type.additionalProperties) { + options.ignoreSubTypeStack.push(false); + diagnostics.pipe(updateUsageOrAccess(context, value, type.additionalProperties, options)); + options.ignoreSubTypeStack.pop(); + } + for (const property of type.properties) { + options.ignoreSubTypeStack.push(false); + if (typeof value === "number") { + let effectiveValue = value; + // Strip Input flag for readonly properties - readonly properties only appear in output + if (property.kind === "property" && isReadOnly(property)) { + effectiveValue = value & ~UsageFlags.Input; + if (effectiveValue === 0) { + options.ignoreSubTypeStack.pop(); + continue; + } + } + diagnostics.pipe(updateUsageOrAccess(context, effectiveValue, property.type, options)); + } else { + // by default, we set property access value to parent. If there's an override though, we override. + let propertyAccess = value; + if (property.__raw) { + const propertyAccessOverride = getAccessOverride(context, property.__raw); + if (propertyAccessOverride) { + propertyAccess = propertyAccessOverride; + } + } + diagnostics.pipe(updateUsageOrAccess(context, propertyAccess, property.type, options)); + } + options.ignoreSubTypeStack.pop(); + } + return diagnostics.wrap(undefined); +} + +function updateTypesFromOperation( + context: TCGCContext, + operation: Operation, +): [void, readonly Diagnostic[]] { + const diagnostics = createDiagnosticCollector(); + const program = context.program; + const httpOperation = getHttpOperationWithCache(context, operation); + const generateConvenient = shouldGenerateConvenient(context, operation); + const overriddenClientMethod = getOverriddenClientMethod(context, operation); + + // Push operation as naming root + pushNamingContext(context, operation.name, operation); + try { + for (const param of httpOperation.parameters.parameters) { + if (isNeverOrVoidType(param.param.type)) continue; + // skip parameters that are out of scope + if (!isInScope(context, param.param)) continue; + pushNamingContext( + context, + `Request${pascalCase(param.name)}`, + param.param.type as ContextNode["type"], + ); + const sdkType = diagnostics.pipe( + getClientTypeWithDiagnostics(context, param.param, operation), + ); + popNamingContext(context); + // Always update input usage for HTTP operation parameters (header, query, path) + // even when generateConvenient is false, so that types like enums are included + diagnostics.pipe(updateUsageOrAccess(context, UsageFlags.Input, sdkType)); + const access = getAccessOverride(context, operation) ?? "public"; + diagnostics.pipe(updateUsageOrAccess(context, access, sdkType)); + } + const httpBody = httpOperation.parameters.body; + if (httpBody && !isNeverOrVoidType(httpBody.type)) { + const spread = isHttpBodySpread(httpBody); + // If the body has a property (from @body decorator), use it to check for alternateType + // Otherwise use the body type directly + const bodyTypeOrProperty = httpBody.property ?? getHttpBodyType(httpBody); + const bodyType = getHttpBodyType(httpBody); + // For named unions, pass undefined so anonymous types inside get named relative + // to the operation (e.g., "PostRequest") rather than the union (e.g., "A1") + const bodyContextType = + bodyType.kind === "Union" && bodyType.name ? undefined : (bodyType as ContextNode["type"]); + pushNamingContext(context, "Request", bodyContextType); + const sdkType = diagnostics.pipe( + getClientTypeWithDiagnostics(context, bodyTypeOrProperty, operation), + ); + popNamingContext(context); + + const multipartRequest = httpBody.bodyKind === "multipart"; + if (generateConvenient) { + if (spread && sdkType.kind === "model") { + updateUsageOrAccess(context, UsageFlags.Spread, sdkType, { propagation: false }); + updateUsageOrAccess(context, UsageFlags.Input, sdkType, { skipFirst: true }); + } else { + updateUsageOrAccess(context, UsageFlags.Input, sdkType); + } + if (httpBody.contentTypes.some((x) => isMediaTypeJson(x))) { + diagnostics.pipe(updateUsageOrAccess(context, UsageFlags.Json, sdkType)); + } + if (httpBody.contentTypes.some((x) => isMediaTypeXml(x))) { + diagnostics.pipe(updateUsageOrAccess(context, UsageFlags.Xml, sdkType)); + } + if (httpBody.contentTypes.includes("application/merge-patch+json")) { + // will also have Json type + diagnostics.pipe(updateUsageOrAccess(context, UsageFlags.JsonMergePatch, sdkType)); + } + if (multipartRequest) { + diagnostics.pipe( + updateUsageOrAccess(context, UsageFlags.MultipartFormData, sdkType, { + propagation: false, + }), + ); + } + + // add serialization options to model type + updateSerializationOptions(context, sdkType, httpBody.contentTypes, undefined, httpBody); + + // after completion of usage calculation for httpBody, check whether it has + // conflicting usage between multipart and regular body + if (sdkType.kind === "model") { + const isUsedInMultipart = (sdkType.usage & UsageFlags.MultipartFormData) > 0; + const isUsedInOthers = + ((sdkType.usage & UsageFlags.Json) | (sdkType.usage & UsageFlags.Xml)) > 0; + if ((!multipartRequest && isUsedInMultipart) || (multipartRequest && isUsedInOthers)) { + // This means we have a model that is used both for formdata input and for regular body input + diagnostics.add( + createDiagnostic({ + code: "conflicting-multipart-model-usage", + target: httpBody.type, + format: { + modelName: sdkType.name, + }, + }), + ); + } + } + } + const access = getAccessOverride(context, operation) ?? "public"; + diagnostics.pipe(updateUsageOrAccess(context, access, sdkType)); + } + // register the streamed payload type for stream request bodies (handles spread streams) + const requestStreamMeta = getStreamMetadata(program, httpOperation.parameters); + if (requestStreamMeta && generateConvenient) { + const sdkStreamType = diagnostics.pipe( + getClientTypeWithDiagnostics(context, requestStreamMeta.streamType, operation), + ); + diagnostics.pipe(updateUsageOrAccess(context, UsageFlags.Input, sdkStreamType)); + if (requestStreamMeta.contentTypes.some((x) => isMediaTypeJson(x))) { + diagnostics.pipe(updateUsageOrAccess(context, UsageFlags.Json, sdkStreamType)); + } + const access = getAccessOverride(context, operation) ?? "public"; + diagnostics.pipe(updateUsageOrAccess(context, access, sdkStreamType)); + } + + // Push "Parameter" context for operation parameters + const paramsModel = (overriddenClientMethod ?? operation).parameters; + pushNamingContext(context, "Parameter", paramsModel); + for (const param of paramsModel.properties.values()) { + if (isNeverOrVoidType(param.type)) continue; + // if it is a body model, skip + if (httpOperation.parameters.body?.property === param) continue; + // skip parameters that are out of scope + if (!isInScope(context, param)) continue; + // if it is a stream model, skip the wrapper but register the streamed payload type + if (param.type.kind === "Model" && isStream(program, param.type)) { + const streamOf = getStreamOf(program, param.type); + if (streamOf && generateConvenient) { + const sdkStreamType = diagnostics.pipe( + getClientTypeWithDiagnostics(context, streamOf, operation), + ); + diagnostics.pipe(updateUsageOrAccess(context, UsageFlags.Input, sdkStreamType)); + const paramStreamMeta = getStreamMetadata(program, httpOperation.parameters); + if (paramStreamMeta?.contentTypes.some((x) => isMediaTypeJson(x))) { + diagnostics.pipe(updateUsageOrAccess(context, UsageFlags.Json, sdkStreamType)); + } + const access = getAccessOverride(context, operation) ?? "public"; + diagnostics.pipe(updateUsageOrAccess(context, access, sdkStreamType)); + } + continue; + } + pushNamingContext(context, param.name, param.type as ContextNode["type"]); + const sdkType = diagnostics.pipe(getClientTypeWithDiagnostics(context, param, operation)); + popNamingContext(context); + if (generateConvenient) { + diagnostics.pipe(updateUsageOrAccess(context, UsageFlags.Input, sdkType)); + } + const access = getAccessOverride(context, operation) ?? "public"; + diagnostics.pipe(updateUsageOrAccess(context, access, sdkType)); + } + popNamingContext(context); + + for (const response of httpOperation.responses) { + for (const innerResponse of response.responses) { + // Process headers BEFORE body so header types get cached with header naming context first. + // This ensures anonymous types in headers use the operation-based path (e.g., "TestResponseRepeatabilityResult") + // rather than the model-property path (e.g., "ResponseWithAnonymousUnionRepeatabilityResult") + const headers = getHttpOperationResponseHeaders(innerResponse); + if (headers) { + for (const header of Object.values(headers)) { + if (isNeverOrVoidType(header.type)) continue; + pushNamingContext( + context, + `Response${pascalCase(header.name)}`, + header.type as ContextNode["type"], + ); + const sdkType = diagnostics.pipe( + getClientTypeWithDiagnostics(context, header.type, operation), + ); + popNamingContext(context); + if (generateConvenient) { + diagnostics.pipe(updateUsageOrAccess(context, UsageFlags.Output, sdkType)); + } + const access = getAccessOverride(context, operation) ?? "public"; + diagnostics.pipe(updateUsageOrAccess(context, access, sdkType)); + } + } + // Process body AFTER headers + if (innerResponse.body?.type && !isNeverOrVoidType(innerResponse.body.type)) { + const body = + innerResponse.body.type.kind === "Model" + ? getEffectivePayloadType(context, innerResponse.body.type, Visibility.Read) + : innerResponse.body.type; + // For named unions, pass undefined so anonymous types inside get named relative + // to the operation rather than the union + const bodyContextType = + body.kind === "Union" && body.name ? undefined : (body as ContextNode["type"]); + pushNamingContext(context, "Response", bodyContextType); + const sdkType = diagnostics.pipe(getClientTypeWithDiagnostics(context, body, operation)); + popNamingContext(context); + if (generateConvenient) { + if (response.statusCodes === "*" || isErrorModel(context.program, body)) { + diagnostics.pipe(updateUsageOrAccess(context, UsageFlags.Exception, sdkType)); + } else { + diagnostics.pipe(updateUsageOrAccess(context, UsageFlags.Output, sdkType)); + } + + if (innerResponse.body.contentTypes.some((x) => isMediaTypeJson(x))) { + diagnostics.pipe(updateUsageOrAccess(context, UsageFlags.Json, sdkType)); + } + + if (innerResponse.body.contentTypes.some((x) => isMediaTypeXml(x))) { + diagnostics.pipe(updateUsageOrAccess(context, UsageFlags.Xml, sdkType)); + } + + if (innerResponse.body.bodyKind === "multipart") { + diagnostics.pipe( + updateUsageOrAccess(context, UsageFlags.MultipartFormData, sdkType, { + propagation: false, + }), + ); + } + + // add serialization options to model type + updateSerializationOptions( + context, + sdkType, + innerResponse.body.contentTypes, + undefined, + innerResponse.body, + ); + } + const access = getAccessOverride(context, operation) ?? "public"; + diagnostics.pipe(updateUsageOrAccess(context, access, sdkType)); + } + // register the streamed payload type for stream responses + const responseStreamMeta = getStreamMetadata(program, innerResponse); + if (responseStreamMeta && generateConvenient) { + const sdkStreamType = diagnostics.pipe( + getClientTypeWithDiagnostics(context, responseStreamMeta.streamType, operation), + ); + diagnostics.pipe(updateUsageOrAccess(context, UsageFlags.Output, sdkStreamType)); + if (responseStreamMeta.contentTypes.some((x) => isMediaTypeJson(x))) { + diagnostics.pipe(updateUsageOrAccess(context, UsageFlags.Json, sdkStreamType)); + } + const access = getAccessOverride(context, operation) ?? "public"; + diagnostics.pipe(updateUsageOrAccess(context, access, sdkStreamType)); + } + } + } + + } finally { + popNamingContext(context); // pop operation + } + return diagnostics.wrap(undefined); +} + +function updateAccessOverride(context: TCGCContext): [void, readonly Diagnostic[]] { + const diagnostics = createDiagnosticCollector(); + // set access for all orphan model without override + for (const sdkType of context.__referencedTypeCache.values()) { + const accessOverride = getAccessOverride(context, sdkType.__raw as any); + if (!sdkType.__accessSet && accessOverride === undefined) { + diagnostics.pipe(updateUsageOrAccess(context, "public", sdkType)); + } + } + for (const sdkType of context.__referencedTypeCache.values()) { + const accessOverride = getAccessOverride(context, sdkType.__raw as any); + if (accessOverride) { + diagnostics.pipe(updateUsageOrAccess(context, accessOverride, sdkType, { isOverride: true })); + } + } + return diagnostics.wrap(undefined); +} + +function updateUsageOverride(context: TCGCContext): [void, readonly Diagnostic[]] { + const diagnostics = createDiagnosticCollector(); + for (const sdkType of context.__referencedTypeCache.values()) { + const usageOverride = getUsageOverride(context, sdkType.__raw as any); + if (usageOverride) { + diagnostics.pipe(updateUsageOrAccess(context, usageOverride, sdkType, { isOverride: true })); + if (usageOverride & UsageFlags.Json) { + // if a type has Json usage, then it should have serialization options + updateSerializationOptions(context, sdkType, ["application/json"]); + } + if (usageOverride & UsageFlags.Xml) { + // if a type has Xml usage, then it should have serialization options + updateSerializationOptions(context, sdkType, ["application/xml"]); + } + } + } + return diagnostics.wrap(undefined); +} + +function updateSpreadModelUsageAndAccess(context: TCGCContext): void { + for (const [_, sdkType] of context.__referencedTypeCache.entries()) { + if ( + sdkType.kind === "model" && + (sdkType.usage & UsageFlags.Spread) > 0 && + (sdkType.usage & (UsageFlags.Input | UsageFlags.Output)) === 0 + ) { + // if a type has spread usage, but not used in any other operation, then set it to be internal + sdkType.access = "internal"; + } + } +} + +function updateExternalUsage(context: TCGCContext): void { + // Propagate External usage flag from external types to their referenced types + for (const [_, sdkType] of context.__referencedTypeCache.entries()) { + if ( + sdkType.external && + (sdkType.kind === "model" || sdkType.kind === "enum" || sdkType.kind === "union") + ) { + // Propagate External usage to the external type itself and all referenced types + updateUsageOrAccess(context, UsageFlags.External, sdkType); + } + } +} + +/** + * Helper function to check if a type has input or output usage + */ +function hasInputOrOutputUsage(usage: number): boolean { + return (usage & (UsageFlags.Input | UsageFlags.Output)) !== 0; +} + +/** + * Clean up discriminator info when discriminated subtypes are missing usage. + * Remove subtypes without usage from the discriminatedSubtypes map to prevent + * language emitters from referencing unavailable types. + */ +function cleanupDiscriminatorForUnusedBase(context: TCGCContext): void { + for (const sdkType of context.__referencedTypeCache.values()) { + if (sdkType.kind !== "model" || !sdkType.discriminatedSubtypes) continue; + + // Remove discriminated subtypes that have no usage + for (const [key, subtype] of Object.entries(sdkType.discriminatedSubtypes)) { + if (!hasInputOrOutputUsage(subtype.usage)) { + delete sdkType.discriminatedSubtypes[key]; + } + } + + // If all subtypes were removed, clear the discriminatedSubtypes entirely + if (Object.keys(sdkType.discriminatedSubtypes).length === 0) { + sdkType.discriminatedSubtypes = undefined; + sdkType.discriminatorProperty = undefined; + } + } +} + +function handleLegacyHierarchyBuilding(context: TCGCContext): [void, readonly Diagnostic[]] { + const diagnostics = createDiagnosticCollector(); + for (const sdkType of context.__referencedTypeCache.values()) { + if (sdkType.kind !== "model" || !sdkType.baseModel) continue; + // if the model has legacyHierarchyBuilding, then we should update its discriminated subtypes + const legacyHierarchyBuilding = getLegacyHierarchyBuilding(context, sdkType.__raw as Model); + + // validate no circular references + const visited = new Set(); + visited.add(sdkType.__raw as Model); + let current: Model | undefined = legacyHierarchyBuilding; + while (current) { + if (visited.has(current)) { + diagnostics.add( + createDiagnostic({ + code: "legacy-hierarchy-building-circular-reference", + target: sdkType.__raw as Model, + }), + ); + return diagnostics.wrap(undefined); + } + visited.add(current); + const changedBase = getLegacyHierarchyBuilding(context, current as Model); + if (changedBase === undefined) { + current = current.baseModel; + } else { + current = changedBase; + } + } + + if (legacyHierarchyBuilding) { + // Reconcile properties on the rebased model: drop duplicates that the + // new base chain already supplies, and lift in properties contributed + // by removed intermediate parents. + diagnostics.pipe(reconcilePropertiesAfterRebase(context, sdkType, legacyHierarchyBuilding)); + } + + // must be done after discriminator is added + // Populate discriminated subtypes for legacy hierarchy building + if (legacyHierarchyBuilding && sdkType.discriminatorValue) { + let currBaseModel: SdkModelType | undefined = sdkType.baseModel; + while (currBaseModel) { + if (!currBaseModel.discriminatedSubtypes) { + currBaseModel.discriminatedSubtypes = {}; + } + currBaseModel.discriminatedSubtypes[sdkType.discriminatorValue] = sdkType; + currBaseModel.discriminatorProperty = currBaseModel.properties.find((p) => p.discriminator); + currBaseModel = currBaseModel.baseModel; + } + + // Filter out legacy hierarchy building properties + sdkType.properties = sdkType.properties.filter((property) => { + return ( + property.discriminator || !legacyHierarchyBuilding.properties.has(property.__raw!.name) + ); + }); + } + } + return diagnostics.wrap(undefined); +} + +/** + * Reconcile properties on a rebased model after `@hierarchyBuilding(oldBase, newBase)`. + * + * Rule: + * 1. Compute `oldBaseEffective` = properties already on `oldBase` (its own + * `properties` Map) plus properties contributed by every removed + * intermediate ancestor. When the same name appears more than once, + * the nearest contributor wins (own > nearest intermediate > farther). + * 2. Compute `newBaseSupplied` = every property name supplied anywhere in + * the new base chain (newBase + its ancestors). + * 3. For each name in `oldBaseEffective` that also appears in + * `newBaseSupplied`: drop it. If the types are assignable in either + * direction, drop silently; otherwise emit + * `legacy-hierarchy-building-conflict` (`property-type-mismatch`). + * 4. Whatever remains becomes the rebased model's own SDK properties. + * Properties already materialized on the SDK model are preserved + * as-is; properties contributed only by removed intermediates are + * materialized via `getSdkModelPropertyType`. + */ +function reconcilePropertiesAfterRebase( + context: TCGCContext, + sdkType: SdkModelType, + newBase: Model, +): [void, readonly Diagnostic[]] { + const diagnostics = createDiagnosticCollector(); + const oldBase = sdkType.__raw as Model; + + const oldBaseChain = walkBaseChain(oldBase); + const newBaseChain = walkBaseChain(newBase); + const newBaseIndex = oldBaseChain.indexOf(newBase); + const removed = newBaseIndex >= 0 ? oldBaseChain.slice(1, newBaseIndex) : oldBaseChain.slice(1); + + // Names supplied anywhere in the new base chain → first ModelProperty seen. + // Apply the same SDK-shape filters as `addPropertiesToModelType` so the + // "supplied by the new base chain" view matches what the SDK actually sees. + const newBaseSupplied = new Map(); + for (const m of newBaseChain) { + for (const [name, prop] of m.properties) { + if (newBaseSupplied.has(name)) continue; + if (!isVisibleSdkProperty(context, prop)) continue; + newBaseSupplied.set(name, prop); + } + } + + // Effective properties on the rebased model before reconciliation: oldBase's + // own first (they win), then nearest-removed-ancestor first. + const oldBaseEffective = new Map(); + for (const [name, prop] of oldBase.properties) { + oldBaseEffective.set(name, prop); + } + for (const intermediate of removed) { + for (const [name, prop] of intermediate.properties) { + if (!oldBaseEffective.has(name)) oldBaseEffective.set(name, prop); + } + } + + // Identify the discriminator property name (if any) on the rebased model — + // it must be preserved across the rebase even when the new base supplies a + // wider-typed property with the same name. + const discriminatorPropName = sdkType.properties.find((p) => p.discriminator)?.__raw?.name; + + const keptProps: SdkModelPropertyType[] = []; + const sdkPropByRawName = new Map(); + for (const prop of sdkType.properties) { + if (prop.__raw?.name) sdkPropByRawName.set(prop.__raw.name, prop); + } + + for (const [name, rawProp] of oldBaseEffective) { + if (!isVisibleSdkProperty(context, rawProp)) { + continue; + } + + const fromOwn = oldBase.properties.has(name); + const isDiscriminator = name === discriminatorPropName; + const newBaseProp = newBaseSupplied.get(name); + + if (newBaseProp && !isDiscriminator) { + if (areTypesIncompatible(context, rawProp.type, newBaseProp.type)) { + diagnostics.add( + createDiagnostic({ + code: "legacy-hierarchy-building-conflict", + messageId: "property-type-mismatch", + format: { + propertyName: name, + childModel: oldBase.name, + parentModel: newBase.name, + }, + target: oldBase, + }), + ); + } + // Drop either way: the new base chain owns this name. + continue; + } + + if (fromOwn) { + const existing = sdkPropByRawName.get(name); + if (existing) keptProps.push(existing); + continue; + } + + // Lift from a removed intermediate. Mirror the contextType logic used in + // `addPropertiesToModelType`: for named unions, pass `undefined` so any + // anonymous variants get named relative to the property rather than + // relative to the union. + const propType = rawProp.type; + const contextType = + propType.kind === "Union" && propType.name ? undefined : (propType as ContextNode["type"]); + pushNamingContext(context, name, contextType); + const lifted = diagnostics.pipe(getSdkModelPropertyType(context, rawProp)); + popNamingContext(context); + keptProps.push(lifted); + } + + sdkType.properties = keptProps; + return diagnostics.wrap(undefined); +} + +/** + * Apply the same predicates as `addPropertiesToModelType` so our view of the + * SDK-observable property set stays aligned with the actual SDK shape. + */ +function isVisibleSdkProperty(context: TCGCContext, property: ModelProperty): boolean { + return ( + !isStatusCode(context.program, property) && + !isNeverOrVoidType(property.type) && + !hasNoneVisibility(context, property) && + isInScope(context, property) + ); +} + +/** + * Check if two property types are incompatible during `@hierarchyBuilding` + * reconciliation. They are considered compatible (no diagnostic) when either + * is assignable to the other under TypeSpec's type system. + */ +function areTypesIncompatible(context: TCGCContext, targetType: Type, newBaseType: Type): boolean { + if (targetType === newBaseType) return false; + const typekit = $(context.program).type; + if (typekit.isAssignableTo(targetType, newBaseType, targetType)) return false; + if (typekit.isAssignableTo(newBaseType, targetType, newBaseType)) return false; + return true; +} + +/** + * Walk a model's raw inheritance chain, returning [model, model.baseModel, ...] + * along the original (pre-rebase) parent links. + */ +function walkBaseChain(model: Model): Model[] { + const chain: Model[] = []; + const seen = new Set(); + let current: Model | undefined = model; + while (current && !seen.has(current)) { + seen.add(current); + chain.push(current); + current = current.baseModel; + } + return chain; +} + +interface UsageFilteringOptions { + input?: boolean; + output?: boolean; +} + +function handleServiceOrphanTypes(context: TCGCContext): [void, readonly Diagnostic[]] { + const diagnostics = createDiagnosticCollector(); + for (const t of listOrphanTypes(context)) { + // skip if already processed + if (context.__referencedTypeCache!.has(t)) { + continue; + } + if (t.kind !== "Enum") { + pushNamingContext(context, t.name ?? "", t); + } + const sdkType = diagnostics.pipe(getClientTypeWithDiagnostics(context, t)); + if (t.kind !== "Enum") { + popNamingContext(context); + } + // add serialization options to model type + updateSerializationOptions(context, sdkType, []); + } + return diagnostics.wrap(undefined); +} + +function filterOutTypes( + context: TCGCContext, + filter: number, +): (SdkModelType | SdkEnumType | SdkUnionType | SdkNullableType)[] { + const seen = new Set(); + const result = new Array(); + for (const sdkType of context.__referencedTypeCache.values()) { + // filter models with unexpected usage + if ((sdkType.usage & filter) === 0) { + continue; + } + if (!seen.has(sdkType)) { + seen.add(sdkType); + result.push(sdkType); + } + } + return result; +} + +export function getAllModelsWithDiagnostics( + context: TCGCContext, + options: UsageFilteringOptions = {}, +): [(SdkModelType | SdkEnumType)[], readonly Diagnostic[]] { + const diagnostics = createDiagnosticCollector(); + return diagnostics.wrap( + filterOutTypes(context, getFilterNumber(options)).filter( + (x) => x.kind === "model" || x.kind === "enum", + ), + ); +} + +export function getAllModels( + context: TCGCContext, + options: UsageFilteringOptions = {}, +): (SdkModelType | SdkEnumType)[] { + // we currently don't return diagnostics even though we keep track of them + // when we move to the new sdk type ecosystem completely, we'll expose + // diagnostics as a separate property on the TCGCContext + return ignoreDiagnostics(getAllModelsWithDiagnostics(context, options)); +} + +function getFilterNumber(options: UsageFilteringOptions = {}): number { + options = { input: true, output: true, ...options }; + let filter = 0; + if (options.input && options.output) { + filter = Number.MAX_SAFE_INTEGER; + } else if (options.input) { + filter += UsageFlags.Input; + } else if (options.output) { + filter += UsageFlags.Output; + } + return filter; +} + +export function getAllReferencedTypes( + context: TCGCContext, + options: UsageFilteringOptions = {}, +): (SdkModelType | SdkEnumType | SdkUnionType | SdkNullableType)[] { + return filterOutTypes(context, getFilterNumber(options)); +} + +export function handleAllTypes(context: TCGCContext): [void, readonly Diagnostic[]] { + const diagnostics = createDiagnosticCollector(); + const services = new Set(); + for (const client of listClients(context)) { + for (const operation of listOperationsInClient(context, client)) { + // operations on a client + diagnostics.pipe(updateTypesFromOperation(context, operation)); + } + for (const sc of listSubClients(context, client, true)) { + for (const operation of listOperationsInClient(context, sc)) { + // operations on sub clients + diagnostics.pipe(updateTypesFromOperation(context, operation)); + } + } + // server parameters + // Multiple services only deal with the first server config + const servers = getServers(context.program, client.services[0]); + if (servers !== undefined && servers[0].parameters !== undefined) { + for (const param of servers[0].parameters.values()) { + const sdkType = diagnostics.pipe(getClientTypeWithDiagnostics(context, param)); + diagnostics.pipe(updateUsageOrAccess(context, UsageFlags.Input, sdkType)); + } + } + client.services.map((s) => services.add(s)); + } + for (const service of services) { + // versioned enums + const versionEnum = context.getPackageVersionEnum().get(service); + const versions = context.getPackageVersions().get(service); + if (versionEnum) { + // create sdk enum for versions enum + let sdkVersionsEnum: SdkEnumType; + const explicitApiVersions = getExplicitClientApiVersions(context, service); + if (explicitApiVersions) { + // add additional api versions to the enum + sdkVersionsEnum = diagnostics.pipe(getSdkEnumWithDiagnostics(context, explicitApiVersions)); + } else { + sdkVersionsEnum = diagnostics.pipe(getSdkEnumWithDiagnostics(context, versionEnum)); + } + filterPreviewVersion(context, sdkVersionsEnum, versions?.at(-1) || ""); + diagnostics.pipe(updateUsageOrAccess(context, UsageFlags.ApiVersionEnum, sdkVersionsEnum)); + } + } + // update for orphan models/enums/unions + diagnostics.pipe(handleServiceOrphanTypes(context)); + // update access + diagnostics.pipe(updateAccessOverride(context)); + // update usage + diagnostics.pipe(updateUsageOverride(context)); + // update spread model + updateSpreadModelUsageAndAccess(context); + // update external usage + updateExternalUsage(context); + // update discriminated subtypes and filter out duplicate properties from `@hierarchyBuilding` + diagnostics.pipe(handleLegacyHierarchyBuilding(context)); + // clean up discriminator info when only subtypes have usage + cleanupDiscriminatorForUnusedBase(context); + // update generated name + resolveConflictGeneratedName(context); + + return diagnostics.wrap(undefined); +} + +function updateSerializationOptions( + context: TCGCContext, + type: SdkType, + contentTypes: string[], + options?: PropagationOptions, + httpBody?: HttpPayloadBody, +) { + options = options ?? {}; + options.seenTypes = options.seenTypes ?? new Set(); + options.propagation = options?.propagation ?? true; + options.ignoreSubTypeStack = options.ignoreSubTypeStack ?? []; + + if (options.seenTypes.has(type)) { + return; // avoid circular references + } + + if (type.kind === "array" || type.kind === "dict") { + updateSerializationOptions(context, type.valueType, contentTypes, options); + return; + } + + if (type.kind !== "model" && type.kind !== "union" && type.kind !== "nullable") return; + + if (options.ignoreSubTypeStack.length === 0 || !options.ignoreSubTypeStack.at(-1)) { + options.seenTypes.add(type); + } + + if (type.kind === "union") { + for (const unionType of type.variantTypes) { + updateSerializationOptions(context, unionType, contentTypes, options); + } + return; + } + if (type.kind === "nullable") { + updateSerializationOptions(context, type.type, contentTypes, options); + return; + } + + // Handle file body serialization - if it's a file, set binary options and skip json/xml + if (httpBody?.bodyKind === "file") { + const fileBody = httpBody as HttpOperationFileBody; + type.serializationOptions.binary = { + isFile: true, + isText: fileBody.isText, + contentTypes: fileBody.contentTypes, + filename: fileBody.filename, + }; + return; // No need to add json/xml serialization for file types + } + + setSerializationOptions(context, type, contentTypes); + + // If the model has serialization options from explicit decorators (not from contentTypes), + // ensure properties also get those serialization options. + // This handles orphan models where contentTypes is empty but the model has XML/JSON decorators. + let effectiveContentTypes = contentTypes; + if (type.serializationOptions.xml && !contentTypes.some(isMediaTypeXml)) { + effectiveContentTypes = [...effectiveContentTypes, "application/xml"]; + } + if (type.serializationOptions.json && !contentTypes.some(isMediaTypeJson)) { + effectiveContentTypes = [...effectiveContentTypes, "application/json"]; + } + + for (const property of type.properties) { + if (property.kind === "property") { + setSerializationOptions(context, property, effectiveContentTypes); + } + } + + if (type.baseModel) { + options.ignoreSubTypeStack.push(true); + updateSerializationOptions(context, type.baseModel, contentTypes, options); + options.ignoreSubTypeStack.pop(); + } + if ( + type.discriminatedSubtypes && + (options.ignoreSubTypeStack.length === 0 || !options.ignoreSubTypeStack.at(-1)) + ) { + for (const discriminatedSubtype of Object.values(type.discriminatedSubtypes)) { + options.ignoreSubTypeStack.push(false); + updateSerializationOptions(context, discriminatedSubtype, contentTypes, options); + options.ignoreSubTypeStack.pop(); + } + } + if (type.additionalProperties) { + options.ignoreSubTypeStack.push(false); + updateSerializationOptions(context, type.additionalProperties, contentTypes, options); + options.ignoreSubTypeStack.pop(); + } + for (const property of type.properties) { + options.ignoreSubTypeStack.push(false); + updateSerializationOptions(context, property.type, contentTypes, options); + options.ignoreSubTypeStack.pop(); + } + return; +} + +function setSerializationOptions( + context: TCGCContext, + type: SdkModelType | SdkModelPropertyType, + contentTypes: string[], +) { + for (const contentType of contentTypes) { + if (isMediaTypeJson(contentType) && !type.serializationOptions.json) { + updateJsonSerializationOptions(context, type); + } + + if (isMediaTypeXml(contentType) && !type.serializationOptions.xml) { + updateXmlSerializationOptions(context, type); + } + } + if ( + !type.serializationOptions.json && + type.__raw && + hasExplicitlyDefinedJsonSerializationInfo(context, type.__raw) + ) { + updateJsonSerializationOptions(context, type); + } + if ( + !type.serializationOptions.xml && + type.__raw && + hasExplicitlyDefinedXmlSerializationInfo(context, type.__raw) + ) { + updateXmlSerializationOptions(context, type); + } + const defaultContentTypes = type.serializationOptions.multipart?.defaultContentTypes; + if (defaultContentTypes && type.kind === "property" && type.type.kind === "model") { + for (const prop of type.type.properties) { + if (prop.kind === "property") { + setSerializationOptions(context, prop, defaultContentTypes); + } + } + } +} + +function updateJsonSerializationOptions( + context: TCGCContext, + type: SdkModelType | SdkModelPropertyType, +) { + type.serializationOptions.json = { + name: + type.__raw?.kind === "Model" || type.__raw?.kind === "ModelProperty" + ? resolveEncodedName(context.program, type.__raw, "application/json") + : type.name, + }; +} + +function updateXmlSerializationOptions( + context: TCGCContext, + type: SdkModelType | SdkModelPropertyType, +) { + type.serializationOptions.xml = { + name: + type.__raw?.kind === "Model" || type.__raw?.kind === "ModelProperty" + ? resolveEncodedName(context.program, type.__raw, "application/xml") + : type.name, + attribute: type.__raw?.kind === "ModelProperty" && isAttribute(context.program, type.__raw), + ns: type.__raw ? getNs(context.program, type.__raw) : undefined, + unwrapped: type.__raw?.kind === "ModelProperty" && isUnwrapped(context.program, type.__raw), + }; + + // set extra serialization info for array property + if ( + type.__raw?.kind === "ModelProperty" && + type.__raw.type.kind === "Model" && + isArrayModelType(type.__raw.type) + ) { + if (!type.serializationOptions.xml.unwrapped) { + // if wrapped, set itemsName and itemsNS according to the array item type + const itemType = type.__raw.type.indexer.value; + if ("name" in itemType) { + // if the type has name then get the name + type.serializationOptions.xml.itemsName = resolveEncodedName( + context.program, + itemType as Type & { name: string }, + "application/xml", + ); + type.serializationOptions.xml.itemsNs = getNs(context.program, itemType); + } else { + // otherwise use the property name + type.serializationOptions.xml.itemsName = type.serializationOptions.xml.name; + type.serializationOptions.xml.itemsNs = type.serializationOptions.xml.ns; + } + } else { + // if unwrapped, always set itemName to property name + type.serializationOptions.xml.itemsName = type.serializationOptions.xml.name; + type.serializationOptions.xml.itemsNs = type.serializationOptions.xml.ns; + } + } +} + +function hasExplicitlyDefinedXmlSerializationInfo(context: TCGCContext, type: Type): boolean { + if (type.kind === "Model" || type.kind === "ModelProperty" || type.kind === "Scalar") { + if (type.decorators && type.decorators.some((d) => d.definition?.namespace.name === "Xml")) { + return true; + } + const xmlName = resolveEncodedName(context.program, type, "application/xml"); + if (xmlName && xmlName !== type.name) { + return true; + } + } + if (type.kind === "ModelProperty" && type.type.kind === "Model" && isArrayModelType(type.type)) { + const itemType = type.type.indexer.value; + if (itemType && hasExplicitlyDefinedXmlSerializationInfo(context, itemType)) { + return true; + } + } + return false; +} + +function hasExplicitlyDefinedJsonSerializationInfo(context: TCGCContext, type: Type): boolean { + if (type.kind === "ModelProperty") { + const jsonName = resolveEncodedName(context.program, type, "application/json"); + if (jsonName && jsonName !== type.name) { + return true; + } + } + return false; +} + +function getSdkTypeFromAlternateType( + context: TCGCContext, + type: Enum | Model | ModelProperty | Scalar | Union, + operation?: Operation, +): SdkType | undefined { + const alternateType = getAlternateType(context, type); + if (!alternateType || alternateType?.kind === "externalTypeInfo") { + return undefined; + } + return ignoreDiagnostics(getClientTypeWithDiagnostics(context, alternateType, operation)); +} diff --git a/packages/http-client-generator-core/src/validate.ts b/packages/http-client-generator-core/src/validate.ts new file mode 100644 index 00000000000..507b84b7aa3 --- /dev/null +++ b/packages/http-client-generator-core/src/validate.ts @@ -0,0 +1,19 @@ +import { Program } from "@typespec/compiler"; +import { createTCGCContext } from "./context.js"; +import { validateClients } from "./validations/clients.js"; +import { validateHttp } from "./validations/http.js"; +import { validateMethods } from "./validations/methods.js"; +import { validatePackage } from "./validations/package.js"; +import { validateTypes } from "./validations/types.js"; + +export function $onValidate(program: Program) { + const tcgcContext = createTCGCContext(program, "@typespec/http-client-generator-core", { + mutateNamespace: false, + }); + + validatePackage(tcgcContext); + validateClients(tcgcContext); + validateMethods(tcgcContext); + validateHttp(tcgcContext); + validateTypes(tcgcContext); +} diff --git a/packages/http-client-generator-core/src/validations/clients.ts b/packages/http-client-generator-core/src/validations/clients.ts new file mode 100644 index 00000000000..bccc2734077 --- /dev/null +++ b/packages/http-client-generator-core/src/validations/clients.ts @@ -0,0 +1,3 @@ +import { TCGCContext } from "../interfaces.js"; + +export function validateClients(context: TCGCContext) {} diff --git a/packages/http-client-generator-core/src/validations/http.ts b/packages/http-client-generator-core/src/validations/http.ts new file mode 100644 index 00000000000..6458e1a9f13 --- /dev/null +++ b/packages/http-client-generator-core/src/validations/http.ts @@ -0,0 +1,3 @@ +import { TCGCContext } from "../interfaces.js"; + +export function validateHttp(context: TCGCContext) {} diff --git a/packages/http-client-generator-core/src/validations/methods.ts b/packages/http-client-generator-core/src/validations/methods.ts new file mode 100644 index 00000000000..00587d35ea7 --- /dev/null +++ b/packages/http-client-generator-core/src/validations/methods.ts @@ -0,0 +1,25 @@ +import { getClientNameOverride } from "../decorators.js"; +import { TCGCContext } from "../interfaces.js"; +import { listScopedDecoratorData, overrideKey } from "../internal-utils.js"; +import { reportDiagnostic } from "../lib.js"; + +export function validateMethods(context: TCGCContext) { + validateClientNameNotOnOverriddenMethods(context); +} + +function validateClientNameNotOnOverriddenMethods(context: TCGCContext) { + for (const [original, override] of listScopedDecoratorData(context, overrideKey)) { + const clientNameOverride = getClientNameOverride(context, override); + if (clientNameOverride) { + reportDiagnostic(context.program, { + code: "client-name-ineffective", + messageId: "override", + target: override, + format: { + name: clientNameOverride, + originalMethodName: original.kind === "Operation" ? original.name : "", + }, + }); + } + } +} diff --git a/packages/http-client-generator-core/src/validations/package.ts b/packages/http-client-generator-core/src/validations/package.ts new file mode 100644 index 00000000000..3ee937cd940 --- /dev/null +++ b/packages/http-client-generator-core/src/validations/package.ts @@ -0,0 +1,64 @@ +import { getNamespaceFullName, Namespace } from "@typespec/compiler"; +import { getVersions } from "@typespec/versioning"; +import { getExplicitClientApiVersions } from "../decorators.js"; +import { TCGCContext } from "../interfaces.js"; +import { listAllUserDefinedNamespaces } from "../internal-utils.js"; +import { reportDiagnostic } from "../lib.js"; + +export function validatePackage(context: TCGCContext) { + validateNamespaces(context); +} + +function validateNamespaces(context: TCGCContext) { + for (const namespace of listAllUserDefinedNamespaces(context)) { + validateDecoratorsAppliedToVersionedService(context, namespace); + validateClientApiVersionsIncludesAllServiceVersions(context, namespace); + } +} +function validateDecoratorsAppliedToVersionedService(context: TCGCContext, namespace: Namespace) { + const versions = getVersions(context.program, namespace)[1]; + if ( + (versions === undefined || versions.getVersions().length === 0) && + getExplicitClientApiVersions(context, namespace) + ) { + reportDiagnostic(context.program, { + code: "require-versioned-service", + format: { + serviceName: getNamespaceFullName(namespace), + decoratorName: "@clientApiVersions", + }, + target: namespace, + }); + } +} + +function validateClientApiVersionsIncludesAllServiceVersions( + context: TCGCContext, + namespace: Namespace, +) { + const versions = getVersions(context.program, namespace)[1]; + if (versions === undefined || versions.getVersions().length === 0) { + return; + } + const clientApiVersionsEnum = getExplicitClientApiVersions(context, namespace); + if (clientApiVersionsEnum === undefined) { + return; + } + const clientApiVersions = [...clientApiVersionsEnum.members.values()].map( + (x) => x.value ?? x.name, + ); + const missingVersions = versions + .getVersions() + .map((x) => x.value) + .filter((version) => !clientApiVersions.includes(version)); + if (missingVersions.length > 0) { + reportDiagnostic(context.program, { + code: "missing-service-versions", + format: { + serviceName: getNamespaceFullName(namespace), + missingVersions: missingVersions.join(", "), + }, + target: namespace, + }); + } +} diff --git a/packages/http-client-generator-core/src/validations/types.ts b/packages/http-client-generator-core/src/validations/types.ts new file mode 100644 index 00000000000..531abd97019 --- /dev/null +++ b/packages/http-client-generator-core/src/validations/types.ts @@ -0,0 +1,275 @@ +import { + Enum, + EnumMember, + Interface, + Model, + ModelProperty, + Namespace, + Operation, + Program, + Scalar, + Type, + Union, + UnionVariant, +} from "@typespec/compiler"; +import { AugmentDecoratorStatementNode, DecoratorExpressionNode } from "@typespec/compiler/ast"; +import { unsafe_Realm } from "@typespec/compiler/experimental"; +import { DuplicateTracker } from "@typespec/compiler/utils"; +import { getClientNameOverride } from "../decorators.js"; +import { TCGCContext } from "../interfaces.js"; +import { + AllScopes, + clientLocationKey, + clientNameKey, + listScopedDecoratorData, +} from "../internal-utils.js"; +import { reportDiagnostic } from "../lib.js"; + +export function validateTypes(context: TCGCContext) { + validateClientNames(context); +} + +/** + * Validate naming with `@clientName` and `@clientLocation` decorators. + * + * This function checks for duplicate client names for types considering the impact of `@clientName` for all possible scopes. + * It also handles the movement of operations to new clients based on the `@clientLocation` decorators. + * + * @param tcgcContext The context for the TypeSpec Client Generator. + */ +function validateClientNames(tcgcContext: TCGCContext) { + const languageScopes = getDefinedLanguageScopes(tcgcContext.program); + + // Check all possible language scopes + for (const scope of languageScopes) { + // Gather all moved operations and their targets + const moved = new Set(); + const movedTo = new Map(); + const newClients = new Map(); + // Cache all `@clientName` overrides for the current scope + for (const [type, target] of listScopedDecoratorData( + tcgcContext, + clientLocationKey, + scope, + ).entries()) { + if (unsafe_Realm.realmForType.has(type)) { + // Skip `@clientName` on versioning types + continue; + } + if (type.kind === "Operation") { + moved.add(type); + if (typeof target === "string") { + // Move to new clients + if (!newClients.has(target)) { + newClients.set(target, [type]); + } else { + newClients.get(target)!.push(type); + } + } else { + // Move to existing clients + if (!movedTo.has(target)) { + movedTo.set(target, [type]); + } else { + movedTo.get(target)!.push(type); + } + } + } + } + + // Validate client names for the current scope + validateClientNamesPerNamespace( + tcgcContext, + scope, + moved, + movedTo, + tcgcContext.program.getGlobalNamespaceType(), + ); + + // Validate client names for new client's operations + [...newClients.values()].map((operations) => { + validateClientNamesCore(tcgcContext, scope, operations); + }); + } +} + +function getDefinedLanguageScopes(program: Program): Set { + const languageScopes = new Set(); + const impacted = [...program.stateMap(clientNameKey).values()]; + impacted.push(...program.stateMap(clientLocationKey).values()); + for (const value of impacted) { + if (value[AllScopes]) { + languageScopes.add(AllScopes); + } + for (const languageScope of Object.keys(value)) { + languageScopes.add(languageScope); + } + } + return languageScopes; +} + +function* adjustOperations( + iterator: MapIterator, + moved: Set, + movedTo: Map, + container: Namespace | Interface, +): MapIterator { + for (const operation of iterator) { + if (moved.has(operation)) { + continue; + } else { + yield operation; + } + } + if (movedTo.has(container)) { + for (const operation of movedTo.get(container)!) { + yield operation; + } + } +} + +function validateClientNamesPerNamespace( + tcgcContext: TCGCContext, + scope: string | typeof AllScopes, + moved: Set, + movedTo: Map, + namespace: Namespace, +) { + // Check for duplicate client names for models, enums, and unions + validateClientNamesCore(tcgcContext, scope, [ + ...namespace.models.values(), + ...namespace.enums.values(), + ...namespace.unions.values(), + ]); + + // Check for duplicate client names for operations + validateClientNamesCore( + tcgcContext, + scope, + adjustOperations(namespace.operations.values(), moved, movedTo, namespace), + ); + + // check for duplicate client names for operations in interfaces + for (const item of namespace.interfaces.values()) { + validateClientNamesCore( + tcgcContext, + scope, + adjustOperations(item.operations.values(), moved, movedTo, item), + ); + } + + // Check for duplicate client names for interfaces + validateClientNamesCore(tcgcContext, scope, namespace.interfaces.values()); + + // Check for duplicate client names for scalars + validateClientNamesCore(tcgcContext, scope, namespace.scalars.values()); + + // Check for duplicate client names for namespaces + validateClientNamesCore(tcgcContext, scope, namespace.namespaces.values()); + + // Check for duplicate client names for model properties + for (const model of namespace.models.values()) { + validateClientNamesCore(tcgcContext, scope, model.properties.values()); + } + + // Check for duplicate client names for enum members + for (const item of namespace.enums.values()) { + validateClientNamesCore(tcgcContext, scope, item.members.values()); + } + + // Check for duplicate client names for union variants + for (const item of namespace.unions.values()) { + validateClientNamesCore(tcgcContext, scope, item.variants.values()); + } + + // Check for duplicate client names for nested namespaces + for (const item of namespace.namespaces.values()) { + validateClientNamesPerNamespace(tcgcContext, scope, moved, movedTo, item); + } +} + +function validateClientNamesCore( + tcgcContext: TCGCContext, + scope: string | typeof AllScopes, + items: Iterable< + | Namespace + | Scalar + | Operation + | Interface + | Model + | Enum + | Union + | ModelProperty + | EnumMember + | UnionVariant + >, +) { + const duplicateTracker = new DuplicateTracker< + string, + Type | [Type, DecoratorExpressionNode | AugmentDecoratorStatementNode] + >(); + + for (const item of items) { + const clientName = getClientNameOverride(tcgcContext, item, scope); + if (clientName !== undefined) { + const clientNameDecorator = item.decorators.find((x) => x.definition?.name === "@clientName"); + if (clientNameDecorator?.node !== undefined) { + duplicateTracker.track(clientName, [item, clientNameDecorator.node]); + } + } else { + if (item.name !== undefined && typeof item.name === "string") { + duplicateTracker.track(item.name, item); + } + } + } + + reportDuplicateClientNames(tcgcContext.program, duplicateTracker, scope); +} + +function reportDuplicateClientNames( + program: Program, + duplicateTracker: DuplicateTracker< + string, + Type | [Type, DecoratorExpressionNode | AugmentDecoratorStatementNode] + >, + scope: string | typeof AllScopes, +) { + for (const [name, duplicates] of duplicateTracker.entries()) { + for (const item of duplicates) { + const scopeStr = scope === AllScopes ? "AllScopes" : scope; + if (Array.isArray(item)) { + // If the item is a decorator application + if (scope === "csharp" && item[0].kind === "Operation") { + // .NET support operations with same name with overloads + reportDiagnostic(program, { + code: "duplicate-client-name-warning", + format: { name, scope: scopeStr }, + target: item[1], + }); + } else { + reportDiagnostic(program, { + code: "duplicate-client-name", + format: { name, scope: scopeStr }, + target: item[1], + }); + } + } else { + if (scope === "csharp" && item.kind === "Operation") { + // .NET support operations with same name with overloads + reportDiagnostic(program, { + code: "duplicate-client-name-warning", + messageId: "nonDecorator", + format: { name, scope: scopeStr }, + target: item, + }); + } else { + reportDiagnostic(program, { + code: "duplicate-client-name", + messageId: "nonDecorator", + format: { name, scope: scopeStr }, + target: item, + }); + } + } + } + } +} diff --git a/packages/http-client-generator-core/tsconfig.build.json b/packages/http-client-generator-core/tsconfig.build.json new file mode 100644 index 00000000000..a10a9961b1a --- /dev/null +++ b/packages/http-client-generator-core/tsconfig.build.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": ".", + "outDir": "./dist", + "tsBuildInfoFile": "./temp/tsconfig.build.tsbuildinfo" + }, + "include": ["src/**/*.ts", "generated-defs/**/*.ts"], + "exclude": ["node_modules", "dist", "test"] +} diff --git a/packages/http-client-generator-core/tsconfig.json b/packages/http-client-generator-core/tsconfig.json new file mode 100644 index 00000000000..ce7ad0fb415 --- /dev/null +++ b/packages/http-client-generator-core/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": ".", + "outDir": "./dist" + }, + "include": ["src/**/*.ts", "test/**/*.ts"] +} diff --git a/packages/http-client-generator-core/vitest.config.ts b/packages/http-client-generator-core/vitest.config.ts new file mode 100644 index 00000000000..8f387af4a9b --- /dev/null +++ b/packages/http-client-generator-core/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + isolate: false, + }, +}); diff --git a/packages/http-client-python/emitter/src/code-model.ts b/packages/http-client-python/emitter/src/code-model.ts index 2f00e341505..b12e97b93bf 100644 --- a/packages/http-client-python/emitter/src/code-model.ts +++ b/packages/http-client-python/emitter/src/code-model.ts @@ -1,18 +1,20 @@ import { - SdkBasicServiceMethod, - SdkClientType, SdkCredentialParameter, SdkCredentialType, SdkEndpointParameter, SdkEndpointType, + SdkMethodParameter, + SdkUnionType, + UsageFlags, +} from "@typespec/http-client-generator-core"; +import { + SdkBasicServiceMethod, + SdkClientType, SdkLroPagingServiceMethod, SdkLroServiceMethod, - SdkMethodParameter, SdkPagingServiceMethod, SdkServiceMethod, SdkServiceOperation, - SdkUnionType, - UsageFlags, getCrossLanguagePackageId, isAzureCoreModel, } from "@azure-tools/typespec-client-generator-core"; diff --git a/packages/http-client-python/emitter/src/http.ts b/packages/http-client-python/emitter/src/http.ts index 07104c972ef..8b262521119 100644 --- a/packages/http-client-python/emitter/src/http.ts +++ b/packages/http-client-python/emitter/src/http.ts @@ -1,5 +1,9 @@ import { getNamespaceFullName, NoTarget } from "@typespec/compiler"; +import { + SdkType, + UsageFlags, +} from "@typespec/http-client-generator-core"; import { getHttpOperationParameter, SdkBasicServiceMethod, @@ -19,8 +23,6 @@ import { SdkQueryParameter, SdkServiceMethod, SdkServiceResponseHeader, - SdkType, - UsageFlags, } from "@azure-tools/typespec-client-generator-core"; import { HttpStatusCodeRange } from "@typespec/http"; import { PythonSdkContext, reportDiagnostic } from "./lib.js"; diff --git a/packages/http-client-python/emitter/src/lib.ts b/packages/http-client-python/emitter/src/lib.ts index b267c836bf3..b7b5dca9c5f 100644 --- a/packages/http-client-python/emitter/src/lib.ts +++ b/packages/http-client-python/emitter/src/lib.ts @@ -1,8 +1,8 @@ import { - SdkContext, SdkType, UnbrandedSdkEmitterOptions, -} from "@azure-tools/typespec-client-generator-core"; +} from "@typespec/http-client-generator-core"; +import { SdkContext } from "@azure-tools/typespec-client-generator-core"; import { createTypeSpecLibrary, JSONSchemaType, paramMessage } from "@typespec/compiler"; export interface PythonEmitterOptions { diff --git a/packages/http-client-python/emitter/src/types.ts b/packages/http-client-python/emitter/src/types.ts index 89f35478abe..42dba82dad6 100644 --- a/packages/http-client-python/emitter/src/types.ts +++ b/packages/http-client-python/emitter/src/types.ts @@ -1,5 +1,4 @@ import { - isHttpMetadata, SdkArrayType, SdkBuiltInType, SdkConstantType, @@ -15,7 +14,8 @@ import { SdkType, SdkUnionType, UsageFlags, -} from "@azure-tools/typespec-client-generator-core"; +} from "@typespec/http-client-generator-core"; +import { isHttpMetadata } from "@azure-tools/typespec-client-generator-core"; import { Type } from "@typespec/compiler"; import { HttpAuth, Visibility } from "@typespec/http"; import { dump } from "js-yaml"; diff --git a/packages/http-client-python/emitter/src/utils.ts b/packages/http-client-python/emitter/src/utils.ts index 9b30231a71d..1d90294f6a6 100644 --- a/packages/http-client-python/emitter/src/utils.ts +++ b/packages/http-client-python/emitter/src/utils.ts @@ -1,5 +1,8 @@ import { InitializedByFlags, + SdkType, +} from "@typespec/http-client-generator-core"; +import { SdkCredentialParameter, SdkEndpointParameter, SdkHeaderParameter, @@ -11,7 +14,6 @@ import { SdkServiceMethod, SdkServiceOperation, SdkServiceResponseHeader, - SdkType, } from "@azure-tools/typespec-client-generator-core"; import { getNamespaceFullName } from "@typespec/compiler"; import { marked, Token } from "marked"; diff --git a/packages/http-client-python/eng/scripts/Initialize-Repository.ps1 b/packages/http-client-python/eng/scripts/Initialize-Repository.ps1 index 4fc04484175..095671b8d7a 100644 --- a/packages/http-client-python/eng/scripts/Initialize-Repository.ps1 +++ b/packages/http-client-python/eng/scripts/Initialize-Repository.ps1 @@ -40,7 +40,7 @@ try { Write-Host "Installing npm dependencies..." Invoke-LoggedCommand "npm ci" - Invoke-LoggedCommand "npm ls -a" -GroupOutput + # Invoke-LoggedCommand "npm ls -a" -GroupOutput # Copy lock files to artifacts for CI caching (if running in Azure DevOps) $artifactStagingDirectory = $env:BUILD_ARTIFACTSTAGINGDIRECTORY diff --git a/packages/http-client-python/package-lock.json b/packages/http-client-python/package-lock.json index 610a53f6ae3..32bd93ebc6d 100644 --- a/packages/http-client-python/package-lock.json +++ b/packages/http-client-python/package-lock.json @@ -29,6 +29,7 @@ "@typespec/compiler": "^1.12.0", "@typespec/events": "~0.82.0", "@typespec/http": "^1.12.0", + "@typespec/http-client-generator-core": "../http-client-generator-core", "@typespec/http-specs": "0.1.0-alpha.37", "@typespec/openapi": "^1.12.0", "@typespec/rest": "~0.82.0", @@ -65,6 +66,48 @@ "@typespec/xml": ">=0.82.0 <1.0.0" } }, + "../http-client-generator-core": { + "name": "@typespec/http-client-generator-core", + "version": "0.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "change-case": "catalog:", + "pluralize": "catalog:" + }, + "devDependencies": { + "@types/node": "catalog:", + "@types/pluralize": "catalog:", + "@typespec/compiler": "workspace:^", + "@typespec/events": "workspace:^", + "@typespec/http": "workspace:^", + "@typespec/openapi": "workspace:^", + "@typespec/rest": "workspace:^", + "@typespec/sse": "workspace:^", + "@typespec/streams": "workspace:^", + "@typespec/versioning": "workspace:^", + "@typespec/xml": "workspace:^", + "@vitest/coverage-v8": "catalog:", + "@vitest/ui": "catalog:", + "rimraf": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" + }, + "engines": { + "node": ">=22.0.0" + }, + "peerDependencies": { + "@typespec/compiler": "workspace:^", + "@typespec/events": "workspace:^", + "@typespec/http": "workspace:^", + "@typespec/openapi": "workspace:^", + "@typespec/rest": "workspace:^", + "@typespec/sse": "workspace:^", + "@typespec/streams": "workspace:^", + "@typespec/versioning": "workspace:^", + "@typespec/xml": "workspace:^" + } + }, "node_modules/@azure-tools/azure-http-specs": { "version": "0.1.0-alpha.40", "resolved": "https://registry.npmjs.org/@azure-tools/azure-http-specs/-/azure-http-specs-0.1.0-alpha.40.tgz", @@ -2518,6 +2561,10 @@ } } }, + "node_modules/@typespec/http-client-generator-core": { + "resolved": "../http-client-generator-core", + "link": true + }, "node_modules/@typespec/http-specs": { "version": "0.1.0-alpha.37", "resolved": "https://registry.npmjs.org/@typespec/http-specs/-/http-specs-0.1.0-alpha.37.tgz", diff --git a/packages/http-client-python/package.json b/packages/http-client-python/package.json index b4edd33cca2..ffd747e28cc 100644 --- a/packages/http-client-python/package.json +++ b/packages/http-client-python/package.json @@ -103,6 +103,7 @@ "@azure-tools/typespec-azure-resource-manager": "~0.68.0", "@azure-tools/typespec-azure-rulesets": "~0.68.0", "@azure-tools/typespec-client-generator-core": "~0.68.0", + "@typespec/http-client-generator-core": "../http-client-generator-core", "@azure-tools/azure-http-specs": "0.1.0-alpha.40", "@typespec/compiler": "^1.12.0", "@typespec/http": "^1.12.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ee30e3063d1..6a7270e1f42 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -159,6 +159,9 @@ catalogs: '@types/plist': specifier: ^3.0.5 version: 3.0.5 + '@types/pluralize': + specifier: ^0.0.33 + version: 0.0.33 '@types/react': specifier: ^19.2.14 version: 19.2.14 @@ -366,6 +369,9 @@ catalogs: plist: specifier: ^3.1.0 version: 3.1.0 + pluralize: + specifier: ^8.0.0 + version: 8.0.0 postject: specifier: 1.0.0-alpha.6 version: 1.0.0-alpha.6 @@ -1184,6 +1190,64 @@ importers: specifier: 'catalog:' version: 4.1.3(@types/node@25.5.2)(@vitest/coverage-v8@4.1.3)(@vitest/ui@4.1.3)(happy-dom@20.9.0)(jsdom@25.0.1)(vite@8.0.8(@types/node@25.5.2)(esbuild@0.28.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + packages/http-client-generator-core: + dependencies: + change-case: + specifier: 'catalog:' + version: 5.4.4 + pluralize: + specifier: 'catalog:' + version: 8.0.0 + devDependencies: + '@types/node': + specifier: 'catalog:' + version: 25.5.2 + '@types/pluralize': + specifier: 'catalog:' + version: 0.0.33 + '@typespec/compiler': + specifier: workspace:^ + version: link:../compiler + '@typespec/events': + specifier: workspace:^ + version: link:../events + '@typespec/http': + specifier: workspace:^ + version: link:../http + '@typespec/openapi': + specifier: workspace:^ + version: link:../openapi + '@typespec/rest': + specifier: workspace:^ + version: link:../rest + '@typespec/sse': + specifier: workspace:^ + version: link:../sse + '@typespec/streams': + specifier: workspace:^ + version: link:../streams + '@typespec/versioning': + specifier: workspace:^ + version: link:../versioning + '@typespec/xml': + specifier: workspace:^ + version: link:../xml + '@vitest/coverage-v8': + specifier: 'catalog:' + version: 4.1.3(vitest@4.1.3) + '@vitest/ui': + specifier: 'catalog:' + version: 4.1.3(vitest@4.1.3) + rimraf: + specifier: 'catalog:' + version: 6.1.3 + typescript: + specifier: 'catalog:' + version: 6.0.2 + vitest: + specifier: 'catalog:' + version: 4.1.3(@types/node@25.5.2)(@vitest/coverage-v8@4.1.3)(@vitest/ui@4.1.3)(happy-dom@20.9.0)(jsdom@25.0.1)(vite@8.0.8(@types/node@25.5.2)(esbuild@0.28.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + packages/http-client-js: dependencies: '@alloy-js/core': @@ -6912,6 +6976,9 @@ packages: '@types/plist@3.0.5': resolution: {integrity: sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA==} + '@types/pluralize@0.0.33': + resolution: {integrity: sha512-JOqsl+ZoCpP4e8TDke9W79FDcSgPAR0l6pixx2JHkhnRjvShyYiAYw2LVsnA7K08Y6DeOnaU6ujmENO4os/cYg==} + '@types/prismjs@1.26.6': resolution: {integrity: sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw==} @@ -7106,7 +7173,6 @@ packages: '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} - deprecated: Potential CWE-502 - Update to 1.3.1 or higher '@upsetjs/venn.js@2.0.0': resolution: {integrity: sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==} @@ -13040,12 +13106,10 @@ packages: uuid@8.3.2: resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} - deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true uuid@9.0.1: resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} - deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true v8-to-istanbul@9.3.0: @@ -19006,6 +19070,8 @@ snapshots: '@types/node': 25.5.2 xmlbuilder: 15.1.1 + '@types/pluralize@0.0.33': {} + '@types/prismjs@1.26.6': {} '@types/qs@6.15.0': {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 5ca502f4ce2..32c63b01e6f 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -86,6 +86,8 @@ catalog: "@yarnpkg/plugin-nm": ^4.0.8 "@yarnpkg/plugin-npm": ^3.4.0 "@yarnpkg/plugin-pnp": ^4.1.3 + pluralize: ^8.0.0 + "@types/pluralize": ^0.0.33 ajv: ^8.18.0 ajv-formats: ^3.0.1 astro: ^6.1.10