diff --git a/.gitignore b/.gitignore index bcde7bc2e8..66a20aa89c 100644 --- a/.gitignore +++ b/.gitignore @@ -32,4 +32,14 @@ vitest.config.ts.timestamp* **/test/**/metadata.json .turbo/ packages/typespec-ts/submodules -.gitmodules \ No newline at end of file +.gitmodules +# Squad: local-only team state (never committed) +.squad/ +.squad-workstream +.copilot/ +plan.md +.github/agents/squad.agent.md +.github/workflows/squad-heartbeat.yml +.github/workflows/squad-issue-assign.yml +.github/workflows/squad-triage.yml +.github/workflows/sync-squad-labels.yml diff --git a/package.json b/package.json index f36452b405..d7971804a4 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,8 @@ "preinstall": "npx only-allow pnpm", "purge": "rimraf --glob \"packages/**/node_modules/\"", "test": "turbo run test", - "update-typespec": "node .scripts/update-typespec-overrides.js" + "update-typespec": "node .scripts/update-typespec-overrides.js", + "compare": "pnpm --dir packages/typespec-ts-pristine compare" }, "devDependencies": { "@types/node": "^18.0.0", diff --git a/packages/typespec-ts-pristine/README.md b/packages/typespec-ts-pristine/README.md new file mode 100644 index 0000000000..7a4f6b369b --- /dev/null +++ b/packages/typespec-ts-pristine/README.md @@ -0,0 +1,45 @@ +# @azure-tools/typespec-ts-pristine + +North-star TypeScript emitter for TypeSpec. Clean-room implementation of a +three-layer pipeline architecture. + +## Architecture + +``` +TCGC SDK Context → Adapter → Code Model (IR) → Renderer → .ts files +``` + +Three layers, three directories: + +| Layer | Directory | Responsibility | +|-------|-----------|----------------| +| Adapter | `src/tcgcadapter/` | Transforms TCGC types into language-specific IR. Only layer that imports TCGC. | +| Code Model | `src/codemodel/` | Pure data types. The contract between adapter and renderer. | +| Renderer | `src/codegen/` | Consumes IR, produces TypeScript source strings. Zero TCGC knowledge. | + +## Why does this exist? + +The existing `@azure-tools/typespec-ts` emitter grew organically and fuses +adapter and renderer concerns. This package is a greenfield rewrite that +enforces strict layer separation from day one. It targets feature parity with +the existing emitter while being simpler to understand and maintain. + +## Comparator + +The `compare` script runs both emitters over the same TypeSpec fixture set and +diffs the generated output. It lives in `src/comparator/`. + +```bash +# Not yet implemented — interface only +pnpm compare --fixtures ../typespec-test/test/ --baseline ../typespec-ts --candidate . +``` + +Output: tree diff, per-file unified diff, summary score (% files identical). + +## Development + +```bash +pnpm install # from repo root +pnpm build # builds this package +pnpm compare # runs comparator (once implemented) +``` diff --git a/packages/typespec-ts-pristine/docs/DESIGN.md b/packages/typespec-ts-pristine/docs/DESIGN.md new file mode 100644 index 0000000000..e17fa23e84 --- /dev/null +++ b/packages/typespec-ts-pristine/docs/DESIGN.md @@ -0,0 +1,198 @@ +# Design: @azure-tools/typespec-ts-pristine + +## What & Why + +This package is a clean-room TypeScript emitter for TypeSpec. It follows a +three-layer pipeline architecture: + +``` +┌─────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ +│ TypeSpec + │ ──▶ │ Adapter │ ──▶ │ Code Model │ ──▶ │ Renderer │ ──▶ .ts files +│ TCGC SDK │ │ (Phase 1) │ │ (IR) │ │ (Phase 3) │ +└─────────────┘ └──────────────┘ └──────────────┘ └──────────────┘ +``` + +**Why rewrite?** The existing `@azure-tools/typespec-ts` emitter grew +organically. Adapter and renderer concerns are fused. TCGC types leak into +rendering logic. Symptom-fix dedupe passes accumulate. This package answers: +*"What would we build if we knew all the requirements and started fresh?"* + +The answer is boring. Three layers. Each layer has one job. No clever +metaprogramming. The renderer never imports TCGC. The adapter never emits +strings. The code model is the contract. + +--- + +## Surface Area Inventory + +The existing emitter produces the following output file categories. Pristine +must cover all of them to achieve parity: + +| # | Output Category | Existing Source Location | IR Driver | +|---|----------------|--------------------------|-----------| +| 1 | **Models** (interfaces/types) | `src/modular/emitModels.ts` | `TSModel[]` | +| 2 | **Enums** (type aliases + known values) | `src/modular/emitModels.ts` | `TSEnum[]` | +| 3 | **Unions** (type aliases) | `src/modular/emitModels.ts` | `TSUnion[]` | +| 4 | **Operations** (send/deserialize/public API) | `src/modular/buildOperations.ts`, `src/codegen/operations.ts` | `TSOperation[]` via `TSClient` | +| 5 | **Client context** (factory + interface) | `src/modular/buildClientContext.ts`, `src/codegen/clients.ts` | `TSClient` | +| 6 | **Classical client** (class wrapper) | `src/modular/buildClassicalClient.ts`, `src/codegen/classicalClient.ts` | `TSClient` | +| 7 | **Classical operation groups** | `src/modular/buildClassicalOperationGroups.ts`, `src/codegen/classicalOperations.ts` | `TSOperationGroup[]` | +| 8 | **Options interfaces** | `src/codegen/apiOptions.ts` | `TSOptionsType` per operation | +| 9 | **Serializers** (JSON/XML) | `src/modular/serialization/` | `TSSerializerGroup[]` + `TSModel[]` | +| 10 | **Paging helpers** | `src/modular/static-helpers-metadata.ts` (PagingHelpers) | `TSPagingConfig` | +| 11 | **Polling/LRO helpers** | `src/modular/static-helpers-metadata.ts` (PollingHelpers) | `TSPollingConfig` | +| 12 | **RestorePoller** | `src/modular/buildRestorePoller.ts`, `src/codegen/lroHelpers.ts` | `TSClient.lroConfig` | +| 13 | **Logger** | `src/modular/emitLoggerFile.ts` | `TSGenerationSettings.packageName` | +| 14 | **Index files** (root, subpath, models, api) | `src/modular/buildRootIndex.ts`, `src/modular/buildSubpathIndex.ts`, `src/codegen/indexFiles.ts` | Full `TSCodeModel` | +| 15 | **Package infrastructure** (package.json, tsconfig, etc.) | `src/modular/buildProjectFiles.ts` | `TSGenerationSettings` | +| 16 | **Samples** | `src/modular/emitSamples.ts` | `TSClient` + `TSOperation` | +| 17 | **Tests** | `src/modular/emitTests.ts` | `TSClient` | +| 18 | **Response type aliases** | `src/codegen/responseTypes.ts` | `TSOperation.returnType` | +| 19 | **Static helpers** (URL template, multipart, platform types) | `src/modular/static-helpers-metadata.ts` | `TSHelperFile[]` | + +--- + +## IR Shapes (The Contract) + +Each surface is driven by specific IR types. These are defined in +`src/codemodel/index.ts`. Key types: + +| IR Type | Drives | Key Fields | +|---------|--------|------------| +| `TSCodeModel` | Root — everything | clients, models, enums, unions, serializers, helpers, settings | +| `TSGenerationSettings` | Package config, infra files | packageName, flavor, isArm, outputDir | +| `TSClient` | Client context + classical class | name, parameters, endpoint, methods, operationGroups, children | +| `TSOperation` | Operation files + options | name, kind, httpMethod, path, parameters, returnType, optionsType | +| `TSOperationGroup` | Grouped operation files | name, operations | +| `TSModel` | Model interfaces + serializers | name, properties, baseModel, discriminator, needsSerializer | +| `TSEnum` | Enum type aliases | name, members, isExtensible, valueType | +| `TSUnion` | Union type aliases | name, variants, discriminator | +| `TSSerializerGroup` | Serializer files | contentType, models | +| `TSHelperFile` | Static helper copies | outputPath, category | +| `TSPagingConfig` | Paging helper inclusion | hasPaging, itemPropertyPath | +| `TSPollingConfig` | LRO helper inclusion | hasLro, emitRestorePoller | +| `TSParameter` | Shared parameter shape | name, type, required, defaultValue | +| `TSProperty` | Model/options properties | name, type, optional, readonly, serializedName | +| `TSDiscriminator` | Polymorphic hierarchies | propertyName, value, variants | +| `TSOptionsType` | Per-operation options bag | name, properties | +| `TSEndpoint` | Client endpoint config | urlTemplate, isParameterized, templateParams | +| `TSApiVersion` | API versioning | paramName, defaultValue, isInEndpoint | + +--- + +## Non-Negotiable Invariants + +1. **Renderer does not import TCGC.** Not transitively, not via re-export, not + via a "utils" file that sneaks it in. If the renderer needs data, it goes + in the code model. + +2. **Code model is the contract.** The adapter's output type is `TSCodeModel`. + The renderer's input type is `TSCodeModel`. That's the only coupling. + +3. **No symptom-fix dedupe passes.** If duplicate imports appear, the adapter + is producing bad data. Fix the adapter. Don't add a post-processing strip + pass. + +4. **No clever metaprogramming.** No code that generates code that generates + code. Stick to string builders or template literals: boring and predictable. + +5. **File-per-concern.** Each renderer function produces one logical file kind. + No 500-line functions that emit three different file types. + +6. **Pure data code model.** No methods on IR types. No side effects. No + closures. Serializable to JSON. + +7. **Self-contained. No internal workspace dependencies. Always extractable.** + This package has ZERO dependencies on other packages in this monorepo — not + `@azure-tools/rlc-common`, not `@azure-tools/typespec-ts`, nothing. The + only allowed dependencies are external npm packages (`@typespec/*`, + `@azure-tools/typespec-client-generator-core`, `ts-morph`, `tslib`, etc.). + If a utility exists in a sibling package and we need it, we copy it in + (with attribution). The package must be liftable to its own repo at any + time: `cp -r packages/typespec-ts-pristine ../new-repo/ && npm install` + must work. + +--- + +## Explicit Non-Goals + +- **AutoRest parity.** The `autorest.typescript` package is in maintenance + mode. Pristine targets TypeSpec-only generation. + +- **Experimental flags.** No `enableExperimentalFeature` toggles. Features are + either implemented or they aren't. + +- **Legacy customer overlays.** No hooks for customers to patch generated code + inside the emitter. That's a migration concern, not a design concern. + +- **RLC generation.** Pristine generates modular SDK only. RLC is handled by + the existing emitter in maintenance mode, or by a separate focused package. + +--- + +## Comparator Approach + +A `compare` script validates pristine output against the existing emitter. + +### Interface + +``` +compare(fixturesDir, baselineOutput, candidateOutput) → CompareResult +``` + +### Location + +`src/comparator/index.ts` — types and orchestration logic. +`pnpm compare` — CLI entry (package.json script). + +### What it does + +1. Enumerates fixture directories under `packages/typespec-test/test/` +2. For each fixture, globs all `.ts` files in both output trees +3. Computes: files only in baseline, files only in candidate, files with diffs +4. Produces per-file unified diffs +5. Calculates a score: `(identical files / total files) × 100` + +### Output format + +``` +Fixture: azure/storage-blob + Score: 94.2% (47/50 files identical) + Missing in candidate: src/models/legacy.ts + Extra in candidate: (none) + Diffs: + src/api/containers/operations.ts (12 lines changed) + src/index.ts (3 lines changed) +``` + +### What it does NOT do + +- Does not compile the output (that's the smoke test's job) +- Does not run integration tests (that's the integration suite's job) +- Does not evaluate which output is "better" — only equivalence +- Does not import the baseline emitter as a dependency — it either invokes it + as a subprocess (`npx @azure-tools/typespec-ts`) or diffs pre-generated + output that already exists in `packages/typespec-test/test/*/generated/` + +--- + +## Directory Structure + +``` +packages/typespec-ts-pristine/ +├── package.json +├── tsconfig.json +├── README.md +├── docs/ +│ └── DESIGN.md ← this file +└── src/ + ├── index.ts ← emitter entry point (3-phase orchestrator) + ├── tcgcadapter/ + │ └── index.ts ← TCGC → TSCodeModel transformation + ├── codemodel/ + │ └── index.ts ← IR type definitions (pure data) + ├── codegen/ + │ └── index.ts ← TSCodeModel → .ts file strings + └── comparator/ + └── index.ts ← diff tool for validating output equivalence +``` diff --git a/packages/typespec-ts-pristine/package.json b/packages/typespec-ts-pristine/package.json new file mode 100644 index 0000000000..c2e7b5d2f3 --- /dev/null +++ b/packages/typespec-ts-pristine/package.json @@ -0,0 +1,41 @@ +{ + "name": "@azure-tools/typespec-ts-pristine", + "version": "0.1.0", + "description": "North-star TypeScript emitter for TypeSpec — clean three-layer pipeline architecture", + "main": "dist/src/index.js", + "typespec": { + "emitter": { + "main": "./dist/src/index.js" + } + }, + "type": "module", + "exports": { + ".": { + "default": "./dist/src/index.js", + "types": "./dist/src/index.d.ts" + } + }, + "scripts": { + "build": "tsc -p .", + "clean": "rimraf ./dist", + "compare": "node dist/src/comparator/index.js" + }, + "author": "Microsoft Corporation", + "license": "MIT", + "devDependencies": { + "@azure-tools/typespec-client-generator-core": "^0.68.0", + "@typespec/compiler": "^1.12.0", + "@typespec/http": "^1.12.0", + "@typespec/rest": "^0.82.0", + "@typespec/versioning": "^0.82.0", + "typescript": "~5.6.3" + }, + "peerDependencies": { + "@azure-tools/typespec-client-generator-core": "^0.68.0", + "@typespec/compiler": "^1.12.0" + }, + "dependencies": { + "ts-morph": "^23.0.0", + "tslib": "^2.3.1" + } +} diff --git a/packages/typespec-ts-pristine/src/codegen/index.ts b/packages/typespec-ts-pristine/src/codegen/index.ts new file mode 100644 index 0000000000..b15b3deac6 --- /dev/null +++ b/packages/typespec-ts-pristine/src/codegen/index.ts @@ -0,0 +1,1486 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Codegen — Phase 3 of the emitter pipeline. + * + * This module consumes the TSCodeModel and produces TypeScript source file + * strings. It has ZERO knowledge of TCGC — it reads only the code model. + * + * One function per output file kind. Each function documents: + * - Which IR fields it consumes + * - What output file(s) it produces + * + * Pattern: same as Go's `codegen.go/` recursive emit and Rust's `codegen/`. + */ + +import { Project, QuoteKind, StructureKind } from "ts-morph"; +import type { + TSCodeModel, + TSClient, + TSModel, + TSEnum, + TSUnion, + TSOperation, + TSOperationGroup, + TSOperationParameter, + TSSerializerGroup, + TSHelperFile, + TSPackageInfo, + TSProperty, +} from "../codemodel/index.js"; + +/** A rendered output file. */ +export interface RenderedFile { + /** Relative path from output root */ + path: string; + /** File content */ + content: string; +} + +/** + * Renders a complete TSCodeModel into output files. + * + * Orchestrates all per-file renderers and collects their output. + * + * @param codeModel - The fully-resolved code model + * @returns All files to write + */ +export function render(codeModel: TSCodeModel): RenderedFile[] { + return [ + ...renderModels(codeModel), + ...renderClients(codeModel), + ...renderOperationFiles(codeModel), + ...renderClassicalFiles(codeModel), + renderLogger(codeModel.packageInfo), + ...renderIndexFiles(codeModel), + ...renderHelpers(codeModel.helpers), + ...renderPackageFiles(codeModel), + ]; +} + +/** + * Renders model interfaces and enum type aliases. + * + * Consumes: `TSCodeModel.models`, `TSCodeModel.enums` + * Produces: `src/models/models.ts` + */ +export function renderModels( + codeModel: Pick, +): RenderedFile[] { + const project = new Project({ + manipulationSettings: { + quoteKind: QuoteKind.Double, + useTrailingCommas: true, + }, + }); + const sourceFile = project.createSourceFile("models.ts", "", { + overwrite: true, + }); + + sourceFile.addStatements(`// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * This file contains only generated model types and their (de)serializers. + * Disable the following rules for internal models with '_' prefix and deserializers which require 'any' for raw JSON input. + */ +/* eslint-disable @typescript-eslint/naming-convention */ +/* eslint-disable @typescript-eslint/explicit-module-boundary-types */`); + + for (const model of codeModel.models) { + renderModel(sourceFile, model); + renderModelSerializer(sourceFile, model); + renderModelDeserializer(sourceFile, model); + } + + for (const enumType of codeModel.enums) { + renderEnum(sourceFile, enumType); + } + + sourceFile.formatText({ indentSize: 2 }); + const content = sourceFile + .getFullText() + .replace(/}\n\/\*\* model interface/g, "}\n\n/** model interface"); + return [{ path: "src/models/models.ts", content }]; +} + +function renderModel( + sourceFile: import("ts-morph").SourceFile, + model: TSModel, +): void { + addModelDocs(sourceFile, model); + const declaration = sourceFile.addInterface({ + name: model.name, + isExported: true, + extends: model.baseModel ? [model.baseModel] : undefined, + properties: model.properties.map(getPropertyStructure), + }); + + if (model.additionalPropertiesType) { + declaration.addIndexSignature({ + keyName: "propertyName", + keyType: "string", + returnType: model.additionalPropertiesType, + }); + } +} + +function getPropertyStructure( + property: TSProperty, +): import("ts-morph").PropertySignatureStructure { + return { + kind: StructureKind.PropertySignature, + name: property.name, + type: property.type, + hasQuestionToken: property.optional, + isReadonly: property.readonly, + docs: property.docs.map((doc) => ({ description: doc })), + }; +} + +function renderModelSerializer( + sourceFile: import("ts-morph").SourceFile, + model: TSModel, +): void { + if (!model.needsSerializer || !model.serializerName) { + return; + } + + sourceFile.addFunction({ + name: model.serializerName, + isExported: true, + parameters: [{ name: "item", type: model.name }], + returnType: "any", + statements: [ + `return { ${getSerializerMappings(model.properties).join(", ")} };`, + ], + }); +} + +function renderModelDeserializer( + sourceFile: import("ts-morph").SourceFile, + model: TSModel, +): void { + if (!model.needsDeserializer || !model.deserializerName) { + return; + } + + sourceFile.addFunction({ + name: model.deserializerName, + isExported: true, + parameters: [{ name: "item", type: "any" }], + returnType: model.name, + statements: [ + `return { ${getDeserializerMappings(model.properties).join(", ")} };`, + ], + }); +} + +function getSerializerMappings(properties: TSProperty[]): string[] { + return properties + .filter((property) => !property.readonly) + .map((property) => + getObjectMapping(property, `item[${JSON.stringify(property.name)}]`), + ); +} + +function getDeserializerMappings(properties: TSProperty[]): string[] { + return properties.map((property) => + getObjectMapping( + property, + `item[${JSON.stringify(property.serializedName ?? property.name)}]`, + ), + ); +} + +function getObjectMapping( + property: TSProperty, + valueExpression: string, +): string { + const wireName = property.serializedName ?? property.name; + const key = isIdentifier(wireName) ? wireName : JSON.stringify(wireName); + if (!property.optional) { + return `${key}: ${valueExpression}`; + } + return `...(${valueExpression} === undefined ? {} : { ${key}: ${valueExpression} })`; +} + +function isIdentifier(name: string): boolean { + return /^[$A-Z_a-z][$\w]*$/.test(name); +} + +function renderEnum( + sourceFile: import("ts-morph").SourceFile, + enumType: TSEnum, +): void { + if (enumType.knownValuesOnly) { + addDocs(sourceFile, enumType.docs); + sourceFile.addEnum({ + name: enumType.name, + isExported: true, + members: enumType.members.map((member) => ({ + name: member.name, + value: member.value, + })), + }); + return; + } + + addDocs(sourceFile, enumType.docs); + if (enumType.isExtensible) { + sourceFile.addEnum({ + name: `Known${enumType.name}`, + isExported: true, + members: enumType.members.map((member) => ({ + name: member.name, + value: member.value, + })), + }); + sourceFile.addTypeAlias({ + name: enumType.name, + isExported: true, + type: `string`, + }); + return; + } + + sourceFile.addTypeAlias({ + name: enumType.name, + isExported: true, + type: enumType.members + .map((member) => JSON.stringify(member.value)) + .join(" | "), + }); +} + +function addModelDocs( + sourceFile: import("ts-morph").SourceFile, + model: TSModel, +): void { + if (model.docs.length === 0) { + sourceFile.addStatements(`/** model interface ${model.name} */`); + return; + } + + addDocs(sourceFile, model.docs); +} + +function addDocs( + sourceFile: import("ts-morph").SourceFile, + docs: string[], +): void { + if (docs.length === 0) { + return; + } + + const lines = docs.map((line) => (line.length === 0 ? " *" : ` * ${line}`)); + sourceFile.addStatements(`/**\n${lines.join("\n")}\n */`); +} + +/** + * Renders enum type aliases and known-values constants. + * + * Consumes: `TSCodeModel.enums` + * Produces: `src/models/enums.ts` + */ +export function renderEnums(_enums: TSEnum[]): RenderedFile[] { + throw new Error("renderEnums: not yet implemented"); +} + +/** + * Renders union type aliases. + * + * Consumes: `TSCodeModel.unions` + * Produces: `src/models/unions.ts` + */ +export function renderUnions(_unions: TSUnion[]): RenderedFile[] { + throw new Error("renderUnions: not yet implemented"); +} + +/** + * Renders client context factories. + * + * Consumes: `TSCodeModel.clients`, `TSCodeModel.packageInfo` + * Produces: `src/api/{clientName}Context.ts` + */ +export function renderClients( + codeModel: Pick, +): RenderedFile[] { + return codeModel.clients.map((client) => + renderClientContext(client, codeModel.packageInfo), + ); +} + +function getCredentialParameter(client: TSClient): { name: string; type: string } | undefined { + return client.credential + ? { name: client.credential.paramName, type: client.credential.type } + : undefined; +} + +function renderEndpointExpression(client: TSClient, endpointParameterName: string): string { + if (client.endpoint.urlTemplate === "{endpoint}") { + return `String(${endpointParameterName})`; + } + return `\`${client.endpoint.urlTemplate.replace(/\{[^}]+\}/g, `\${${endpointParameterName}}`)}\``; +} + +function renderApiVersionInterfaceProperty(client: TSClient): string { + if (!client.apiVersion) { + return ""; + } + return ` + /** The API version to use for this operation. */ + /** Known values of {@link KnownVersions} that the service accepts. */ + apiVersion?: string; +`; +} + +function renderCredentialOptions(client: TSClient): string { + if (!client.credential) { + return ""; + } + const scopes = client.credential.scopes.length > 0 ? client.credential.scopes : []; + const apiKeyHeaderName = client.credential.apiKeyHeaderName; + return ` + credentials: { + scopes: options.credentials?.scopes ?? ${JSON.stringify(scopes)},${ + apiKeyHeaderName + ? ` + apiKeyHeaderName: options.credentials?.apiKeyHeaderName ?? ${JSON.stringify(apiKeyHeaderName)},` + : "" + } + },`; +} + +export function renderClientContext( + client: TSClient, + packageInfo: TSPackageInfo, +): RenderedFile { + const clientBaseName = getClientBaseName(client.name); + const endpointParameter = getEndpointParameter(client); + const contextName = `${clientBaseName}Context`; + const optionsName = `${clientBaseName}ClientOptionalParams`; + const factoryName = `create${clientBaseName}`; + const credentialParameter = getCredentialParameter(client); + const endpointExpression = endpointParameter + ? renderEndpointExpression(client, endpointParameter.name) + : JSON.stringify(client.endpoint.urlTemplate); + const parameters = [ + ...(endpointParameter ? [`${endpointParameter.name}: ${endpointParameter.type}`] : []), + ...(credentialParameter ? [`${credentialParameter.name}: ${credentialParameter.type}`] : []), + ]; + const content = `${copyrightHeader()} + +import { logger } from "../logger.js"; +${client.apiVersion ? `import { KnownVersions } from "../models/models.js";\n` : ""}import { Client, ClientOptions, getClient } from "@azure-rest/core-client"; +${credentialParameter ? `import { KeyCredential, TokenCredential } from "@azure/core-auth";\n` : ""} +export interface ${contextName} extends Client {${renderApiVersionInterfaceProperty(client)}} + +/** Optional parameters for the client. */ +export interface ${optionsName} extends ClientOptions {${renderApiVersionInterfaceProperty(client)}} + +export function ${factoryName}( + ${parameters.length > 0 ? `${parameters.join(",\n ")},\n ` : ""}options: ${optionsName} = {}, +): ${contextName} { + const endpointUrl = options.endpoint ?? ${endpointExpression}; + const prefixFromOptions = options?.userAgentOptions?.userAgentPrefix; + const userAgentInfo = \`azsdk-js-${getPackageShortName(packageInfo.name)}/${packageInfo.version}\`; + const userAgentPrefix = prefixFromOptions + ? \`${"${prefixFromOptions}"} azsdk-js-api ${"${userAgentInfo}"}\` + : \`azsdk-js-api ${"${userAgentInfo}"}\`; + const { apiVersion: _, ...updatedOptions } = { + ...options, + userAgentOptions: { userAgentPrefix }, + loggingOptions: { logger: options.loggingOptions?.logger ?? logger.info },${renderCredentialOptions(client)} + }; + const clientContext = getClient(endpointUrl, ${credentialParameter?.name ?? "undefined"}, updatedOptions); +${client.apiVersion ? ` const apiVersion = options.apiVersion;\n return { ...clientContext, apiVersion } as ${contextName};` : `\n if (options.apiVersion) { + logger.warning( + "This client does not support client api-version, please change it at the operation level", + ); + } + return clientContext;`} +} +`; + return { path: `src/api/${lowerFirst(clientBaseName)}Context.ts`, content }; +} + +/** + * Renders operation files — send, deserialize, and public API functions. + * + * Consumes: `TSClient.methods` and `TSClient.operationGroups[].operations` + * Produces: `src/api/{group}/{operation}.ts` + */ +export function renderOperations( + client: TSClient, + group: TSOperationGroup, +): RenderedFile { + const operations = group.operations; + const clientBaseName = getClientBaseName(client.name); + const serializerNames = operations + .filter((operation) => operation.bodyShape === "named-with-serializer") + .map((operation) => getOperationBodySerializerName(operation)) + .sort(); + const optionNames = operations.map((operation) => operation.optionsType.name); + const content = `${copyrightHeader()} + +${renderOperationImports( + clientBaseName, + serializerNames, + optionNames, + operations.some((operation) => operation.apiVersionQuery), +)} + +${operations.map((operation) => renderOperation(operation)).join("\n\n")} +`; + return { path: `src/api/${group.name}/operations.ts`, content }; +} + +/** + * Renders options interfaces for each operation. + * + * Consumes: `TSOperation.optionsType` across all clients + * Produces: `src/api/{group}/options.ts` + */ +export function renderOptions( + _client: TSClient, + group: TSOperationGroup, +): RenderedFile { + const content = `${copyrightHeader()} + +import { OperationOptions } from "@azure-rest/core-client"; + +${group.operations + .map((operation) => renderOptionsInterface(operation)) + .join("\n\n")} +`; + return { path: `src/api/${group.name}/options.ts`, content }; +} + +export function renderOperationGroupBarrel( + _client: TSClient, + group: TSOperationGroup, +): RenderedFile { + const operationNames = group.operations + .map((operation) => operation.name) + .join(", "); + const optionNames = group.operations + .map((operation) => operation.optionsType.name) + .join(",\n "); + const content = `${copyrightHeader()} + +export { ${operationNames} } from "./operations.js"; +export type { + ${optionNames}, +} from "./options.js"; +`; + return { path: `src/api/${group.name}/index.ts`, content }; +} + +export function renderOperationFiles( + codeModel: Pick, +): RenderedFile[] { + return codeModel.clients.flatMap((client) => + client.operationGroups.flatMap((group) => [ + renderOperations(client, group), + renderOptions(client, group), + renderOperationGroupBarrel(client, group), + ]), + ); +} + +function renderOperationImports( + clientBaseName: string, + serializerNames: string[], + optionNames: string[], + needsUrlTemplate: boolean, +): string { + const serializersImport = + serializerNames.length > 0 + ? `import { ${serializerNames.join(", ")} } from "../../models/models.js";\n` + : ""; + const urlTemplateImport = needsUrlTemplate + ? `import { expandUrlTemplate } from "../../static-helpers/urlTemplate.js";\n` + : ""; + return `import { ${clientBaseName}Context as Client } from "../index.js"; +${urlTemplateImport}${serializersImport}import { + ${optionNames.join(",\n ")}, +} from "./options.js"; +import { + StreamableMethod, + PathUncheckedResponse, + createRestError, + operationOptionsToRequestParameters, +} from "@azure-rest/core-client";`; +} + +function renderOperation(operation: TSOperation): string { + return `${renderSendFunction(operation)} + +${renderDeserializeFunction(operation)} + +${renderPublicOperationFunction(operation)}`; +} + +function renderOperationDocs(operation: TSOperation, indent = ""): string { + if (operation.docs.length === 0) { + return ""; + } + if (operation.docs.length === 1) { + return `${indent}/** ${operation.docs[0]} */\n`; + } + const lines = operation.docs.map((line) => `${indent} * ${line}`).join("\n"); + return `${indent}/**\n${lines}\n${indent} */\n`; +} + +function renderPathExpression(operation: TSOperation): string { + if (!operation.apiVersionQuery) { + return ""; + } + const query = operation.apiVersionQuery; + return ` const path = expandUrlTemplate( + "${operation.path}{?${query.encodedName}}", + { + "${query.encodedName}": context.apiVersion ?? "${query.defaultValue}", + }, + { + allowReserved: options?.requestOptions?.skipUrlEncoding, + }, + ); +`; +} + +function renderSendFunction(operation: TSOperation): string { + const parameters = renderOperationSignatureParameters(operation); + return `export function _${operation.name}Send( + context: Client, +${parameters} + options: ${operation.optionsType.name} = { requestOptions: {} }, +): StreamableMethod { +${renderPathExpression(operation)} return context + .path(${operation.apiVersionQuery ? "path" : JSON.stringify(operation.path)}) + .${operation.httpMethod.toLowerCase()}({ + ...operationOptionsToRequestParameters(options), + contentType: ${JSON.stringify(operation.contentType ?? "application/json")}, + ${renderBodyExpression(operation)} + }); +}`; +} + +function renderDeserializeFunction(operation: TSOperation): string { + return `export async function _${operation.name}Deserialize(result: PathUncheckedResponse): Promise<${operation.returnType.type}> { + const expectedStatuses = ${JSON.stringify(operation.expectedStatuses)}; + if (!expectedStatuses.includes(result.status)) { + throw createRestError(result); + } + + return${operation.returnType.isVoid ? "" : " result.body as " + operation.returnType.type}; +}`; +} + +function renderPublicOperationFunction(operation: TSOperation): string { + const parameters = renderOperationSignatureParameters(operation); + const argumentNames = operation.parameters + .map((parameter) => parameter.name) + .join(", "); + const sendArguments = argumentNames ? `${argumentNames}, options` : "options"; + return `${renderOperationDocs(operation)}export async function ${operation.name}( + context: Client, +${parameters} + options: ${operation.optionsType.name} = { requestOptions: {} }, +): Promise<${operation.returnType.type}> { + const result = await _${operation.name}Send(context, ${sendArguments}); + return _${operation.name}Deserialize(result); +}`; +} + +function renderOperationSignatureParameters(operation: TSOperation): string { + return operation.parameters + .map((parameter) => renderOperationParameter(parameter)) + .join("\n"); +} + +function renderOperationParameter(parameter: TSOperationParameter): string { + return ` ${parameter.name}: ${indentInlineType(parameter.type)},`; +} + +function indentInlineType(type: string): string { + return type; +} + +function renderBodyExpression(operation: TSOperation): string { + if ( + operation.parameters.every((parameter) => parameter.location !== "body") + ) { + return ""; + } + const bodyParameter = operation.parameters.find( + (parameter) => parameter.location === "body", + ); + if (operation.bodyShape === "raw") { + return `body: ${bodyParameter?.name ?? "body"},`; + } + if (operation.bodyShape === "named-with-serializer") { + return `body: ${getOperationBodySerializerName(operation)}(${bodyParameter?.name ?? "body"}),`; + } + const bodyProperties = operation.parameters + .filter((parameter) => parameter.location === "body") + .map((parameter) => `${parameter.name}: ${parameter.name}`) + .join(", "); + return `body: { ${bodyProperties} },`; +} + +function getOperationBodySerializerName(operation: TSOperation): string { + return `_${operation.name}RequestSerializer`; +} + +function renderOptionsInterface(operation: TSOperation): string { + const properties = operation.optionsType.properties + .map((property) => ` ${property.name}?: ${property.type};`) + .join("\n"); + return `/** Optional parameters. */ +export interface ${operation.optionsType.name} extends OperationOptions {${properties ? `\n${properties}\n` : ""}}`; +} + +/** + * Renders classical client class and operation group wrappers. + * + * Consumes: `TSCodeModel.clients` with operation groups and operations + * Produces: `src/{clientName}.ts`, `src/classic/{group}/index.ts`, `src/classic/index.ts` + */ +export function renderClassicalFiles( + codeModel: Pick, +): RenderedFile[] { + return codeModel.clients.flatMap((client) => [ + renderClassicalClient(client), + ...client.operationGroups.map((group) => + renderClassicalOperationGroup(client, group), + ), + renderClassicalBarrel(client), + ]); +} + +export function renderClassicalClient(client: TSClient): RenderedFile { + const clientBaseName = getClientBaseName(client.name); + const contextName = `${clientBaseName}Context`; + const optionsName = `${clientBaseName}ClientOptionalParams`; + const factoryName = `create${clientBaseName}`; + const clientClassName = `${clientBaseName}Client`; + const endpointParameter = getEndpointParameter(client); + const credentialParameter = getCredentialParameter(client); + const constructorParameters = [ + ...(endpointParameter + ? [`${endpointParameter.name}: ${endpointParameter.type}`] + : []), + ...(credentialParameter ? [`${credentialParameter.name}: ${credentialParameter.type}`] : []), + `options: ${optionsName} = {}`, + ].join(", "); + const factoryOptions = `{ + ...options, + userAgentOptions: { userAgentPrefix }, + }`; + const factoryArguments = [ + ...(endpointParameter ? [endpointParameter.name] : []), + ...(credentialParameter ? [credentialParameter.name] : []), + factoryOptions, + ].join(", "); + const groupImports = client.operationGroups + .map((group) => { + const groupType = `${upperFirst(group.name)}Operations`; + return `import { ${groupType}, _get${upperFirst( + group.name, + )}Operations } from "./classic/${group.name}/index.js";`; + }) + .join("\n"); + const groupInitializers = client.operationGroups + .map( + (group) => + ` this.${group.name} = _get${upperFirst(group.name)}Operations(this._client);`, + ) + .join("\n"); + const groupProperties = client.operationGroups + .map( + (group) => + ` /** The operation groups for ${group.name} */\n public readonly ${group.name}: ${upperFirst(group.name)}Operations;`, + ) + .join("\n\n"); + + const content = `${copyrightHeader()} + +import { + ${contextName}, + ${optionsName}, + ${factoryName}, +} from "./api/index.js"; +${groupImports} +${credentialParameter ? `import { KeyCredential, TokenCredential } from "@azure/core-auth";\n` : ""}import { Pipeline } from "@azure/core-rest-pipeline"; + +export type { ${optionsName} } from "./api/${lowerFirst(clientBaseName)}Context.js"; + +export class ${clientClassName} { + private _client: ${contextName}; + /** The pipeline used by this client to make requests */ + public readonly pipeline: Pipeline; + + constructor(${constructorParameters}) { + const prefixFromOptions = options?.userAgentOptions?.userAgentPrefix; + const userAgentPrefix = prefixFromOptions + ? \`${"${prefixFromOptions}"} azsdk-js-client\` + : \`azsdk-js-client\`; + this._client = ${factoryName}(${factoryArguments}); + this.pipeline = this._client.pipeline; +${groupInitializers} + } + +${groupProperties} +} +`; + return { path: `src/${lowerFirst(clientClassName)}.ts`, content }; +} + +export function renderClassicalOperationGroup( + client: TSClient, + group: TSOperationGroup, +): RenderedFile { + const clientBaseName = getClientBaseName(client.name); + const contextName = `${clientBaseName}Context`; + const groupName = upperFirst(group.name); + const operationNames = group.operations + .map((operation) => operation.name) + .join(", "); + const optionNames = group.operations + .map((operation) => operation.optionsType.name) + .join(",\n "); + const content = `${copyrightHeader()} + +import { ${contextName} } from "../../api/${lowerFirst(clientBaseName)}Context.js"; +import { ${operationNames} } from "../../api/${group.name}/operations.js"; +import { + ${optionNames}, +} from "../../api/${group.name}/options.js"; + +/** Interface representing a ${groupName} operations. */ +export interface ${groupName}Operations { +${group.operations.map(renderClassicalMethodSignature).join("\n")} +} + +function _get${groupName}(context: ${contextName}) { + return { +${group.operations.map(renderClassicalMethodDelegate).join("\n")} + }; +} + +${renderGetOperationsFunctionDeclaration(groupName, contextName)} { + return { + ..._get${groupName}(context), + }; +} +`; + return { path: `src/classic/${group.name}/index.ts`, content }; +} + +export function renderClassicalBarrel(client: TSClient): RenderedFile { + const exports = client.operationGroups + .map( + (group) => + `export type { ${upperFirst(group.name)}Operations } from "./${group.name}/index.js";`, + ) + .join("\n"); + return { + path: "src/classic/index.ts", + content: `${copyrightHeader()}\n\n${exports}\n`, + }; +} + +function renderGetOperationsFunctionDeclaration( + groupName: string, + contextName: string, +): string { + const oneLine = `export function _get${groupName}Operations(context: ${contextName}): ${groupName}Operations`; + if (oneLine.length <= 100) { + return oneLine; + } + return `export function _get${groupName}Operations(\n context: ${contextName},\n): ${groupName}Operations`; +} + +function renderClassicalMethodSignature(operation: TSOperation): string { + const docs = renderOperationDocs(operation, " "); + if (shouldRenderMultilineSignature(operation)) { + return `${docs} ${operation.name}: (\n${renderClassicalSignatureParameters( + operation, + 4, + )}\n ) => Promise<${operation.returnType.type}>;`; + } + return `${docs} ${operation.name}: (${renderClassicalSignatureParameters( + operation, + )}) => Promise<${operation.returnType.type}>;`; +} + +function renderClassicalMethodDelegate(operation: TSOperation): string { + const parameterNames = operation.parameters + .map((parameter) => parameter.name) + .join(", "); + const argumentsList = parameterNames + ? `${parameterNames}, options` + : "options"; + if (hasMultilineParameter(operation)) { + return ` ${operation.name}: (\n${renderClassicalSignatureParameters( + operation, + 6, + )}\n ) => ${operation.name}(context, ${argumentsList}),`; + } + const line = ` ${operation.name}: (${renderClassicalSignatureParameters( + operation, + )}) => ${operation.name}(context, ${argumentsList}),`; + if (line.length <= 100) { + return line; + } + return ` ${operation.name}: (${renderClassicalSignatureParameters( + operation, + )}) =>\n ${operation.name}(context, ${argumentsList}),`; +} + +function renderClassicalSignatureParameters( + operation: TSOperation, + indent?: number, +): string { + if (indent === undefined) { + return [ + ...operation.parameters.map( + (parameter) => `${parameter.name}: ${parameter.type}`, + ), + `options?: ${operation.optionsType.name}`, + ].join(", "); + } + + const spaces = " ".repeat(indent); + return [ + ...operation.parameters.map((parameter) => + renderClassicalParameter(parameter, indent), + ), + `${spaces}options?: ${operation.optionsType.name},`, + ].join("\n"); +} + +function renderClassicalParameter( + parameter: TSOperationParameter, + indent: number, +): string { + const spaces = " ".repeat(indent); + const lines = parameter.type.split("\n"); + if (lines.length === 1) { + return `${spaces}${parameter.name}: ${parameter.type},`; + } + return [ + `${spaces}${parameter.name}: ${(lines[0] ?? "").trimEnd()}`, + ...lines.slice(1).map((line, index, rest) => { + const trimmed = line.trim(); + const lineIndent = trimmed === "}" ? spaces : `${spaces} `; + const suffix = index === rest.length - 1 ? "," : ""; + return `${lineIndent}${trimmed}${suffix}`; + }), + ].join("\n"); +} + +function hasMultilineParameter(operation: TSOperation): boolean { + return operation.parameters.some((parameter) => + parameter.type.includes("\n"), + ); +} + +function shouldRenderMultilineSignature(operation: TSOperation): boolean { + return ( + hasMultilineParameter(operation) || + ` ${operation.name}: (${renderClassicalSignatureParameters(operation)}) => Promise<${operation.returnType.type}>;`.length > 100 + ); +} + +/** + * Renders serialization/deserialization helpers. + * + * Consumes: `TSCodeModel.serializers` + `TSCodeModel.models` + * Produces: `src/models/serializers.ts` + */ +export function renderSerializers( + _serializers: TSSerializerGroup[], + _models: TSModel[], +): RenderedFile[] { + throw new Error("renderSerializers: not yet implemented"); +} + +/** + * Renders static helper files (paging, polling, auth, etc.). + * + * Consumes: `TSCodeModel.helpers` + * Produces: `src/helpers/{category}.ts` + */ +export function renderHelpers(helpers: TSHelperFile[]): RenderedFile[] { + return helpers.map((helper) => { + if (helper.outputPath === "static-helpers/urlTemplate.ts") { + return { path: "src/static-helpers/urlTemplate.ts", content: urlTemplateHelperContent() }; + } + throw new Error(`Unsupported helper: ${helper.outputPath}`); + }); +} + +/** + * Renders barrel index files (root, subpath, models, api). + * + * Consumes: full `TSCodeModel` (needs to know all exported symbols) + * Produces: `src/index.ts`, `src/models/index.ts`, `src/api/index.ts`, etc. + */ +export function renderIndexFiles(codeModel: TSCodeModel): RenderedFile[] { + return [ + renderRootBarrel(codeModel), + ...codeModel.clients.map((client) => renderApiBarrel(client)), + renderModelsBarrel(codeModel), + ]; +} + +export function renderRootBarrel( + codeModel: Pick, +): RenderedFile { + const clientExports = codeModel.clients + .map((client) => getClientBaseName(client.name)) + .sort() + .map( + (clientBaseName) => + `export { ${clientBaseName}Client } from "./${lowerFirst(clientBaseName)}Client.js";`, + ) + .join("\n"); + const clientOptionNames = codeModel.clients + .map((client) => `${getClientBaseName(client.name)}ClientOptionalParams`) + .sort(); + const operationOptionExports = collectOperationOptionExports( + codeModel.clients, + ); + const groupInterfaceNames = codeModel.clients + .flatMap((client) => client.operationGroups) + .map((group) => `${upperFirst(group.name)}Operations`) + .sort(); + + const enumValueExports = codeModel.enums + .filter((enumType) => enumType.knownValuesOnly) + .map((enumType) => enumType.name) + .sort(); + + const sections = [ + clientExports, + enumValueExports.length > 0 + ? `export { ${enumValueExports.join(", ")} } from "./models/index.js";` + : "", + clientOptionNames.length > 0 + ? `export type { ${clientOptionNames.join(", ")} } from "./api/index.js";` + : "", + ...operationOptionExports, + groupInterfaceNames.length > 0 + ? `export type { ${groupInterfaceNames.join(", ")} } from "./classic/index.js";` + : "", + `export { RestError, isRestError } from "@azure/core-rest-pipeline";`, + ].filter((section) => section.length > 0); + + return { + path: "src/index.ts", + content: `${copyrightHeader()}\n\n${sections.join("\n")}\n`, + }; +} + +export function renderApiBarrel(client: TSClient): RenderedFile { + const clientBaseName = getClientBaseName(client.name); + const contextModule = `./${lowerFirst(clientBaseName)}Context.js`; + const contextTypeNames = [ + `${clientBaseName}Context`, + `${clientBaseName}ClientOptionalParams`, + ]; + const factoryName = `create${clientBaseName}`; + + const contextTypeExport = + contextTypeNames.length > 1 && (client.credential || client.apiVersion) + ? `export type {\n ${contextTypeNames.join(",\n ")},\n} from "${contextModule}";` + : `export type { ${contextTypeNames.join(", ")} } from "${contextModule}";`; + return { + path: "src/api/index.ts", + content: `${copyrightHeader()}\n\n${contextTypeExport}\nexport { ${factoryName} } from "${contextModule}";\n`, + }; +} + +export function renderModelsBarrel( + codeModel: Pick, +): RenderedFile { + const valueNames = codeModel.enums + .filter((enumType) => enumType.knownValuesOnly) + .map((enumType) => enumType.name) + .sort(); + const typeNames = [ + ...codeModel.models.map((model) => model.name), + ...codeModel.enums + .filter((enumType) => !enumType.knownValuesOnly) + .map((enumType) => enumType.name), + ...codeModel.unions.map((union) => union.name), + ] + .filter((name) => !name.startsWith("_")) + .sort(); + const sections = [ + valueNames.length > 0 ? `export { ${valueNames.join(", ")} } from "./models.js";` : "", + typeNames.length > 0 ? `export type { ${typeNames.join(", ")} } from "./models.js";` : "", + ].filter((section) => section.length > 0); + const content = + sections.length > 0 + ? `${copyrightHeader()}\n\n${sections.join("\n")}\n` + : `${copyrightHeader()}\n`; + + return { + path: "src/models/index.ts", + content, + }; +} + +function collectOperationOptionExports(clients: TSClient[]): string[] { + const optionsByGroup = new Map>(); + for (const client of clients) { + for (const group of client.operationGroups) { + const groupOptions = optionsByGroup.get(group.name) ?? new Set(); + for (const operation of group.operations) { + groupOptions.add(operation.optionsType.name); + } + optionsByGroup.set(group.name, groupOptions); + } + } + + return [...optionsByGroup.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([groupName, optionNames]) => + renderTypeExport([...optionNames], `./api/${groupName}/index.js`), + ); +} + +function renderTypeExport(typeNames: string[], modulePath: string): string { + if (typeNames.length === 0) { + return ""; + } + if (typeNames.length === 1) { + return `export type { ${typeNames[0]} } from "${modulePath}";`; + } + return `export type {\n ${typeNames.join(",\n ")},\n} from "${modulePath}";`; +} + +/** + * Renders package infrastructure files (package.json, tsconfig, etc.). + * + * Consumes: `TSCodeModel.packageInfo` + * Produces: `package.json`, `tsconfig.json`, `README.md` + */ +export function renderPackageFiles(codeModel: TSCodeModel): RenderedFile[] { + return [ + { + path: "package.json", + content: `${renderPackageJson(codeModel.packageInfo)}\n`, + }, + { path: "tsconfig.json", content: `${renderTsconfig()}\n` }, + { path: "README.md", content: renderReadme(codeModel.packageInfo) }, + { path: "CHANGELOG.md", content: renderChangelog(codeModel.packageInfo) }, + { path: "LICENSE", content: renderLicense() }, + { path: "api-extractor.json", content: `${renderApiExtractorJson(codeModel.packageInfo)}\n` }, + { path: "eslint.config.mjs", content: renderEslintConfig() }, + { path: "rollup.config.js", content: renderRollupConfig() }, + ]; +} + +function renderLicense(): string { + return `Copyright (c) Microsoft Corporation. + +MIT License + +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.`; +} + +function renderEslintConfig(): string { + return `import azsdkEslint from "@azure/eslint-plugin-azure-sdk"; + +export default azsdkEslint.config([ + { + rules: { + "@azure/azure-sdk/ts-modules-only-named": "warn", + "@azure/azure-sdk/ts-package-json-types": "warn", + "@azure/azure-sdk/ts-package-json-engine-is-present": "warn", + "@azure/azure-sdk/ts-package-json-files-required": "off", + "@azure/azure-sdk/ts-package-json-main-is-cjs": "off", + "tsdoc/syntax": "warn" + } + } +]); +`; +} + +function renderRollupConfig(): string { + return `// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import nodeResolve from "@rollup/plugin-node-resolve"; +import cjs from "@rollup/plugin-commonjs"; +import sourcemaps from "rollup-plugin-sourcemaps"; +import multiEntry from "@rollup/plugin-multi-entry"; +import json from "@rollup/plugin-json"; + +import nodeBuiltins from "builtin-modules"; + +// #region Warning Handler + +/** + * A function that can determine whether a rollup warning should be ignored. If + * the function returns \`true\`, then the warning will not be displayed. + */ + +function ignoreNiseSinonEval(warning) { + return ( + warning.code === "EVAL" && + warning.id && + (warning.id.includes("node_modules/nise") || warning.id.includes("node_modules/sinon")) === true + ); +} + +function ignoreChaiCircularDependency(warning) { + return ( + warning.code === "CIRCULAR_DEPENDENCY" && + warning.importer && + warning.importer.includes("node_modules/chai") === true + ); +} + +const warningInhibitors = [ignoreChaiCircularDependency, ignoreNiseSinonEval]; + +/** + * Construct a warning handler for the shared rollup configuration + * that ignores certain warnings that are not relevant to testing. + */ +function makeOnWarnForTesting() { + return (warning, warn) => { + // If every inhibitor returns false (i.e. no inhibitors), then show the warning + if (warningInhibitors.every((inhib) => !inhib(warning))) { + warn(warning); + } + }; +} + +// #endregion + +function makeBrowserTestConfig() { + const config = { + input: { + include: ["dist-esm/test/**/*.spec.js"], + exclude: ["dist-esm/test/**/node/**"], + }, + output: { + file: \`dist-test/index.browser.js\`, + format: "umd", + sourcemap: true, + }, + preserveSymlinks: false, + plugins: [ + multiEntry({ exports: false }), + nodeResolve({ + mainFields: ["module", "browser"], + }), + cjs(), + json(), + sourcemaps(), + //viz({ filename: "dist-test/browser-stats.html", sourcemap: true }) + ], + onwarn: makeOnWarnForTesting(), + // Disable tree-shaking of test code. In rollup-plugin-node-resolve@5.0.0, + // rollup started respecting the "sideEffects" field in package.json. Since + // our package.json sets "sideEffects=false", this also applies to test + // code, which causes all tests to be removed by tree-shaking. + treeshake: false, + }; + + return config; +} + +const defaultConfigurationOptions = { + disableBrowserBundle: false, +}; + +export function makeConfig(pkg, options) { + options = { + ...defaultConfigurationOptions, + ...(options || {}), + }; + + const baseConfig = { + // Use the package's module field if it has one + input: pkg["module"] || "dist-esm/src/index.js", + external: [ + ...nodeBuiltins, + ...Object.keys(pkg.dependencies), + ...Object.keys(pkg.devDependencies), + ], + output: { file: "dist/index.js", format: "cjs", sourcemap: true }, + preserveSymlinks: false, + plugins: [sourcemaps(), nodeResolve()], + }; + + const config = [baseConfig]; + + if (!options.disableBrowserBundle) { + config.push(makeBrowserTestConfig()); + } + + return config; +} + +export default makeConfig(require("./package.json")); +`; +} + +function renderChangelog(packageInfo: TSPackageInfo): string { + return `# Release History\n\n## ${packageInfo.version} (Unreleased)\n\n### Features Added\n\n### Breaking Changes\n\n### Bugs Fixed\n\n### Other Changes\n`; +} + +function renderApiExtractorJson(packageInfo: TSPackageInfo): string { + const shortName = getPackageShortName(packageInfo.name); + return `{ + "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", + "mainEntryPointFilePath": "dist/esm/index.d.ts", + "docModel": { "enabled": true }, + "apiReport": { "enabled": true, "reportFolder": "./review" }, + "dtsRollup": { + "enabled": true, + "untrimmedFilePath": "", + "publicTrimmedFilePath": "dist/${shortName}.d.ts" + }, + "messages": { + "tsdocMessageReporting": { "default": { "logLevel": "none" } }, + "extractorMessageReporting": { + "ae-missing-release-tag": { "logLevel": "none" }, + "ae-unresolved-link": { "logLevel": "none" } + } + } +}`; +} + +function urlTemplateHelperContent(): string { + return "// Copyright (c) Microsoft Corporation.\n// Licensed under the MIT License.\n\n// ---------------------\n// interfaces\n// ---------------------\ninterface ValueOptions {\n isFirst: boolean; // is first value in the expression\n op?: string; // operator\n varValue?: any; // variable value\n varName?: string; // variable name\n modifier?: string; // modifier e.g *\n reserved?: boolean; // if true we'll keep reserved words with not encoding\n}\n\nexport interface UrlTemplateOptions {\n // if set to true, reserved characters will not be encoded\n allowReserved?: boolean;\n}\n\n// ---------------------\n// helpers\n// ---------------------\nfunction encodeComponent(val: string, reserved?: boolean, op?: string): string {\n return (reserved ?? op === \"+\") || op === \"#\"\n ? encodeReservedComponent(val)\n : encodeRFC3986URIComponent(val);\n}\n\nfunction encodeReservedComponent(str: string): string {\n return str\n .split(/(%[0-9A-Fa-f]{2})/g)\n .map((part) => (!/%[0-9A-Fa-f]/.test(part) ? encodeURI(part) : part))\n .join(\"\");\n}\n\nfunction encodeRFC3986URIComponent(str: string): string {\n return encodeURIComponent(str).replace(\n /[!'()*]/g,\n (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`,\n );\n}\n\nfunction isDefined(val: any): boolean {\n return val !== undefined && val !== null;\n}\n\nfunction getNamedAndIfEmpty(op?: string): [boolean, string] {\n return [!!op && [\";\", \"?\", \"&\"].includes(op), !!op && [\"?\", \"&\"].includes(op) ? \"=\" : \"\"];\n}\n\nfunction getFirstOrSep(op?: string, isFirst = false): string {\n if (isFirst) {\n return !op || op === \"+\" ? \"\" : op;\n } else if (!op || op === \"+\" || op === \"#\") {\n return \",\";\n } else if (op === \"?\") {\n return \"&\";\n } else {\n return op;\n }\n}\n\nfunction getExpandedValue(option: ValueOptions): string {\n let isFirst = option.isFirst;\n const { op, varName, varValue: value, reserved } = option;\n const vals: string[] = [];\n const [named, ifEmpty] = getNamedAndIfEmpty(op);\n\n if (Array.isArray(value)) {\n for (const val of value.filter(isDefined)) {\n // prepare the following parts: separator, varName, value\n vals.push(`${getFirstOrSep(op, isFirst)}`);\n if (named && varName) {\n vals.push(`${encodeURIComponent(varName)}`);\n if (val === \"\") {\n vals.push(ifEmpty);\n } else {\n vals.push(\"=\");\n }\n }\n vals.push(encodeComponent(val, reserved, op));\n isFirst = false;\n }\n } else if (typeof value === \"object\") {\n for (const key of Object.keys(value)) {\n const val = value[key];\n if (!isDefined(val)) {\n continue;\n }\n // prepare the following parts: separator, key, value\n vals.push(`${getFirstOrSep(op, isFirst)}`);\n if (key) {\n vals.push(`${encodeURIComponent(key)}`);\n if (named && val === \"\") {\n vals.push(ifEmpty);\n } else {\n vals.push(\"=\");\n }\n }\n vals.push(encodeComponent(val, reserved, op));\n isFirst = false;\n }\n }\n return vals.join(\"\");\n}\n\nfunction getNonExpandedValue(option: ValueOptions): string | undefined {\n const { op, varName, varValue: value, isFirst, reserved } = option;\n const vals: string[] = [];\n const first = getFirstOrSep(op, isFirst);\n const [named, ifEmpty] = getNamedAndIfEmpty(op);\n if (named && varName) {\n vals.push(encodeComponent(varName, reserved, op));\n if (value === \"\") {\n if (!ifEmpty) {\n vals.push(ifEmpty);\n }\n return !vals.join(\"\") ? undefined : `${first}${vals.join(\"\")}`;\n }\n vals.push(\"=\");\n }\n\n const items = [];\n if (Array.isArray(value)) {\n for (const val of value.filter(isDefined)) {\n items.push(encodeComponent(val, reserved, op));\n }\n } else if (typeof value === \"object\") {\n for (const key of Object.keys(value)) {\n if (!isDefined(value[key])) {\n continue;\n }\n items.push(encodeRFC3986URIComponent(key));\n items.push(encodeComponent(value[key], reserved, op));\n }\n }\n vals.push(items.join(\",\"));\n return !vals.join(\",\") ? undefined : `${first}${vals.join(\"\")}`;\n}\n\nfunction getVarValue(option: ValueOptions): string | undefined {\n const { op, varName, modifier, isFirst, reserved, varValue: value } = option;\n\n if (!isDefined(value)) {\n return undefined;\n } else if ([\"string\", \"number\", \"boolean\"].includes(typeof value)) {\n let val = value.toString();\n const [named, ifEmpty] = getNamedAndIfEmpty(op);\n const vals: string[] = [getFirstOrSep(op, isFirst)];\n if (named && varName) {\n // No need to encode varName considering it is already encoded\n vals.push(varName);\n if (val === \"\") {\n vals.push(ifEmpty);\n } else {\n vals.push(\"=\");\n }\n }\n if (modifier && modifier !== \"*\") {\n val = val.substring(0, parseInt(modifier, 10));\n }\n vals.push(encodeComponent(val, reserved, op));\n return vals.join(\"\");\n } else if (modifier === \"*\") {\n return getExpandedValue(option);\n } else {\n return getNonExpandedValue(option);\n }\n}\n\n// ---------------------------------------------------------------------------------------------------\n// This is an implementation of RFC 6570 URI Template: https://datatracker.ietf.org/doc/html/rfc6570.\n// ---------------------------------------------------------------------------------------------------\nexport function expandUrlTemplate(\n template: string,\n context: Record,\n option?: UrlTemplateOptions,\n): string {\n const result = template.replace(/\\{([^{}]+)\\}|([^{}]+)/g, (_, expr, text) => {\n if (!expr) {\n return encodeReservedComponent(text);\n }\n let op;\n if ([\"+\", \"#\", \".\", \"/\", \";\", \"?\", \"&\"].includes(expr[0])) {\n op = expr[0];\n expr = expr.slice(1);\n }\n const varList = expr.split(/,/g);\n const innerResult = [];\n for (const varSpec of varList) {\n const varMatch = /([^:*]*)(?::(\\d+)|(\\*))?/.exec(varSpec);\n if (!varMatch || !varMatch[1]) {\n continue;\n }\n const varValue = getVarValue({\n isFirst: innerResult.length === 0,\n op,\n varValue: context[varMatch[1]],\n varName: varMatch[1],\n modifier: varMatch[2] || varMatch[3],\n reserved: option?.allowReserved,\n });\n if (varValue) {\n innerResult.push(varValue);\n }\n }\n return innerResult.join(\"\");\n });\n\n return normalizeUnreserved(result);\n}\n\n/**\n * Normalize an expanded URI by decoding percent-encoded unreserved characters.\n * RFC 3986 unreserved: \"-\" / \".\" / \"~\"\n */\nfunction normalizeUnreserved(uri: string): string {\n return uri.replace(/%([0-9A-Fa-f]{2})/g, (match, hex) => {\n const char = String.fromCharCode(parseInt(hex, 16));\n // Decode only if it's unreserved\n if (/[.~-]/.test(char)) {\n return char;\n }\n return match; // leave other encodings intact\n });\n}\n"; +} + +function copyrightHeader(): string { + return `// Copyright (c) Microsoft Corporation.\n// Licensed under the MIT License.`; +} + +function renderPackageJson(packageInfo: TSPackageInfo): string { + const tshyExports = Object.fromEntries([ + ["./package.json", "./package.json"], + ...packageInfo.exports.map((item) => [item.subpath, item.source]), + ]); + const packageJson = { + name: packageInfo.name, + version: packageInfo.version, + description: packageInfo.description ?? `A generated SDK for ${packageInfo.clientName}.`, + engines: { node: ">=20.0.0" }, + sideEffects: false, + autoPublish: false, + tshy: { + exports: tshyExports, + dialects: ["esm", "commonjs"], + esmDialects: ["browser"], + selfLink: false, + }, + type: "module", + browser: "./dist/browser/index.js", + keywords: ["node", "azure", "cloud", "typescript", "browser", "isomorphic"], + author: "Microsoft Corporation", + license: "MIT", + files: ["dist/", "!dist/**/*.d.*ts.map", "README.md", "LICENSE"], + dependencies: { + "@azure/core-util": "^1.9.2", + "@azure-rest/core-client": "^2.3.1", + "@azure/core-auth": "^1.6.0", + "@azure/core-rest-pipeline": "^1.5.0", + "@azure/logger": "^1.0.0", + tslib: "^2.6.2", + }, + devDependencies: { + dotenv: "^16.0.0", + "@types/node": "^20.0.0", + eslint: "^9.9.0", + typescript: "~5.8.2", + tshy: "^2.0.0", + "@microsoft/api-extractor": "^7.40.3", + rimraf: "^5.0.5", + mkdirp: "^3.0.1", + }, + scripts: { + clean: + "rimraf --glob dist dist-browser dist-esm test-dist temp types *.tgz *.log", + "extract-api": + "rimraf review && mkdirp ./review && api-extractor run --local", + pack: "npm pack 2>&1", + lint: "eslint package.json api-extractor.json src", + "lint:fix": + "eslint package.json api-extractor.json src --fix --fix-type [problem,suggestion]", + build: "npm run clean && tshy && npm run extract-api", + }, + exports: renderPackageExports(packageInfo.exports), + main: "./dist/commonjs/index.js", + types: "./dist/commonjs/index.d.ts", + module: "./dist/esm/index.js", + }; + return JSON.stringify(packageJson, undefined, 2); +} + +function renderPackageExports( + exports: TSPackageInfo["exports"], +): Record { + return Object.fromEntries([ + ["./package.json", "./package.json"], + ...exports.map((item) => [item.subpath, renderPackageExport(item.source)]), + ]); +} + +function renderPackageExport( + source: string, +): Record> { + const distPath = source + .replace(/^\.\/src\//, "./dist/{dialect}/") + .replace(/\.ts$/, ".js"); + const typesPath = distPath.replace(/\.js$/, ".d.ts"); + return { + browser: { + types: typesPath.replace("{dialect}", "browser"), + default: distPath.replace("{dialect}", "browser"), + }, + import: { + types: typesPath.replace("{dialect}", "esm"), + default: distPath.replace("{dialect}", "esm"), + }, + require: { + types: typesPath.replace("{dialect}", "commonjs"), + default: distPath.replace("{dialect}", "commonjs"), + }, + }; +} + +function renderTsconfig(): string { + return `{ + "compilerOptions": { + "target": "ES2017", + "module": "NodeNext", + "lib": [], + "declaration": true, + "declarationMap": true, + "inlineSources": true, + "sourceMap": true, + "importHelpers": true, + "strict": true, + "alwaysStrict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "forceConsistentCasingInFileNames": true, + "moduleResolution": "NodeNext", + "allowSyntheticDefaultImports": true, + "esModuleInterop": true + }, + "include": ["src/**/*.ts"] +}`; +} + +function renderReadme(packageInfo: TSPackageInfo): string { + return `# ${packageInfo.serviceName} client library for JavaScript + +This package contains an isomorphic SDK (runs both in Node.js and in browsers) for ${packageInfo.serviceName} client. + +${packageInfo.description ?? ""} + +Key links: + +- [Package (NPM)](https://www.npmjs.com/package/${packageInfo.name}) + +## Getting started + +### Currently supported environments + +- [LTS versions of Node.js](https://github.com/nodejs/release#release-schedule) +- Latest versions of Safari, Chrome, Edge and Firefox. + +See our [support policy](https://github.com/Azure/azure-sdk-for-js/blob/main/SUPPORT.md) for more details. + + +### Install the \`${packageInfo.name}\` package + +Install the ${packageInfo.serviceName} client library for JavaScript with \`npm\`: + +\`\`\`bash +npm install ${packageInfo.name} +\`\`\` + + + +### JavaScript Bundle +To use this client library in the browser, first you need to use a bundler. For details on how to do this, please refer to our [bundling documentation](https://aka.ms/AzureSDKBundling). + +## Key concepts + +### ${packageInfo.clientName} + +\`${packageInfo.clientName}\` is the primary interface for developers using the ${packageInfo.serviceName} client library. Explore the methods on this client object to understand the different features of the ${packageInfo.serviceName} service that you can access. + +`; +} + +/** + * Renders logger file. + * + * Consumes: `TSCodeModel.settings.packageName` + * Produces: `src/logger.ts` + */ +export function renderLogger(packageInfo: TSPackageInfo): RenderedFile { + return { + path: "src/logger.ts", + content: `${copyrightHeader()}\n\nimport { createClientLogger } from "@azure/logger";\nexport const logger = createClientLogger("${getPackageShortName(packageInfo.name)}");\n`, + }; +} + +function getClientBaseName(clientName: string): string { + return clientName.endsWith("Client") + ? clientName.slice(0, -"Client".length) + : clientName; +} + +function getEndpointParameter( + client: TSClient, +): TSClient["parameters"][number] | undefined { + return client.parameters.find((parameter) => + parameter.name.toLowerCase().includes("endpoint"), + ); +} + +function getPackageShortName(packageName: string): string { + return packageName.split("/").at(-1) ?? packageName; +} + +function lowerFirst(name: string): string { + return `${name.charAt(0).toLowerCase()}${name.slice(1)}`; +} + +function upperFirst(name: string): string { + return `${name.charAt(0).toUpperCase()}${name.slice(1)}`; +} diff --git a/packages/typespec-ts-pristine/src/codemodel/index.ts b/packages/typespec-ts-pristine/src/codemodel/index.ts new file mode 100644 index 0000000000..25e75c44a7 --- /dev/null +++ b/packages/typespec-ts-pristine/src/codemodel/index.ts @@ -0,0 +1,413 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * TypeScript Code Model — The contract between adapter and renderer. + * + * This module defines the complete intermediate representation (IR) for a + * TypeScript client library. It is: + * + * - Pure data: no methods, no side effects, no imports from TCGC or ts-morph + * - Serializable: can be JSON.stringify'd for snapshot testing + * - Complete: contains everything the renderer needs — no back-references to TCGC + * + * Pattern: same role as Go's `codemodel.go/` and Rust's `codemodel/`. + */ + +// ─── Code Model Root ────────────────────────────────────────────────── + +/** Root of the TypeScript code model. Everything needed to generate a complete client library. */ +export interface TSCodeModel { + /** Package-level metadata and generation settings. */ + settings: TSGenerationSettings; + + /** Generated SDK package metadata. */ + packageInfo: TSPackageInfo; + + /** All top-level clients in the package. May contain nested children. */ + clients: TSClient[]; + + /** All named model/interface types to emit. */ + models: TSModel[]; + + /** All named enum types to emit. */ + enums: TSEnum[]; + + /** All named union types to emit. */ + unions: TSUnion[]; + + /** Serialization helpers needed (JSON, XML, etc.) */ + serializers: TSSerializerGroup[]; + + /** Static helper files to copy into the output. */ + helpers: TSHelperFile[]; + + /** Paging metadata for paged operations. */ + pagingInfo: TSPagingConfig; + + /** Polling metadata for long-running operations. */ + pollingInfo: TSPollingConfig; +} + +// ─── Generation Settings ────────────────────────────────────────────── + +/** Resolved emitter configuration. Not raw options — normalized and validated. */ +export interface TSGenerationSettings { + /** Package name (e.g., "@azure/storage-blob") */ + packageName: string; + /** Package version (e.g., "1.0.0") */ + packageVersion: string; + /** Package description. */ + packageDescription?: string; + /** Azure-flavored or unbranded */ + flavor: "azure" | "unbranded"; + /** Whether this is an ARM management-plane SDK */ + isArm: boolean; + /** Source output directory */ + outputDir: string; + /** Whether to emit credential support */ + addCredentials: boolean; + /** OAuth scopes for credential */ + credentialScopes: string[]; + /** Whether multi-client generation is enabled */ + isMultiClient: boolean; + /** Whether hierarchical client pattern is used */ + hierarchyClient: boolean; +} + +/** Credential configuration for Azure or API-key authenticated clients. */ +export interface TSCredentialInfo { + /** Constructor parameter name. */ + paramName: string; + /** TypeScript type expression for the credential parameter. */ + type: string; + /** OAuth scopes, when token credentials are supported. */ + scopes: string[]; + /** API-key header name, when key credentials are supported. */ + apiKeyHeaderName?: string; +} + +// ─── Package Info ───────────────────────────────────────────────────── + +/** Generated SDK package.json and README metadata. */ +export interface TSPackageInfo { + /** Package name (e.g., "@azure/storage-blob") */ + name: string; + /** Package version (e.g., "1.0.0") */ + version: string; + /** Package description. */ + description?: string; + /** Human-readable service/client name used in metadata docs. */ + serviceName: string; + /** Primary client class name. */ + clientName: string; + /** Package subpath exports backed by emitted source files. */ + exports: TSPackageExport[]; +} + +/** A package subpath export. */ +export interface TSPackageExport { + /** Package export path (e.g., "." or "./models") */ + subpath: string; + /** Source TypeScript entry point (e.g., "./src/index.ts") */ + source: string; +} + +// ─── Client ─────────────────────────────────────────────────────────── + +/** A client in the generated SDK. Drives both context factory and classical class emission. */ +export interface TSClient { + /** Client class name (e.g., "BlobClient") */ + name: string; + /** Documentation lines */ + docs: string[]; + /** Client initialization parameters */ + parameters: TSParameter[]; + /** Endpoint configuration */ + endpoint: TSEndpoint; + /** API version info (if versioned) */ + apiVersion?: TSApiVersion; + /** Credential configuration, when authentication is required. */ + credential?: TSCredentialInfo; + /** Operation groups on this client */ + operationGroups: TSOperationGroup[]; + /** Direct methods (ungrouped operations) */ + methods: TSOperation[]; + /** Sub-clients (hierarchical client pattern) */ + children: TSClient[]; +} + +/** Endpoint configuration for a client. */ +export interface TSEndpoint { + /** URL template (e.g., "{endpoint}/v1") */ + urlTemplate: string; + /** Whether the endpoint is parameterized */ + isParameterized: boolean; + /** Template parameters */ + templateParams: TSParameter[]; +} + +/** API version configuration. */ +export interface TSApiVersion { + /** Parameter name */ + paramName: string; + /** Default value */ + defaultValue?: string; + /** Whether version is embedded in endpoint template */ + isInEndpoint: boolean; +} + +/** Operation-level query parameter backed by the client apiVersion option. */ +export interface TSApiVersionQuery { + /** Serialized query name, e.g. api-version. */ + serializedName: string; + /** Percent-encoded URI-template variable name. */ + encodedName: string; + /** Default API version. */ + defaultValue: string; +} + +// ─── Operations ─────────────────────────────────────────────────────── + +/** A group of operations sharing a prefix/namespace. */ +export interface TSOperationGroup { + /** Group name (e.g., "containers") */ + name: string; + /** Operations in this group */ + operations: TSOperation[]; +} + +/** A single API operation. Drives operation file, send/deserialize helpers, and options interface. */ +export interface TSOperation { + /** Method name (e.g., "listBlobs") */ + name: string; + /** Documentation */ + docs: string[]; + /** Operation kind — determines which runtime pattern to use */ + kind: "basic" | "paging" | "lro" | "lroPaging"; + /** HTTP method */ + httpMethod: string; + /** URL path template */ + path: string; + /** All parameters for this operation */ + parameters: TSOperationParameter[]; + /** Return type info */ + returnType: TSReturnType; + /** Options interface for this operation */ + optionsType: TSOptionsType; + /** Request body emission style. */ + bodyShape: "inline" | "named-with-serializer" | "raw"; + /** Request content type, when known. */ + contentType?: string; + /** Client api-version query parameter metadata, when the operation uses it. */ + apiVersionQuery?: TSApiVersionQuery; + /** HTTP success status codes accepted by the deserializer. */ + expectedStatuses: string[]; +} + +/** A parameter on an operation. */ +export interface TSOperationParameter { + /** Parameter name */ + name: string; + /** TypeScript type expression */ + type: string; + /** Where this parameter goes on the wire */ + location: "path" | "query" | "header" | "body"; + /** Whether the parameter is required */ + required: boolean; + /** Default value expression (if any) */ + defaultValue?: string; + /** Documentation */ + docs: string[]; +} + +/** Return type metadata for an operation. */ +export interface TSReturnType { + /** Full TypeScript type expression */ + type: string; + /** Whether the response is void/204 */ + isVoid: boolean; + /** Whether the response can be null */ + nullable: boolean; +} + +/** Options interface for an operation (e.g., "ListBlobsOptionalParams"). */ +export interface TSOptionsType { + /** Interface name */ + name: string; + /** Properties on the options bag */ + properties: TSProperty[]; +} + +// ─── Parameters ─────────────────────────────────────────────────────── + +/** A parameter (used for both client and operation parameters). */ +export interface TSParameter { + /** Parameter name */ + name: string; + /** TypeScript type expression */ + type: string; + /** Whether this is required */ + required: boolean; + /** Default value (if any) */ + defaultValue?: string; + /** Documentation */ + docs: string[]; +} + +// ─── Models ─────────────────────────────────────────────────────────── + +/** A named model/interface type. Drives models.ts emission and serializer generation. */ +export interface TSModel { + /** Model name (e.g., "BlobProperties") */ + name: string; + /** Documentation */ + docs: string[]; + /** Properties */ + properties: TSProperty[]; + /** Parent model (for inheritance) */ + baseModel?: string; + /** Additional properties type (for Record patterns) */ + additionalPropertiesType?: string; + /** Discriminator info (for polymorphic hierarchies) */ + discriminator?: TSDiscriminator; + /** Whether this model needs a serializer */ + needsSerializer: boolean; + /** Serializer function name, when emitted */ + serializerName?: string; + /** Whether this model needs a deserializer */ + needsDeserializer: boolean; + /** Deserializer function name, when emitted */ + deserializerName?: string; +} + +/** A property on a model or options interface. */ +export interface TSProperty { + /** Property name */ + name: string; + /** TypeScript type expression */ + type: string; + /** Whether the property is optional */ + optional: boolean; + /** Whether the property is readonly */ + readonly: boolean; + /** Wire name for serialization (if different from name) */ + serializedName?: string; + /** Documentation */ + docs: string[]; +} + +/** Discriminator metadata for polymorphic models. */ +export interface TSDiscriminator { + /** Discriminator property name */ + propertyName: string; + /** Value for this specific model variant */ + value?: string; + /** All known derived type names */ + variants: string[]; +} + +// ─── Enums ──────────────────────────────────────────────────────────── + +/** A named enum type. May be fixed or extensible. */ +export interface TSEnum { + /** Enum type alias name */ + name: string; + /** Documentation */ + docs: string[]; + /** Enum members */ + members: TSEnumMember[]; + /** Whether the enum is extensible (allows unknown values) */ + isExtensible: boolean; + /** Whether to emit only the Known* enum value container. */ + knownValuesOnly?: boolean; + /** Underlying value type ("string" or "number") */ + valueType: "string" | "number"; +} + +/** A single enum member. */ +export interface TSEnumMember { + /** Member name */ + name: string; + /** Member value */ + value: string | number; + /** Documentation */ + docs?: string[]; +} + +// ─── Unions ─────────────────────────────────────────────────────────── + +/** A named union type (TypeSpec `union` keyword). */ +export interface TSUnion { + /** Union type alias name */ + name: string; + /** Documentation */ + docs: string[]; + /** Variant types */ + variants: TSUnionVariant[]; + /** Discriminator info (for discriminated unions) */ + discriminator?: TSUnionDiscriminator; +} + +/** A variant in a union. */ +export interface TSUnionVariant { + /** Variant label (if named) */ + name?: string; + /** TypeScript type expression */ + type: string; +} + +/** Discriminator metadata for discriminated unions. */ +export interface TSUnionDiscriminator { + /** Property used for discrimination */ + propertyName: string; +} + +// ─── Serializers ────────────────────────────────────────────────────── + +/** A group of serialization/deserialization functions for a content type. */ +export interface TSSerializerGroup { + /** Content type this serializer handles (e.g., "application/json", "application/xml") */ + contentType: string; + /** Models that need serialization in this format */ + models: string[]; +} + +// ─── Helpers ────────────────────────────────────────────────────────── + +/** A static helper file to copy into the output tree. */ +export interface TSHelperFile { + /** Output path relative to source root */ + outputPath: string; + /** Helper category for organizational purposes */ + category: + | "paging" + | "polling" + | "serialization" + | "auth" + | "logging" + | "url" + | "multipart"; +} + +// ─── Paging ─────────────────────────────────────────────────────────── + +/** Paging configuration for the package. */ +export interface TSPagingConfig { + /** Whether any operation uses paging */ + hasPaging: boolean; + /** Item property path in paged responses */ + itemPropertyPath?: string; + /** Next link property path */ + nextLinkPropertyPath?: string; +} + +// ─── Polling ────────────────────────────────────────────────────────── + +/** Polling configuration for long-running operations. */ +export interface TSPollingConfig { + /** Whether any operation uses LRO */ + hasLro: boolean; + /** Whether to emit compatibility restorePoller helper */ + emitRestorePoller: boolean; +} diff --git a/packages/typespec-ts-pristine/src/comparator/index.ts b/packages/typespec-ts-pristine/src/comparator/index.ts new file mode 100644 index 0000000000..9d4573ad5d --- /dev/null +++ b/packages/typespec-ts-pristine/src/comparator/index.ts @@ -0,0 +1,307 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Comparator — Validates pristine emitter output against the baseline emitter. + * + * Interface: + * compare(fixtures, baselineEmitter, candidateEmitter) → CompareResult + * + * Steps: + * 1. For each fixture in the fixture directory, run both emitters + * 2. Collect generated output trees + * 3. Produce: tree diff (files added/removed), per-file unified diff, summary score + * + * The comparator does NOT evaluate correctness — only equivalence. Correctness + * is validated by the existing integration test suite. + * + * IMPORTANT: This module does NOT import the baseline emitter as a dependency. + * It either invokes it as a subprocess (npx @azure-tools/typespec-ts) or reads + * pre-generated output from disk. No workspace coupling. + * + * Location: packages/typespec-ts-pristine/src/comparator/ + * CLI entry: `pnpm compare` + */ + +import { compile, logDiagnostics, NodeHost, type EmitContext } from "@typespec/compiler"; +import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { dirname, join, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { emit } from "../index.js"; + +/** Result of comparing two emitter outputs for a single fixture. */ +export interface FixtureCompareResult { + /** Fixture name/path */ + fixture: string; + /** Files only in baseline output */ + filesOnlyInBaseline: string[]; + /** Files only in candidate output */ + filesOnlyInCandidate: string[]; + /** Files present in both with differing content */ + filesWithDiffs: FileDiff[]; + /** Files present in both with identical content */ + filesIdentical: string[]; + /** Percentage of files that are identical (0-100) */ + score: number; +} + +/** A unified diff for a single file. */ +export interface FileDiff { + /** Relative file path */ + path: string; + /** Unified diff string */ + diff: string; + /** Line-level match percentage for this file (0-100). */ + matchRate: number; +} + +/** Aggregate result across all fixtures. */ +export interface CompareResult { + /** Per-fixture results */ + fixtures: FixtureCompareResult[]; + /** Overall score (average across fixtures) */ + overallScore: number; + /** Total files compared */ + totalFiles: number; + /** Total files identical */ + totalIdentical: number; +} + +/** + * Runs the comparator over one fixture output tree. + * + * @param fixturesDir - Path to the TypeSpec fixture project + * @param baselineOutput - Path to baseline emitter's generated output + * @param candidateOutput - Path to candidate (pristine) emitter's generated output + * @returns Comparison results with diffs and scores + */ +export function compare( + fixturesDir: string, + baselineOutput: string, + candidateOutput: string +): CompareResult { + const fixture = fixturesDir.split(/[\\/]/).filter(Boolean).at(-1) ?? fixturesDir; + const baselineFiles = listFiles(baselineOutput); + const candidateFiles = listFiles(candidateOutput); + const baselineSet = new Set(baselineFiles); + const candidateSet = new Set(candidateFiles); + const filesOnlyInBaseline = baselineFiles.filter((file) => !candidateSet.has(file)); + const filesOnlyInCandidate = candidateFiles.filter((file) => !baselineSet.has(file)); + const filesIdentical: string[] = []; + const filesWithDiffs: FileDiff[] = []; + + for (const file of baselineFiles.filter((item) => candidateSet.has(item))) { + const baseline = readFileSync(join(baselineOutput, file), "utf-8"); + const candidate = readFileSync(join(candidateOutput, file), "utf-8"); + if (baseline === candidate) { + filesIdentical.push(file); + } else { + filesWithDiffs.push({ + path: file, + diff: makeUnifiedDiff(file, baseline, candidate), + matchRate: getLineMatchRate(baseline, candidate) + }); + } + } + + const totalFiles = filesOnlyInBaseline.length + filesOnlyInCandidate.length + filesIdentical.length + filesWithDiffs.length; + const score = totalFiles === 0 ? 100 : (filesIdentical.length / totalFiles) * 100; + const fixtureResult: FixtureCompareResult = { + fixture, + filesOnlyInBaseline, + filesOnlyInCandidate, + filesWithDiffs, + filesIdentical, + score + }; + + return { + fixtures: [fixtureResult], + overallScore: fixtureResult.score, + totalFiles, + totalIdentical: filesIdentical.length + }; +} + +export async function compareFixture(fixtureName = "spread"): Promise { + const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../../../../.."); + const fixtureDir = join(repoRoot, "packages/typespec-test/test", fixtureName); + const baselineOutput = join(fixtureDir, "generated/typespec-ts"); + const candidateOutput = join(repoRoot, "packages/typespec-ts-pristine/dist/compare", fixtureName); + + rmSync(candidateOutput, { recursive: true, force: true }); + await runPristineEmitter(fixtureDir, candidateOutput); + + return compare(fixtureDir, baselineOutput, candidateOutput); +} + +async function runPristineEmitter(fixtureDir: string, outputDir: string): Promise { + const mainFile = join(fixtureDir, "spec/main.tsp"); + const config = join(fixtureDir, "tspconfig.yaml"); + const program = await compile(NodeHost, mainFile, { + config, + noEmit: true + }); + + if (program.diagnostics.length > 0) { + logDiagnostics(program.diagnostics, NodeHost.logSink); + } + if (program.hasError()) { + throw new Error(`TypeSpec compilation failed for ${fixtureDir}`); + } + + const context: EmitContext> = { + program, + emitterOutputDir: outputDir, + options: readTypespecTsOptions(config), + perf: { + startTimer: () => ({ end: () => 0 }), + time: (_label, callback) => callback(), + timeAsync: (_label, callback) => callback(), + report: () => undefined + } + }; + + const files = await emit(context); + for (const file of files) { + const path = join(outputDir, file.path); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, file.content); + } +} + +function listFiles(root: string, current = ""): string[] { + const directory = join(root, current); + if (!existsSync(directory)) { + return []; + } + + return readdirSync(directory) + .filter((entry) => !shouldSkipEntry(entry)) + .flatMap((entry) => { + const relativePath = current ? `${current}/${entry}` : entry; + const fullPath = join(root, relativePath); + return statSync(fullPath).isDirectory() ? listFiles(root, relativePath) : [relativePath]; + }) + .sort(); +} + +function shouldSkipEntry(entry: string): boolean { + return [".tshy", "dist", "dist-browser", "dist-esm", "node_modules", "review", "temp", "metadata.json", "package-lock.json"].includes(entry); +} + +function readTypespecTsOptions(configPath: string): Record { + const content = readFileSync(configPath, "utf-8"); + const packageName = content.match(/name:\s*["']?([^\s"']+)/)?.[1]; + const packageVersion = content.match(/version:\s*["']?([^\s"']+)/)?.[1]; + const packageDescription = content.match(/description:\s*["']?([^\n"']+)/)?.[1]?.trim(); + return { + "package-details": { + ...(packageName ? { name: packageName } : {}), + ...(packageVersion ? { version: packageVersion } : {}), + ...(packageDescription ? { description: packageDescription } : {}) + } + }; +} + +function makeUnifiedDiff(path: string, baseline: string, candidate: string): string { + const baselineLines = baseline.split(/\r?\n/); + const candidateLines = candidate.split(/\r?\n/); + const firstDifferentLine = findFirstDifferentLine(baselineLines, candidateLines); + const start = Math.max(firstDifferentLine - 3, 0); + const end = Math.min(Math.max(baselineLines.length, candidateLines.length), firstDifferentLine + 8); + const lines = [`--- baseline/${path}`, `+++ candidate/${path}`, `@@ -${start + 1},${end - start} +${start + 1},${end - start} @@`]; + + for (let index = start; index < end; index++) { + const baselineLine = baselineLines[index]; + const candidateLine = candidateLines[index]; + if (baselineLine === candidateLine) { + lines.push(` ${baselineLine ?? ""}`); + } else { + if (baselineLine !== undefined) { + lines.push(`-${baselineLine}`); + } + if (candidateLine !== undefined) { + lines.push(`+${candidateLine}`); + } + } + } + + return lines.join("\n"); +} + +function getLineMatchRate(baseline: string, candidate: string): number { + const baselineLines = baseline.split(/\r?\n/); + const candidateLines = candidate.split(/\r?\n/); + const denominator = Math.max(baselineLines.length, candidateLines.length); + if (denominator === 0) { + return 100; + } + return (getCommonLineCount(baselineLines, candidateLines) / denominator) * 100; +} + +function getCommonLineCount(left: string[], right: string[]): number { + const previous = Array.from({ length: right.length + 1 }, () => 0); + const current = Array.from({ length: right.length + 1 }, () => 0); + + for (const leftLine of left) { + for (let index = 0; index < right.length; index++) { + current[index + 1] = + leftLine === right[index] + ? previous[index]! + 1 + : Math.max(previous[index + 1]!, current[index]!); + } + previous.splice(0, previous.length, ...current); + current.fill(0); + } + + return previous[right.length] ?? 0; +} + +function findFirstDifferentLine(left: string[], right: string[]): number { + const length = Math.max(left.length, right.length); + for (let index = 0; index < length; index++) { + if (left[index] !== right[index]) { + return index; + } + } + return 0; +} + +function printResult(result: CompareResult): void { + for (const fixture of result.fixtures) { + const candidateCount = fixture.filesIdentical.length + fixture.filesWithDiffs.length + fixture.filesOnlyInCandidate.length; + console.log(`Fixture: ${fixture.fixture}`); + console.log(` Candidate emitted files: ${candidateCount}`); + console.log(` Score: ${fixture.score.toFixed(1)}% identical files`); + if (fixture.filesIdentical.length > 0) { + console.log(` Identical: ${fixture.filesIdentical.join(", ")}`); + } + if (fixture.filesWithDiffs.length > 0) { + console.log(" Per-file match rates:"); + for (const diff of fixture.filesWithDiffs) { + console.log(` ${diff.path}: ${diff.matchRate.toFixed(1)}%`); + } + } + if (fixture.filesOnlyInBaseline.length > 0) { + console.log(` Missing in candidate: ${fixture.filesOnlyInBaseline.join(", ")}`); + } + if (fixture.filesOnlyInCandidate.length > 0) { + console.log(` Extra in candidate: ${fixture.filesOnlyInCandidate.join(", ")}`); + } + for (const diff of fixture.filesWithDiffs.slice(0, 3)) { + console.log(` Diff: ${diff.path}`); + console.log(diff.diff); + } + } +} + +if (process.argv[1] && relative(process.argv[1], fileURLToPath(import.meta.url)) === "") { + const fixtureName = process.argv[2] ?? "spread"; + compareFixture(fixtureName) + .then(printResult) + .catch((error: unknown) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + }); +} diff --git a/packages/typespec-ts-pristine/src/index.ts b/packages/typespec-ts-pristine/src/index.ts new file mode 100644 index 0000000000..649cde7f89 --- /dev/null +++ b/packages/typespec-ts-pristine/src/index.ts @@ -0,0 +1,74 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * @azure-tools/typespec-ts-pristine — North-star TypeScript emitter. + * + * Three-phase pipeline: + * 1. adapt() — TCGC SDK context → language-specific IR (code model) + * 2. build() — (reserved for IR transformations / enrichment) + * 3. render() — code model → TypeScript source files + * + * This is the only file that orchestrates the pipeline. Each phase is a + * one-liner delegation to a focused module. + */ + +import type { EmitContext } from "@typespec/compiler"; +import { createTypeSpecLibrary } from "@typespec/compiler"; +import { adaptSdkContext } from "./tcgcadapter/index.js"; +import type { TSCodeModel } from "./codemodel/index.js"; +import { render } from "./codegen/index.js"; + +/** Output file descriptor produced by the renderer. */ +export interface OutputFile { + /** Relative path from output root (e.g., "src/models/index.ts") */ + path: string; + /** File content as a string */ + content: string; +} + +const EmitterOptionsSchema = { + type: "object", + additionalProperties: true +} as const; + +export const $lib = createTypeSpecLibrary({ + name: "@azure-tools/typespec-ts-pristine", + capabilities: { + dryRun: true + }, + diagnostics: {}, + emitter: { + options: EmitterOptionsSchema + } +}); + +/** + * Main emitter entry point. Called by the TypeSpec compiler via $onEmit. + * + * @param context - TypeSpec emit context (contains program + options) + * @returns Array of output files to write to disk + */ +export async function emit(context: EmitContext>): Promise { + // Phase 1: Adapt TCGC output into our language-specific IR + const codeModel: TSCodeModel = await adaptSdkContext(context); + + // Phase 2: (Reserved) IR-level transforms — none needed yet. + // No dedupe passes, no fixup loops. If you're adding one, fix the adapter instead. + + // Phase 3: Render IR into TypeScript source files + const files: OutputFile[] = render(codeModel); + + return files; +} + +export async function $onEmit(context: EmitContext>): Promise { + const files = await emit(context); + for (const file of files) { + await context.program.host.writeFile(`${context.emitterOutputDir}/${file.path}`, file.content); + } +} + +export { adaptSdkContext } from "./tcgcadapter/index.js"; +export type { TSCodeModel } from "./codemodel/index.js"; +export { render, renderModels } from "./codegen/index.js"; diff --git a/packages/typespec-ts-pristine/src/tcgcadapter/index.ts b/packages/typespec-ts-pristine/src/tcgcadapter/index.ts new file mode 100644 index 0000000000..a1fdd82318 --- /dev/null +++ b/packages/typespec-ts-pristine/src/tcgcadapter/index.ts @@ -0,0 +1,836 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * TCGC Adapter — Phase 1 of the emitter pipeline. + * + * This is the ONLY module that imports from @azure-tools/typespec-client-generator-core. + * Its job is to consume the TCGC SdkContext and produce a TSCodeModel — our + * language-specific intermediate representation. + * + * Downstream modules (codemodel, codegen) MUST NOT import TCGC types. + * If you need data from TCGC in the renderer, add it to the code model here. + */ + +import type { EmitContext } from "@typespec/compiler"; +import { + createSdkContext, + UsageFlags, + type SdkArrayType, + type SdkBuiltInType, + type SdkClientType, + type SdkConstantType, + type SdkContext, + type SdkCredentialParameter, + type SdkDictionaryType, + type SdkEndpointParameter, + type SdkEndpointType, + type SdkEnumType, + type SdkHttpOperation, + type SdkHttpParameter, + type SdkMethodParameter, + type SdkModelPropertyType, + type SdkPathParameter, + type SdkModelType, + type SdkNullableType, + type SdkServiceMethod, + type SdkServiceOperation, + type SdkTupleType, + type SdkType, + type SdkUnionType, +} from "@azure-tools/typespec-client-generator-core"; +import type { + TSClient, + TSCodeModel, + TSEndpoint, + TSEnum, + TSGenerationSettings, + TSModel, + TSOperation, + TSOperationGroup, + TSOperationParameter, + TSOptionsType, + TSParameter, + TSProperty, + TSReturnType, + TSUnion, +} from "../codemodel/index.js"; + +/** + * Adapts a TypeSpec emit context into a fully-resolved TSCodeModel. + * + * Internally calls createSdkContext() to get TCGC's language-neutral model, + * then maps every TCGC construct into our TypeScript-specific IR types. + * + * @param context - TypeSpec emit context + * @returns Complete code model ready for rendering + */ +export async function adaptSdkContext( + context: EmitContext>, +): Promise { + const sdkContext = await createPristineSdkContext(context); + + const models = _adaptModels(sdkContext); + + const settings = _resolveSettings(context); + return { + settings, + packageInfo: _resolvePackageInfo(settings, sdkContext), + clients: _adaptClients(sdkContext), + models, + enums: _adaptEnums(sdkContext), + unions: [], + serializers: [ + { + contentType: "application/json", + models: models + .filter((model) => model.needsSerializer || model.needsDeserializer) + .map((model) => model.name), + }, + ], + helpers: _adaptHelpers(sdkContext), + pagingInfo: { hasPaging: false }, + pollingInfo: { hasLro: false, emitRestorePoller: false }, + }; +} + +async function createPristineSdkContext( + context: EmitContext>, +): Promise { + context.options = { + ...context.options, + "generate-protocol-methods": true, + "generate-convenience-methods": true, + emitters: [ + { + main: "@azure-tools/typespec-ts-pristine", + metadata: { name: "@azure-tools/typespec-ts-pristine" }, + }, + ], + }; + + return createSdkContext(context, "@azure-tools/typespec-ts-pristine", { + flattenUnionAsEnum: false, + }); +} + +/** + * Adapts TCGC clients into TSClient IR nodes. + * Maps client hierarchy, parameters, methods, and operation groups. + */ +export function _adaptClients(sdkContext: SdkContext): TSClient[] { + return sdkContext.sdkPackage.clients.map(adaptClient); +} + +function adaptClient(client: SdkClientType): TSClient { + return { + name: client.name, + docs: getDocs(client), + parameters: + client.clientInitialization.parameters.map(adaptClientParameter), + endpoint: adaptEndpoint(client), + apiVersion: adaptApiVersion(client), + credential: adaptCredential(client), + operationGroups: _adaptOperations(client), + methods: client.methods.map(adaptOperation).reverse(), + children: (client.children ?? []).map(adaptClient), + }; +} + +/** + * Adapts operation groups and operations from a TCGC client. + */ +export function _adaptOperations( + sdkClient: SdkClientType, + _sdkContext?: SdkContext, +): TSOperationGroup[] { + return (sdkClient.children ?? []) + .filter((child) => child.methods.length > 0) + .map((child) => ({ + name: lowerFirst(child.name), + operations: child.methods.map(adaptOperation).reverse(), + })); +} + +function adaptOperation( + sdkMethod: SdkServiceMethod, +): TSOperation { + const bodyType = sdkMethod.operation.bodyParam?.type; + return { + name: lowerFirst(sdkMethod.name), + docs: getDocs(sdkMethod), + kind: sdkMethod.kind === "lropaging" ? "lroPaging" : sdkMethod.kind, + httpMethod: sdkMethod.operation.verb.toUpperCase(), + path: sdkMethod.operation.path, + parameters: _adaptOperationParameters(sdkMethod), + returnType: adaptReturnType(sdkMethod), + optionsType: _adaptOptionsType(sdkMethod), + bodyShape: getBodyShape(bodyType), + contentType: getOperationContentType(sdkMethod), + apiVersionQuery: getOperationApiVersionQuery(sdkMethod), + expectedStatuses: getExpectedStatuses(sdkMethod), + }; +} + +export function _adaptOperationParameters( + sdkMethod: SdkServiceMethod, +): TSOperationParameter[] { + return sdkMethod.parameters + .filter((parameter) => !shouldSkipGeneratedMethodParameter(parameter)) + .filter( + (parameter) => + !parameter.onClient && + !isConstantContentTypeParameter(parameter) && + !(parameter.optional || parameter.clientDefaultValue !== undefined), + ) + .map((parameter) => ({ + name: parameter.name, + location: getOperationParameterLocation(sdkMethod, parameter), + type: getOperationParameterType(parameter), + required: !parameter.optional, + defaultValue: + parameter.clientDefaultValue === undefined + ? undefined + : JSON.stringify(parameter.clientDefaultValue), + docs: getDocs(parameter), + })); +} + +export function _adaptOptionsType( + sdkMethod: SdkServiceMethod, +): TSOptionsType { + return { + name: `${toPascalCase(getOperationGroupName(sdkMethod))}${toPascalCase(sdkMethod.name)}OptionalParams`, + properties: sdkMethod.parameters + .filter((parameter) => !shouldSkipGeneratedMethodParameter(parameter)) + .filter( + (parameter) => + !parameter.onClient && + (parameter.optional || parameter.clientDefaultValue !== undefined), + ) + .map((parameter) => ({ + name: parameter.name, + type: getTypeExpression(parameter.type), + optional: true, + readonly: false, + docs: getDocs(parameter), + })), + }; +} + +function adaptReturnType( + sdkMethod: SdkServiceMethod, +): TSReturnType { + const type = sdkMethod.response.type; + return { + type: type ? getTypeExpression(type) : "void", + isVoid: type === undefined, + nullable: sdkMethod.response.optional === true, + }; +} + +function getExpectedStatuses( + sdkMethod: SdkServiceMethod, +): string[] { + return sdkMethod.operation.responses.flatMap((response) => { + const statusCodes = response.statusCodes; + if (typeof statusCodes === "number") { + return [String(statusCodes)]; + } + return [`${statusCodes.start}-${statusCodes.end}`]; + }); +} + +function getBodyShape(bodyType: SdkHttpOperation["bodyParam"] extends infer Body ? Body extends { type?: infer Type } ? Type : SdkType | undefined : SdkType | undefined): TSOperation["bodyShape"] { + if (!bodyType) { + return "inline"; + } + if (bodyType.kind === "bytes") { + return "raw"; + } + return bodyType.kind === "model" && shouldEmitModel(bodyType as SdkModelType) + ? "named-with-serializer" + : "inline"; +} + +function getOperationContentType( + sdkMethod: SdkServiceMethod, +): string | undefined { + const bodyContentType = sdkMethod.operation.bodyParam?.contentTypes?.[0]; + if (bodyContentType) { + return bodyContentType; + } + const contentTypeParam = sdkMethod.operation.parameters.find( + (parameter) => parameter.kind === "header" && parameter.serializedName?.toLowerCase() === "content-type", + ); + return contentTypeParam?.type.kind === "constant" + ? String((contentTypeParam.type as SdkConstantType).value) + : undefined; +} + +function getOperationApiVersionQuery( + sdkMethod: SdkServiceMethod, +): TSOperation["apiVersionQuery"] { + const parameter = sdkMethod.operation.parameters.find( + (item) => item.kind === "query" && item.isApiVersionParam, + ); + if (!parameter) { + return undefined; + } + const serializedName = parameter.serializedName ?? parameter.name; + return { + serializedName, + encodedName: encodeUriTemplateVariableName(serializedName), + defaultValue: String(parameter.clientDefaultValue), + }; +} + +function encodeUriTemplateVariableName(name: string): string { + return encodeURIComponent(name).replace(/-/g, "%2D"); +} + +function getOperationParameterLocation( + sdkMethod: SdkServiceMethod, + parameter: SdkMethodParameter, +): TSOperationParameter["location"] { + if (isMappedFromBody(sdkMethod.operation.bodyParam, parameter)) { + return "body"; + } + + const httpParameter = sdkMethod.operation.parameters.find((item) => + isMappedFromHttpParameter(item, parameter), + ); + if ( + httpParameter?.kind === "query" || + httpParameter?.kind === "path" || + httpParameter?.kind === "header" + ) { + return httpParameter.kind; + } + + return "body"; +} + +function getOperationParameterType(parameter: SdkMethodParameter): string { + if (parameter.type.kind === "bytes") { + return "Uint8Array"; + } + if ( + parameter.type.kind === "model" && + (parameter.type as SdkModelType).isGeneratedName + ) { + return getInlineModelType(parameter.type as SdkModelType); + } + return getTypeExpression(parameter.type); +} + +function getInlineModelType(model: SdkModelType): string { + if (model.properties.length === 0) { + return "Record"; + } + const properties = model.properties + .map( + (property) => + ` ${property.name}${property.optional ? "?" : ""}: ${getTypeExpression(property.type)};`, + ) + .join("\n"); + return `{\n${properties}\n }`; +} + +function isMappedFromBody( + bodyParameter: SdkHttpOperation["bodyParam"], + parameter: SdkMethodParameter, +): boolean { + return ( + bodyParameter?.methodParameterSegments.some( + (segments) => segments[0]?.name === parameter.name, + ) === true + ); +} + +function isMappedFromHttpParameter( + httpParameter: SdkHttpParameter, + parameter: SdkMethodParameter, +): boolean { + return httpParameter.methodParameterSegments.some( + (segments) => segments[0]?.name === parameter.name, + ); +} + +function shouldSkipGeneratedMethodParameter( + parameter: SdkMethodParameter, +): boolean { + return ( + parameter.isGeneratedName && + (parameter.name === "contentType" || parameter.name !== "accept") + ); +} + +function isConstantContentTypeParameter(parameter: SdkMethodParameter): boolean { + return parameter.name === "contentType" && parameter.type.kind === "constant"; +} + +function getOperationGroupName( + sdkMethod: SdkServiceMethod, +): string { + const parent = sdkMethod.__raw?.interface?.name; + return parent ?? ""; +} + +function adaptClientParameter( + parameter: SdkCredentialParameter | SdkEndpointParameter | SdkMethodParameter, +): TSParameter { + const name = + parameter.kind === "endpoint" && parameter.name === "endpoint" + ? "endpointParam" + : parameter.name; + return { + name, + type: getClientParameterType(parameter), + required: !parameter.optional, + defaultValue: + parameter.clientDefaultValue === undefined + ? undefined + : JSON.stringify(parameter.clientDefaultValue), + docs: getDocs(parameter), + }; +} + +function getClientParameterType( + parameter: SdkCredentialParameter | SdkEndpointParameter | SdkMethodParameter, +): string { + if (parameter.kind === "endpoint") { + return "string"; + } + if (parameter.kind === "credential") { + return getCredentialType(parameter); + } + return getTypeExpression(parameter.type); +} + +function adaptEndpoint(client: SdkClientType): TSEndpoint { + const endpointParameter = client.clientInitialization.parameters.find( + (parameter): parameter is SdkEndpointParameter => + parameter.kind === "endpoint", + ); + const endpointType = endpointParameter + ? getEndpointType(endpointParameter) + : undefined; + return { + urlTemplate: endpointType?.serverUrl ?? "{endpoint}", + isParameterized: (endpointType?.templateArguments.length ?? 0) > 0, + templateParams: (endpointType?.templateArguments ?? []).map( + adaptEndpointTemplateParameter, + ), + }; +} + +function adaptEndpointTemplateParameter( + parameter: SdkPathParameter, +): TSParameter { + return { + name: parameter.name, + type: getTypeExpression(parameter.type), + required: !parameter.optional, + defaultValue: + parameter.clientDefaultValue === undefined + ? undefined + : JSON.stringify(parameter.clientDefaultValue), + docs: getDocs(parameter), + }; +} + +function getEndpointType( + parameter: SdkEndpointParameter, +): SdkEndpointType | undefined { + if (parameter.type.kind === "endpoint") { + return parameter.type; + } + if (parameter.type.kind === "union") { + return parameter.type.variantTypes.find( + (variant): variant is SdkEndpointType => variant.kind === "endpoint", + ); + } + return undefined; +} + +function adaptApiVersion( + client: SdkClientType, +): TSClient["apiVersion"] { + const parameter = client.clientInitialization.parameters.find( + (item) => item.isApiVersionParam, + ); + if (!parameter) { + return undefined; + } + return { + paramName: parameter.name, + defaultValue: + parameter.clientDefaultValue === undefined + ? undefined + : String(parameter.clientDefaultValue), + isInEndpoint: false, + }; +} + +function adaptCredential( + client: SdkClientType, +): TSClient["credential"] { + const parameter = client.clientInitialization.parameters.find( + (item): item is SdkCredentialParameter => item.kind === "credential", + ); + if (!parameter) { + return undefined; + } + const credentialSchemes = getCredentialSchemes(parameter.type); + return { + paramName: parameter.name, + type: getCredentialType(parameter), + scopes: credentialSchemes.flatMap((scheme) => + (scheme.flows ?? []).flatMap((flow) => + (flow.scopes ?? []).map((scope) => + typeof scope === "string" ? scope : String(scope.value), + ), + ), + ), + apiKeyHeaderName: credentialSchemes.find((scheme) => scheme.type === "apiKey")?.name, + }; +} + +function getCredentialType(parameter: SdkCredentialParameter): string { + return getCredentialSchemes(parameter.type) + .map((scheme) => (scheme.type === "apiKey" ? "KeyCredential" : "TokenCredential")) + .filter((value, index, array) => array.indexOf(value) === index) + .join(" | "); +} + +function getCredentialVariantType(type: SdkType): string { + const scheme = (type as unknown as { scheme?: { type?: string } }).scheme; + return scheme?.type === "apiKey" ? "KeyCredential" : "TokenCredential"; +} + +function getCredentialSchemes( + type: SdkType, +): Array<{ type?: string; name?: string; flows?: Array<{ scopes?: Array<{ value?: string } | string> }> }> { + if (type.kind === "union") { + return (type as SdkUnionType).variantTypes.flatMap(getCredentialSchemes); + } + const scheme = (type as unknown as { scheme?: { type?: string; name?: string; flows?: Array<{ scopes?: Array<{ value?: string } | string> }> } }).scheme; + return scheme ? [scheme] : []; +} + +/** + * Adapts TCGC model types into TSModel IR nodes. + * Maps properties, inheritance, discriminators, and additional properties. + */ +export function _adaptModels(sdkContext: SdkContext): TSModel[] { + return sdkContext.sdkPackage.models.filter(shouldEmitModel).map(adaptModel); +} + +function adaptModel(model: SdkModelType): TSModel { + const name = getModelName(model); + const needsSerializer = hasUsage(model, UsageFlags.Input); + const needsDeserializer = hasUsage(model, UsageFlags.Output); + return { + name, + docs: getDocs(model), + properties: model.properties.map(adaptProperty), + baseModel: model.baseModel ? getModelName(model.baseModel) : undefined, + additionalPropertiesType: model.additionalProperties + ? getTypeExpression(model.additionalProperties) + : undefined, + discriminator: model.discriminatorProperty + ? { + propertyName: model.discriminatorProperty.name, + value: model.discriminatorValue, + variants: Object.values(model.discriminatedSubtypes ?? {}).map( + getModelName, + ), + } + : undefined, + needsSerializer, + serializerName: needsSerializer + ? `${lowerFirst(name)}Serializer` + : undefined, + needsDeserializer, + deserializerName: needsDeserializer + ? `${lowerFirst(name)}Deserializer` + : undefined, + }; +} + +function adaptProperty(property: SdkModelPropertyType): TSProperty { + const serializedName = getSerializedName(property); + return { + name: property.name, + type: getTypeExpression(property.type), + optional: property.optional, + readonly: isReadonly(property), + serializedName: + serializedName === property.name ? undefined : serializedName, + docs: getDocs(property), + }; +} + +/** + * Adapts TCGC enum types into TSEnum IR nodes. + * Maps members, fixed/extensible semantics, and value types. + */ +export function _adaptEnums(sdkContext: SdkContext): TSEnum[] { + const hasPackageVersions = (sdkContext.getPackageVersions?.().size ?? 0) > 0; + return sdkContext.sdkPackage.enums.map((enumType) => + adaptEnum(enumType, hasPackageVersions && enumType.name === "Versions"), + ); +} + +function adaptEnum(enumType: SdkEnumType, knownValuesOnly = false): TSEnum { + return { + docs: getDocs(enumType), + members: enumType.values.map((member) => ({ + name: getEnumMemberName(member.name), + value: member.value, + docs: getDocs(member), + })), + name: knownValuesOnly ? `Known${enumType.name}` : enumType.name, + isExtensible: !enumType.isFixed, + knownValuesOnly, + valueType: enumType.valueType.kind === "numeric" ? "number" : "string", + }; +} + +/** + * Adapts TCGC union types into TSUnion IR nodes. + * Maps variants and discriminator metadata. + */ +export function _adaptUnions(): TSUnion[] { + return []; +} + +function _adaptHelpers(sdkContext: SdkContext): TSCodeModel["helpers"] { + const needsUrlTemplate = sdkContext.sdkPackage.clients.some((client) => + (client.children ?? []).some((child) => + child.methods.some((method) => getOperationApiVersionQuery(method) !== undefined), + ), + ); + return needsUrlTemplate + ? [{ outputPath: "static-helpers/urlTemplate.ts", category: "url" }] + : []; +} + +/** + * Resolves emitter options and program metadata into TSGenerationSettings. + */ +export function _resolveSettings( + context: EmitContext>, +): TSGenerationSettings { + const packageDetails = getRecordOption(context.options, "package-details"); + const packageName = + getStringOption(packageDetails, "name") ?? + "@azure-tools/typespec-ts-pristine"; + const packageVersion = + getStringOption(packageDetails, "version") ?? "1.0.0-beta.1"; + const packageDescription = getStringOption(packageDetails, "description"); + + return { + packageName, + packageVersion, + packageDescription, + flavor: packageName.startsWith("@azure/") ? "azure" : "unbranded", + isArm: packageName.startsWith("@azure/arm-"), + outputDir: context.emitterOutputDir, + addCredentials: false, + credentialScopes: [], + isMultiClient: false, + hierarchyClient: false, + }; +} + +function _resolvePackageInfo( + settings: TSGenerationSettings, + sdkContext: SdkContext, +): TSCodeModel["packageInfo"] { + const clientName = + sdkContext.sdkPackage.clients[0]?.name ?? + `${toPascalCase(getPackageShortName(settings.packageName))}Client`; + const serviceName = clientName.endsWith("Client") + ? clientName.slice(0, -"Client".length) + : clientName; + return { + name: settings.packageName, + version: settings.packageVersion, + description: settings.packageDescription, + serviceName, + clientName, + exports: [ + { subpath: ".", source: "./src/index.ts" }, + { subpath: "./api", source: "./src/api/index.ts" }, + ...sdkContext.sdkPackage.clients.flatMap((client) => + (client.children ?? []) + .filter((child) => child.methods.length > 0) + .map((child) => ({ + subpath: `./api/${lowerFirst(child.name)}`, + source: `./src/api/${lowerFirst(child.name)}/index.ts`, + })), + ), + { subpath: "./models", source: "./src/models/index.ts" }, + ], + }; +} + +function getTypeExpression(type: SdkType): string { + switch (type.kind) { + case "array": + return `${wrapArrayElementType(getTypeExpression((type as SdkArrayType).valueType))}[]`; + case "tuple": + return `[${(type as SdkTupleType).valueTypes.map(getTypeExpression).join(", ")}]`; + case "dict": + return `Record`; + case "nullable": + return `${getTypeExpression((type as SdkNullableType).type)} | null`; + case "enum": + return type.name; + case "model": + return getModelName(type as SdkModelType); + case "enumvalue": + return JSON.stringify(type.value); + case "constant": + return JSON.stringify((type as SdkConstantType).value); + case "union": + return (type as SdkUnionType).variantTypes + .map(getTypeExpression) + .join(" | "); + case "utcDateTime": + case "offsetDateTime": + return "Date"; + case "duration": + return "string"; + case "credential": + return getCredentialVariantType(type); + default: + return getBuiltInTypeExpression(type as SdkBuiltInType); + } +} + +function getBuiltInTypeExpression(type: SdkBuiltInType): string { + switch (type.kind) { + case "boolean": + return "boolean"; + case "bytes": + return "Uint8Array"; + case "numeric": + case "integer": + case "safeint": + case "int8": + case "int16": + case "int32": + case "int64": + case "uint8": + case "uint16": + case "uint32": + case "uint64": + case "float": + case "float32": + case "float64": + return "number"; + case "plainDate": + case "plainTime": + case "url": + case "string": + return "string"; + case "unknown": + return "unknown"; + default: + return "any"; + } +} + +function shouldEmitModel(model: SdkModelType): boolean { + return ( + !hasUsage(model, UsageFlags.Spread) && + (hasUsage(model, UsageFlags.Input) || hasUsage(model, UsageFlags.Output)) + ); +} + +function hasUsage(model: SdkModelType, usage: UsageFlags): boolean { + return (model.usage & usage) === usage; +} + +function getModelName(model: SdkModelType): string { + return `${model.isGeneratedName ? "_" : ""}${model.name}`; +} + +function getEnumMemberName(name: string): string { + if (/^\d{4}-\d{2}-\d{2}$/.test(name)) { + return `V${name.replace(/-/g, "")}`; + } + return toPascalCase(name); +} + +function lowerFirst(name: string): string { + const prefix = name.startsWith("_") ? "_" : ""; + const body = prefix ? name.slice(1) : name; + return `${prefix}${body.charAt(0).toLowerCase()}${body.slice(1)}`; +} + +function wrapArrayElementType(type: string): string { + return type.includes(" | ") ? `(${type})` : type; +} + +function getPackageShortName(packageName: string): string { + return packageName.split("/").at(-1) ?? packageName; +} + +function toPascalCase(value: string): string { + return value + .split(/[^A-Za-z0-9]+/) + .filter((part) => part.length > 0) + .map((part) => `${part.charAt(0).toUpperCase()}${part.slice(1)}`) + .join(""); +} + +function getDocs(type: { doc?: string; summary?: string }): string[] { + const docs = type.doc ?? type.summary; + return docs ? docs.split(/\r?\n/) : []; +} + +function getSerializedName(property: SdkModelPropertyType): string { + return ( + property.serializationOptions.json?.name ?? + property.serializedName ?? + property.name + ); +} + +function isReadonly(property: SdkModelPropertyType): boolean { + return ( + property.visibility?.some( + (visibility) => getVisibilityName(visibility) === "read", + ) === true && property.visibility.length === 1 + ); +} + +function getVisibilityName(visibility: unknown): string | undefined { + return isRecord(visibility) && typeof visibility["name"] === "string" + ? visibility["name"] + : undefined; +} + +function getRecordOption( + options: object, + key: string, +): Record { + const value = (options as Record)[key]; + return isRecord(value) ? value : {}; +} + +function getStringOption( + options: Record, + key: string, +): string | undefined { + const value = options[key]; + return typeof value === "string" ? value : undefined; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/packages/typespec-ts-pristine/tsconfig.json b/packages/typespec-ts-pristine/tsconfig.json new file mode 100644 index 0000000000..70b49fa590 --- /dev/null +++ b/packages/typespec-ts-pristine/tsconfig.json @@ -0,0 +1,32 @@ +{ + "compilerOptions": { + "rootDir": ".", + "outDir": "./dist", + "composite": true, + "lib": ["ES2019"], + "target": "es2019", + "module": "node16", + "moduleResolution": "node16", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "importHelpers": true, + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "alwaysStrict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "allowUnusedLabels": false, + "allowUnreachableCode": false, + "skipLibCheck": true + }, + "include": ["src/**/*.ts"], + "exclude": ["dist", "node_modules"] +} diff --git a/packages/typespec-ts/docs/ARCHITECTURE.md b/packages/typespec-ts/docs/ARCHITECTURE.md new file mode 100644 index 0000000000..5d67e8efb1 --- /dev/null +++ b/packages/typespec-ts/docs/ARCHITECTURE.md @@ -0,0 +1,439 @@ +# typespec-ts — Architecture + +> Reference documentation for the `@azure-tools/typespec-ts` emitter. Reads +> top-to-bottom; section headers are anchors, not narrative. +> +> Last regenerated: 2026-05-18. + +This document describes how the emitter turns a compiled TypeSpec program into +a TypeScript client package. It focuses on the **Modular** generation path, +which received a three-layer rewrite to enforce clean separation of concerns. +The **RLC** path is documented at a higher level because its shape has been +stable. + +--- + +## 1. Entry point — `src/index.ts` + +The emitter is a TypeSpec compiler plugin. The TypeSpec compiler invokes the +exported `$onEmit(context: EmitContext)` function at +`src/index.ts:123` with the compiled program plus emitter options. + +In plain English, `$onEmit` does the following: + +1. Builds an `SdkContext` (TCGC's "interpreted" view of the program) and + resolves emitter options into `RLCOptions` / `ModularEmitterOptions`. +2. **Pass 1 — RLC code models.** Calls `transformRLCModel` for every RLC + client and stashes the resulting `RLCModel` objects. The RLC code model is + the foundation that Modular generation also depends on. +3. **Pass 2 — RLC source emission.** Calls `generateRLCSources`, which + dispatches a sequence of `build*` functions from `@azure-tools/rlc-common` + (`buildClient`, `buildClientDefinitions`, `buildResponseTypes`, + `buildParameterTypes`, `buildIsUnexpectedHelper`, `buildIndexFile`, + `buildLogger`, `buildPaginateHelper`, `buildPollingHelper`, + `buildSerializeHelper`, `buildSamples`). Each writes one or more files + into the RLC sources root. +4. **Pass 3 — Modular generation.** Calls `generateModularSources` + (`src/index.ts:326-399`). This is the path described in detail below. +5. **Pass 4 — Project metadata.** Emits `package.json`, `tsconfig.json`, + `README.md`, ESLint/Rollup/API-Extractor configs, the changelog, and the + license file. Cleans intermediate directories. + +The Modular pass is what the rest of this document is about. + +--- + +## 2. Two SDK styles + +The repo emits two styles of client from the same TypeSpec input. Both are +produced in the same `$onEmit` run. + +### REST Level Client (RLC) — `src/rlc/` + +A thin, near-1:1 mapping of REST operations into TypeScript. Each operation +is a `path(...).get(...)` call against a typed `Client`. RLC is the +*foundation* — its `RLCModel` is consumed by Modular too. Most RLC builders +live in `@azure-tools/rlc-common`; the emitter side lives under `src/rlc/` +(transformers, customization logic, etc.). + +### Modular — generated from `src/tcgcadapter` → `src/codemodel` → `src/codegen` + +A higher-level, ergonomic API surface: classical client classes, +operation-group sub-clients, paged/LRO helpers, model interfaces. Modular +sits *on top of* the RLC client for HTTP transport but exposes idiomatic +TypeScript shapes to consumers. The Modular path was rewritten into the +three-layer pipeline described next. + +--- + +## 3. Three-layer pipeline (Modular) + +``` + ┌─────────────────────────────────────────┐ + │ @typespec/compiler + TCGC SdkContext │ + └────────────────────┬────────────────────┘ + │ SdkClientType, SdkMethod, + │ SdkModelType, SdkEnumType, ... + ▼ + ┌──────────────────────────────────────────────────────┐ + │ Layer 1 — TCGC adapter │ + │ src/tcgcadapter/adapter.ts │ + │ Only file in the new pipeline that imports TCGC. │ + └────────────────────────┬─────────────────────────────┘ + │ TSCodeModel + │ (pure data, no TCGC, no ts-morph) + ▼ + ┌──────────────────────────────────────────────────────┐ + │ Layer 2 — Code model (IR) │ + │ src/codemodel/index.ts │ + │ TSCodeModel, TSClient, TSMethod, TSModel, ... │ + └────────────────────────┬─────────────────────────────┘ + │ consumed by renderers + ▼ + ┌──────────────────────────────────────────────────────┐ + │ Layer 3 — Codegen (ts-morph rendering) │ + │ src/codegen/*.ts │ + │ Writes TypeScript SourceFiles into the project. │ + └──────────────────────────────────────────────────────┘ +``` + +The layering rule is mechanical: + +| Layer | Imports TCGC? | Imports ts-morph? | +|--------------|:-------------:|:-----------------:| +| `tcgcadapter`| **yes** | no | +| `codemodel` | no | no | +| `codegen` | no (\*) | **yes** | + +(\*) See §13 — `src/codegen/models.ts` still imports TCGC and is on the +follow-up list. + +This three-layer pattern—adapter, code model, codegen—cleanly separates +concerns and is used in other language emitters. + +--- + +## 4. Which Modular path is live? + +**Two Modular paths exist in the tree at the same time.** Newcomers reliably +trip over this. The rule: + +- **Production generation goes through the three-layer pipeline.** + `$onEmit` → `generateModularSources` (`src/index.ts:326`) → + `adaptToCodeModel` (`src/index.ts:340`) → `emitModelFiles`, + `emitResponseTypes`, `emitOperations`, `emitClientContext`, + `emitClassicalClient`, `emitClassicalOperationFiles`, `emitRootIndex`. + All of these live under `src/codegen/`. + +- **Some unit tests still drive the legacy `src/modular/*` builders.** + Files like `src/modular/buildOperations.ts`, + `src/modular/helpers/operationHelpers.ts`, + `src/modular/buildClassicalClient.ts`, and + `src/modular/buildClassicalOperationGroups.ts` are still on disk and are + exercised by historical scenario tests under `test/modularUnit/scenarios/`. + They are **being phased out**. `src/index.ts` no longer calls the legacy + operation builders in the production path (`src/index.ts:354-399` — + every call is an `emit*` from `src/codegen/`). + +- **The adapter still pulls helpers from the legacy tree** (see imports at + `src/tcgcadapter/adapter.ts:38-78`: `namingHelpers`, `docsHelpers`, + `clientHelpers`, `operationHelpers`, `type-expressions`, `emitModels`). + This is intentional during the transition — those helpers are pure + functions, not builders, and will be relocated under `src/tcgcadapter/` + in follow-ups. See §13. + +**Verification recipe.** If you are unsure whether a piece of code is on the +production path, search for it from `$onEmit` outward: open `src/index.ts` +at line 123, follow function calls down. If your file is not reachable from +`generateModularSources`, it is either RLC-only or legacy. + +--- + +## 5. TCGC adapter — `src/tcgcadapter/adapter.ts` + +**Input.** An `SdkContext` from `@azure-tools/typespec-client-generator-core` +plus a `ModularEmitterOptions`. Entry point: `adaptToCodeModel({ sdkContext, +emitterOptions })`. + +**Output.** A fully populated `TSCodeModel` (see §6) representing every +client, method, model, enum, and union the package will expose. + +**Boundary rule.** The adapter is the **only** file in the new pipeline +that imports `@azure-tools/typespec-client-generator-core`. Verified by: + +```text +$ grep -rn '@azure-tools/typespec-client-generator-core' \ + src/tcgcadapter src/codemodel src/codegen +src/tcgcadapter/adapter.ts:29 ← expected +src/tcgcadapter/adapter.ts:34 ← expected +src/codegen/models.ts:6 ← known leak; tracked in §13 +``` + +Inside the adapter, TCGC's language-neutral concepts get *interpreted* into +TypeScript-specific shapes: method names get normalized via +`NameType.Method`, doc comments get assembled from `description`/`details`, +nullable/optional flags are flattened, paging/LRO are tagged onto methods +(`TSMethodKind`), credential scopes are resolved, etc. + +The adapter receives all dependencies explicitly. There is no global state +inside `src/tcgcadapter/`; see §13 for the one exception +(`ContextManager`). + +--- + +## 6. Code model — `src/codemodel/index.ts` + +A single file of pure-data TypeScript types. No TCGC, no ts-morph, no I/O. + +Top-level interface: `TSCodeModel` (`src/codemodel/index.ts:27`), which +holds: + +| Field | Type | Notes | +|------------|----------------------------|--------------------------------------------| +| `clients` | `TSClient[]` | Client hierarchy (root + sub-clients) | +| `models` | `TSModel[]` | Named model declarations | +| `enums` | `TSEnum[]` | Named enum declarations | +| `unions` | `TSUnion[]` | Named union declarations | +| `settings` | `TSGenerationSettings` | Flavor, ARM flag, paths, credential config | + +Key types you will encounter when reading codegen: + +- `TSClient` (line 70) — modular + classical client identity, endpoint, + credential, parameters, method groups. +- `TSMethod` (line 224) and `TSMethodKind` (line 197 — + `"basic" | "lro" | "paging" | "lroPaging"`). +- `TSOperationGroup` (line 294) — a sub-client's methods. +- `TSModel` / `TSProperty` / `TSDiscriminator` (lines 349/368/385). +- `TSEnum`, `TSUnion`, `TSApiOptions`, `TSLroConfig`. + +Because the IR is pure data, it is snapshot-testable and renderer-agnostic. +The same `TSCodeModel` could theoretically drive Alloy.js or any other +renderer. + +--- + +## 7. Codegen — `src/codegen/*.ts` + +Every renderer accepts a `Project` (ts-morph) and parts of the +`TSCodeModel`, and writes one or more `SourceFile`s. + +| File | Output | +|---------------------------------|------------------------------------------------------------------------| +| `emitter.ts` / `index.ts` | Orchestrator — walks `TSCodeModel` and dispatches to file generators. | +| `clients.ts` | `api/{name}Context.ts`: client interface, options, factory function. | +| `operations.ts` | `api/.../operations.ts`: per-operation `_send` / `_deserialize` / public function. | +| `classicalClient.ts` | `{name}Client.ts`: the classical class wrapper around the context. | +| `classicalOperations.ts` | `classic/.../index.ts`: classical operation-group interfaces + factories. | +| `models.ts` | `models/models.ts`: model/enum/union TypeScript declarations. | +| `responseTypes.ts` | Response-type aliases derived from RLC responses. | +| `apiOptions.ts` | Per-operation `OptionalParams` interfaces. | +| `lroHelpers.ts` | Restore-poller helpers for LRO operations. | +| `indexFiles.ts` | Root `index.ts` + subpath barrels (`models`, `api`, `classic`). | +| `pagingImports.ts` | Small helper for paging-related import resolution. | + +**JSDoc rendering.** Doc comments are attached directly via ts-morph's +`addJsDoc` / `getJsDoc` calls inside each renderer. There is no shared +helper for assembling JSDoc blocks from `TSMethod.docs`, parameter docs, +return-type docs, and deprecation tags — every renderer threads the same +pattern by hand. Tracked in §13. + +--- + +## 8. Framework — `src/framework/`, `src/modular/static-helpers-metadata.ts`, `static/static-helpers/` + +The **framework** is the import/dependency resolver used by all renderers. +Renderers do not write `import` statements directly — they request a symbol +by reference key and let the framework decide what file it lives in and how +to import it. + +Core APIs: + +- `refkey("Name")` — `src/framework/refkey.ts`. Creates a stable token + identifying a static helper, external dependency, or generated symbol. +- `resolveReference(context, refkey)` — `src/framework/reference.ts`. + Resolves a refkey at emit time, registers the import, and returns the + in-scope name to use in the generated source. +- `useDependencies()`, `useContext()` — hooks in `src/framework/hooks/` + for accessing the emitter context (Project, options, etc.). +- `load-static-helpers.ts` — picks up every helper file under + `static/static-helpers/` and registers them with the binder. + +**Static helpers** live at `static/static-helpers/` as plain TypeScript +source. They are *copied* (not bundled) into the generated package when +referenced. Metadata lives at `src/modular/static-helpers-metadata.ts` +(e.g., `PagingHelpers`, `PollingHelpers`, `SerializationHelpers`, +`XmlHelpers`, `MultipartHelpers`). + +**External dependencies** (npm packages the generated code depends on, e.g. +`@azure/core-lro`, `@azure-rest/core-client`) are declared in +`src/modular/external-dependencies.ts`. Renderers request them through +`useDependencies()` and resolve through refkeys. + +The metadata file currently lives under `src/modular/` for historical +reasons; it is shared by both the legacy and the new pipeline. + +--- + +## 9. End-to-end flow A — spec to package + +``` +TypeSpec spec ──► @typespec/compiler ──► Program (AST) + │ + ▼ + TCGC (typespec-client-generator-core) + │ + ▼ SdkContext + $onEmit (src/index.ts:123) + ┌───────────────┴───────────────┐ + │ │ + RLC pipeline Modular pipeline + (src/rlc + rlc-common) (this document) + │ │ + ▼ ▼ + rest/*.ts, models, api/*, classic/*, models/*, + isUnexpected, etc. Client.ts, index.ts + │ │ + └──────────────┬────────────────┘ + ▼ + project metadata: package.json, tsconfig, + README, eslint, rollup, api-extractor, + CHANGELOG, LICENSE + │ + ▼ + generated TypeScript package +``` + +--- + +## 10. End-to-end flow B — one paged list operation + +Tracing a single `@list` method called `listFoos`: + +1. **TCGC** classifies the method on `SdkClientType.methods` with + `kind: "paging"` and an `SdkPagingServiceMethod` containing the + continuation-token strategy. +2. **Adapter** (`src/tcgcadapter/adapter.ts`): + - Normalizes the name to `listFoos` (`NameType.Method`). + - Builds a `TSMethod` with `kind: "paging"` + (`TSMethodKind`, `src/codemodel/index.ts:197`). + - Populates `TSReturnType` to reference the array element type + (interface reference into `TSCodeModel.models`). + - Tags paging metadata onto the method so the renderer can choose the + right helper. + - Builds a `TSApiOptions` entry (`FooListOptionalParams`). +3. **Code model** holds the result as plain data — no TCGC, no ts-morph. +4. **Codegen**: + - `src/codegen/apiOptions.ts` writes the `FooListOptionalParams` + interface. + - `src/codegen/operations.ts` writes `_listFoosSend`, + `_listFoosDeserialize`, and a public `listFoos` function. Paging-flag + methods resolve `buildPagedAsyncIterator` from `PagingHelpers` via + `resolveReference(context, refkey("buildPagedAsyncIterator"))` so the + framework copies the static helper into the package. + - `src/codegen/classicalOperations.ts` adds `listFoos` to the + `FooOperations` interface and its factory. + - `src/codegen/classicalClient.ts` exposes the operation group on the + classical client. + - `src/codegen/indexFiles.ts` re-exports the method and types. + +A reader who wants to confirm any of this can search for the operation name +in `test/modularIntegration/generated/` after a regeneration and follow the +breadcrumbs back to the renderer files above. + +--- + +## 11. Testing + +| Suite | Location | What it covers | +|----------------------------------------------------|-------------------------------------------------------|---------------------------------------------| +| Modular unit | `test/modularUnit/` | Adapter, model emission, scenarios | +| Adapter unit | `test/modularUnit/adapter.spec.ts`, `adapter-models.spec.ts` | `TSCodeModel` shape from TCGC inputs | +| RLC unit | `test/unit/` | RLC builders | +| RLC integration | `test/integration/` | Live mock-server tests for RLC clients | +| Modular integration | `test/modularIntegration/` | Live mock-server tests for Modular clients | +| Azure RLC integration | `test/azureIntegration/` | Azure-flavored RLC | +| Azure Modular integration | `test/azureModularIntegration/` | Azure-flavored Modular | +| Static-helper unit | `test-next/unit/static-helpers/` | Runtime helpers shipped into generated code | +| Smoke (cross-package) | `packages/typespec-test/` | End-to-end "does it build?" matrix | + +Common commands (from `packages/typespec-ts/`): + +```bash +npm run test:modular # modular unit +npm run test:rlc # RLC unit +npm run unit-test # both +npm run copy:typespec # required before any integration suite +npm run integration-test-ci:azure-modular +``` + +To regenerate one integration target: + +```bash +npx tsx ./test/commands/gen-cadl-ranch.js --tag=azure-modular --filter=payload/xml +``` + +--- + +## 12. Legacy code paths + +- **`src/modular/buildOperations.ts`, `src/modular/buildClassicalClient.ts`, + `src/modular/buildClassicalOperationGroups.ts`, `src/modular/helpers/*`.** + Historical Modular builders. Production no longer calls + `buildOperations.ts` / `buildClassicalClient.ts` / `buildClassicalOperationGroups.ts` + — equivalent functionality lives at `src/codegen/operations.ts`, + `src/codegen/classicalClient.ts`, `src/codegen/classicalOperations.ts`. + Some helpers (`namingHelpers`, `docsHelpers`, `operationHelpers`, + `clientHelpers`, `type-expressions`) are still imported by the adapter + during the transition. + +- **`packages/autorest.typescript/`.** The AutoRest TypeScript generator is + in **maintenance mode**. Treat it as out-of-scope unless explicitly asked + to touch it. Do not borrow patterns from it. + +- **`src/modular/static-helpers-metadata.ts`, + `src/modular/external-dependencies.ts`.** Not legacy — see §8. They live + under `src/modular/` for historical reasons and are shared. + +--- + +## 13. Known follow-ups + +These are *known* gaps. Adding to this list is encouraged. + +1. **`ContextManager` singleton.** Modular emission still relies on a + process-global context manager for the active `Project` and emitter + options. Make context explicit by threading it through `emit*` calls + instead of relying on a global. + +2. **Adapter still imports from `src/modular/helpers/`.** See `src/tcgcadapter/adapter.ts:38-78` + (`namingHelpers`, `docsHelpers`, `clientHelpers`, `operationHelpers`, + `type-expressions`, `emitModels`). Relocate the pure-function helpers + into `src/tcgcadapter/`. + +3. **No `src/tcgcadapter/naming.ts` yet.** Naming normalization lives in + `src/modular/helpers/namingHelpers.ts`. Carve it out so the adapter owns + the language-specific naming policy. + +4. **`src/codegen/models.ts` still imports TCGC** (`SdkArrayType`, + `SdkDictionaryType`, `SdkNullableType`, `SdkType` at + `src/codegen/models.ts:1-6`). Close this leak by routing all type + information through `TSCodeModel`. + +5. **Shared JSDoc-assembly helper for codegen renderers.** Every renderer + in `src/codegen/` builds JSDoc by calling ts-morph's `addJsDoc` / + `getJsDoc` directly, threading `docs`, parameter docs, return docs, and + deprecation tags by hand. A shared helper (taking `TSMethod` / + `TSProperty` and emitting a normalized JSDoc structure) would remove + the duplication and the "what does this look like?" friction newcomers + hit when adding doc-bearing decorators (`@doc`, `@summary`, + `@deprecated`). + +6. **Adapter test fixture helpers for decorator metadata.** TCGC's + operation surface — including fields like `summary` on + `SdkServiceMethod` (see + `node_modules/@azure-tools/typespec-client-generator-core/dist/src/interfaces.d.ts:165-168`) + — is not obvious from the adapter source alone. Newcomers currently + discover it via runtime inspection. Provide adapter-test fixture + helpers covering `doc` / `summary` / deprecation so contributors can + write metadata-bearing tests without first cracking open + `interfaces.d.ts`. diff --git a/packages/typespec-ts/src/codegen/apiOptions.ts b/packages/typespec-ts/src/codegen/apiOptions.ts new file mode 100644 index 0000000000..f404b182ca --- /dev/null +++ b/packages/typespec-ts/src/codegen/apiOptions.ts @@ -0,0 +1,86 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { NameType, normalizeName } from "@azure-tools/rlc-common"; +import { + InterfaceDeclarationStructure, + Project, + SourceFile, + StructureKind +} from "ts-morph"; +import type { + TSApiOptions, + TSApiOptionsInterface, + TSClient, + TSGenerationSettings +} from "../codemodel/index.js"; +import { addDeclaration } from "../framework/declaration.js"; +import { resolveReference } from "../framework/reference.js"; +import { useDependencies } from "../framework/hooks/useDependencies.js"; + +export function emitApiOptions( + project: Project, + client: TSClient, + settings: TSGenerationSettings +): SourceFile[] { + const dependencies = useDependencies(); + const subfolder = client.path.join("/"); + const operationOptionsReference = resolveReference( + dependencies.OperationOptions + ); + + return [...client.apiOptions] + .sort((left, right) => + left.prefixes.join("/").localeCompare(right.prefixes.join("/")) + ) + .map((apiOptions) => { + const file = project.createSourceFile( + `${settings.sourceRoot}/${ + subfolder && subfolder !== "" ? subfolder + "/" : "" + }api/${getApiOptionsFileName(apiOptions)}.ts`, + "", + { overwrite: true } + ); + + for (const optionsInterface of apiOptions.interfaces) { + addDeclaration( + file, + toInterfaceDeclaration(optionsInterface, operationOptionsReference), + optionsInterface.refKey + ); + } + + file.fixMissingImports({}, { importModuleSpecifierEnding: "js" }); + file.fixUnusedIdentifiers(); + return file; + }); +} + +function getApiOptionsFileName(apiOptions: TSApiOptions): string { + if (apiOptions.prefixes.length === 0) { + return "options"; + } + + return `${apiOptions.prefixes + .map((prefix) => normalizeName(prefix, NameType.File)) + .join("/")}/options`; +} + +function toInterfaceDeclaration( + optionsInterface: TSApiOptionsInterface, + operationOptionsReference: string +): InterfaceDeclarationStructure { + return { + kind: StructureKind.Interface, + name: optionsInterface.name, + isExported: true, + extends: [operationOptionsReference], + docs: ["Optional parameters."], + properties: optionsInterface.properties.map((property) => ({ + name: property.name, + type: property.type, + hasQuestionToken: true, + docs: property.docs + })) + }; +} diff --git a/packages/typespec-ts/src/codegen/classicalClient.ts b/packages/typespec-ts/src/codegen/classicalClient.ts new file mode 100644 index 0000000000..4ae4eb1542 --- /dev/null +++ b/packages/typespec-ts/src/codegen/classicalClient.ts @@ -0,0 +1,410 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { NameType, normalizeName } from "@azure-tools/rlc-common"; +import { + MethodDeclarationStructure, + Project, + Scope, + SourceFile, + StructureKind +} from "ts-morph"; +import type { + TSClient, + TSGenerationSettings, + TSMethod +} from "../codemodel/index.js"; +import { resolveReference } from "../framework/reference.js"; +import { dedupePagedAsyncIterableIteratorImports } from "./pagingImports.js"; +import { refkey } from "../framework/refkey.js"; +import { useDependencies } from "../framework/hooks/useDependencies.js"; +import { AzurePollingDependencies } from "../modular/external-dependencies.js"; +import { + PagingHelpers, + SimplePollerHelpers +} from "../modular/static-helpers-metadata.js"; +import { getPagingLROMethodName } from "../modular/helpers/classicalOperationHelpers.js"; + +export function emitClassicalClient( + project: Project, + client: TSClient, + settings: TSGenerationSettings +): SourceFile { + const dependencies = useDependencies(); + const subfolder = client.path.join("/"); + const filePath = `${settings.sourceRoot}/${ + subfolder && subfolder !== "" ? subfolder + "/" : "" + }${normalizeName(client.name, NameType.File)}.ts`; + const file = project.createSourceFile(filePath, undefined, { + overwrite: true + }); + + if (client.usesNamespacedContextType) { + file.addImportDeclaration({ + namespaceImport: "Client", + moduleSpecifier: "./api/index.js" + }); + } + + file.addImportDeclaration({ + namedImports: [ + client.contextTypeName, + `${client.name}OptionalParams`, + `create${client.modularName}` + ], + moduleSpecifier: "./api/index.js" + }); + file.addImportDeclaration({ + namedImports: ["Pipeline"], + moduleSpecifier: "@azure/core-rest-pipeline" + }); + file.addExportDeclaration({ + isTypeOnly: true, + namedExports: [`${client.name}OptionalParams`], + moduleSpecifier: `./api/${normalizeName(client.modularName, NameType.File)}Context.js` + }); + + for (const child of client.children) { + file.addImportDeclaration({ + moduleSpecifier: `./${normalizeName(child.modularName, NameType.File)}/${normalizeName( + child.name, + NameType.File + )}.js`, + namedImports: [child.name, `${child.name}OptionalParams`] + }); + } + + const clientClass = file.addClass({ + isExported: true, + name: client.name + }); + + clientClass.addProperty({ + name: "_client", + type: client.usesNamespacedContextType + ? `Client.${client.contextTypeName}` + : client.contextTypeName, + scope: Scope.Private + }); + clientClass.addProperty({ + name: "pipeline", + type: resolveReference(dependencies.Pipeline), + scope: Scope.Public, + isReadonly: true, + docs: ["The pipeline used by this client to make requests"] + }); + + const constructorParams = getConstructorParameters(client); + const clientParamsType = [ + ...constructorParams.map( + (parameter) => `${parameter.name}: ${parameter.type}` + ), + `options: ${client.name}OptionalParams` + ].join("; "); + const clientParamsObject = [ + ...constructorParams.map((parameter) => parameter.name), + "options" + ].join(", "); + if (client.hasParentInitializedChildren) { + clientClass.addProperty({ + name: "_clientParams", + type: `{ ${clientParamsType} }`, + scope: Scope.Private, + docs: ["The parent client parameters that are used in the constructors."] + }); + } + + const constructor = addConstructor(clientClass, client, constructorParams); + const constructorArgs = constructorParams.map((parameter) => { + if ( + client.allowOptionalSubscriptionId && + parameter.name.toLowerCase() === "subscriptionid" + ) { + return 'subscriptionId ?? ""'; + } + + return parameter.name; + }); + + constructor.addStatements([ + "const prefixFromOptions = options?.userAgentOptions?.userAgentPrefix;", + "const userAgentPrefix = prefixFromOptions ? `${prefixFromOptions} azsdk-js-client` : `azsdk-js-client`;", + `this._client = create${client.modularName}(${[ + ...constructorArgs, + "{ ...options, userAgentOptions: { userAgentPrefix } }" + ].join(",")});`, + "this.pipeline = this._client.pipeline;" + ]); + + if (client.hasParentInitializedChildren) { + constructor.addStatements( + `this._clientParams = { ${clientParamsObject} };` + ); + } + + const seenOperationGroups = new Set(); + for (const group of client.operationGroups) { + const rootGroupName = group.prefixes[0] ?? group.name; + if (seenOperationGroups.has(rootGroupName)) { + continue; + } + seenOperationGroups.add(rootGroupName); + + const propertyName = normalizeName(rootGroupName, NameType.Property); + const operationsInterfaceName = `${normalizeName(rootGroupName, NameType.OperationGroup)}Operations`; + const operationGetterName = `_get${normalizeName(rootGroupName, NameType.OperationGroup)}Operations`; + + clientClass.addProperty({ + name: propertyName, + type: resolveReference( + refkey(operationsInterfaceName, 0, "classicOperations") + ), + scope: Scope.Public, + isReadonly: true, + docs: [`The operation groups for ${propertyName}`] + }); + constructor.addStatements( + `this.${propertyName} = ${resolveReference(refkey(operationGetterName, 0, "getClassicOperations"))}(this._client);` + ); + } + + clientClass.addMethods( + client.methods.flatMap((method) => + buildMethodDeclarations(method, settings) + ) + ); + + for (const child of client.children) { + const diffParams = getChildOnlyParameters(client, child); + const method = clientClass.addMethod({ + docs: child.docs, + name: `get${child.name}`, + returnType: child.name, + parameters: [ + ...diffParams.map((parameter) => ({ + name: parameter.name, + type: parameter.type + })), + { + name: "options", + type: `${child.name}OptionalParams`, + initializer: "{}" + } + ] + }); + const parentArgs = constructorParams.map( + (parameter) => `this._clientParams.${parameter.name}` + ); + const childArgs = diffParams.map((parameter) => parameter.name); + method.addStatements( + `return new ${child.name}(${[ + ...parentArgs, + ...childArgs, + "{ ...this._clientParams.options, ...options }" + ].join(",")});` + ); + } + + file.fixMissingImports({}, { importModuleSpecifierEnding: "js" }); + dedupePagedAsyncIterableIteratorImports(file); + file.fixUnusedIdentifiers(); + return file; +} + +function addConstructor( + clientClass: any, + client: TSClient, + constructorParams: { name: string; type: string }[] +) { + if (!client.allowOptionalSubscriptionId) { + return clientClass.addConstructor({ + docs: client.docs, + parameters: [ + ...constructorParams.map((parameter) => ({ + name: parameter.name, + type: parameter.type + })), + { + name: "options", + type: `${client.name}OptionalParams`, + initializer: "{}" + } + ] + }); + } + + const requiredWithoutSubscriptionId = constructorParams.filter( + (parameter) => parameter.name.toLowerCase() !== "subscriptionid" + ); + const constructor = clientClass.addConstructor({ + docs: client.docs, + parameters: [ + ...requiredWithoutSubscriptionId.map((parameter) => ({ + name: parameter.name, + type: parameter.type + })), + { + name: "subscriptionIdOrOptions", + type: `string | ${client.name}OptionalParams`, + hasQuestionToken: true + }, + { + name: "options", + type: `${client.name}OptionalParams`, + hasQuestionToken: true + } + ] + }); + constructor.addOverload({ + parameters: [ + ...requiredWithoutSubscriptionId.map((parameter) => ({ + name: parameter.name, + type: parameter.type + })), + { + name: "options", + type: `${client.name}OptionalParams`, + hasQuestionToken: true + } + ] + }); + constructor.addOverload({ + parameters: [ + ...requiredWithoutSubscriptionId.map((parameter) => ({ + name: parameter.name, + type: parameter.type + })), + { + name: "subscriptionId", + type: + constructorParams.find( + (parameter) => parameter.name.toLowerCase() === "subscriptionid" + )?.type ?? "string" + }, + { + name: "options", + type: `${client.name}OptionalParams`, + hasQuestionToken: true + } + ] + }); + constructor.addStatements([ + "let subscriptionId: string | undefined;", + "", + 'if (typeof subscriptionIdOrOptions === "string") {', + " subscriptionId = subscriptionIdOrOptions;", + '} else if (typeof subscriptionIdOrOptions === "object") {', + " options = subscriptionIdOrOptions;", + "}", + "options = options ?? {};" + ]); + return constructor; +} + +function buildMethodDeclarations( + method: TSMethod, + settings: TSGenerationSettings +): MethodDeclarationStructure[] { + const methodName = + method.apiFunction.propertyName ?? method.apiFunction.name ?? method.name; + const parameters = method.apiFunction.parameters.filter( + (parameter) => parameter.name !== "context" + ); + const declarations: MethodDeclarationStructure[] = [ + { + docs: method.apiFunction.docs, + kind: StructureKind.Method, + name: methodName, + returnType: method.apiFunction.returnType, + parameters, + statements: `return ${resolveReference(method.apiRefKey)}(${[ + "this._client", + ...parameters.map((parameter) => parameter.name) + ].join(",")})` + } + ]; + + if (!settings.compatibilityLro) { + return declarations; + } + + if (method.kind === "lro") { + const operationStateReference = resolveReference( + AzurePollingDependencies.OperationState + ); + const simplePollerLikeReference = resolveReference( + SimplePollerHelpers.SimplePollerLike + ); + const getSimplePollerReference = resolveReference( + SimplePollerHelpers.getSimplePoller + ); + const returnType = method.compatibilityLroReturnType ?? "void"; + const beginName = normalizeName(`begin_${methodName}`, NameType.Method); + const beginAndWaitName = normalizeName( + `${beginName}_andWait`, + NameType.Method + ); + + declarations.push({ + isAsync: true, + docs: [`@deprecated use ${methodName} instead`], + kind: StructureKind.Method, + name: beginName, + returnType: `Promise<${simplePollerLikeReference}<${operationStateReference}<${returnType}>, ${returnType}>>`, + parameters, + statements: `const poller = ${resolveReference(method.apiRefKey)}(${[ + "this._client", + ...parameters.map((parameter) => parameter.name) + ].join( + "," + )});\nawait poller.submitted();\nreturn ${getSimplePollerReference}(poller);` + }); + declarations.push({ + isAsync: true, + docs: [`@deprecated use ${methodName} instead`], + kind: StructureKind.Method, + name: beginAndWaitName, + returnType: `Promise<${returnType}>`, + parameters, + statements: `return await ${resolveReference(method.apiRefKey)}(${[ + "this._client", + ...parameters.map((parameter) => parameter.name) + ].join(",")});` + }); + } + + if (method.kind === "lroPaging") { + declarations.push({ + docs: [`@deprecated use ${methodName} instead`], + kind: StructureKind.Method, + name: normalizeName(getPagingLROMethodName(methodName), NameType.Method), + returnType: `${resolveReference(PagingHelpers.PagedAsyncIterableIterator)}<${method.compatibilityLroPagingReturnType ?? "void"}>`, + parameters, + statements: `return ${resolveReference(method.apiRefKey)}(${[ + "this._client", + ...parameters.map((parameter) => parameter.name) + ].join(",")});` + }); + } + + return declarations; +} + +function getConstructorParameters(client: TSClient) { + return client.parameters + .filter((parameter) => parameter.required && !parameter.hasDefaultValue) + .filter((parameter) => !parameter.isApiVersion) + .map((parameter) => ({ + name: parameter.name, + type: parameter.type + })); +} + +function getChildOnlyParameters(parent: TSClient, child: TSClient) { + const parentParams = new Set( + getConstructorParameters(parent).map((parameter) => parameter.name) + ); + return getConstructorParameters(child).filter( + (parameter) => !parentParams.has(parameter.name) + ); +} diff --git a/packages/typespec-ts/src/codegen/classicalOperations.ts b/packages/typespec-ts/src/codegen/classicalOperations.ts new file mode 100644 index 0000000000..a4d740332b --- /dev/null +++ b/packages/typespec-ts/src/codegen/classicalOperations.ts @@ -0,0 +1,419 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { NameType, normalizeName } from "@azure-tools/rlc-common"; +import { + FunctionDeclarationStructure, + InterfaceDeclarationStructure, + Project, + PropertySignatureStructure, + SourceFile, + StructureKind +} from "ts-morph"; +import type { + TSClient, + TSGenerationSettings, + TSMethod, + TSOperationGroup +} from "../codemodel/index.js"; +import { addDeclaration } from "../framework/declaration.js"; +import { refkey } from "../framework/refkey.js"; +import { resolveReference } from "../framework/reference.js"; +import { AzurePollingDependencies } from "../modular/external-dependencies.js"; +import { getPagingLROMethodName } from "../modular/helpers/classicalOperationHelpers.js"; +import { getClassicalLayerPrefix } from "../modular/helpers/namingHelpers.js"; +import { + PagingHelpers, + SimplePollerHelpers +} from "../modular/static-helpers-metadata.js"; + +interface ClassicalOperationNode { + prefixes: string[]; + methods: TSMethod[]; + children: Map; +} + +export function emitClassicalOperationFiles( + project: Project, + client: TSClient, + settings: TSGenerationSettings +): SourceFile[] { + if (client.operationGroups.length === 0) { + return []; + } + + const root = buildOperationTree(client.operationGroups); + const files: SourceFile[] = []; + + for (const node of getNodes(root)) { + if (node.prefixes.length === 0) { + continue; + } + + const file = project.createSourceFile( + getClassicFilePath(client, node, settings), + "", + { overwrite: true } + ); + addContextImport(file, client, node); + emitClassicalOperationFile(file, client, node, settings); + file.fixMissingImports({}, { importModuleSpecifierEnding: "js" }); + file.fixUnusedIdentifiers(); + files.push(file); + } + + return files; +} + +function buildOperationTree( + groups: TSOperationGroup[] +): ClassicalOperationNode { + const root: ClassicalOperationNode = { + prefixes: [], + methods: [], + children: new Map() + }; + + for (const group of groups) { + let current = root; + for (const prefix of group.prefixes) { + let child = current.children.get(prefix); + if (!child) { + child = { + prefixes: [...current.prefixes, prefix], + methods: [], + children: new Map() + }; + current.children.set(prefix, child); + } + current = child; + } + + current.methods.push(...group.methods); + } + + return root; +} + +function getNodes(root: ClassicalOperationNode): ClassicalOperationNode[] { + const nodes: ClassicalOperationNode[] = []; + const queue = [...root.children.values()]; + + while (queue.length > 0) { + const node = queue.shift()!; + nodes.push(node); + queue.push(...node.children.values()); + } + + return nodes.sort((left, right) => + left.prefixes.join("/").localeCompare(right.prefixes.join("/")) + ); +} + +function getClassicFilePath( + client: TSClient, + node: ClassicalOperationNode, + settings: TSGenerationSettings +): string { + const subfolder = client.path.join("/"); + const groupPath = node.prefixes + .map((prefix) => normalizeName(prefix, NameType.File)) + .join("/"); + + return `${settings.sourceRoot}/${ + subfolder && subfolder !== "" ? subfolder + "/" : "" + }classic/${groupPath}/index.ts`; +} + +function addContextImport( + file: SourceFile, + client: TSClient, + node: ClassicalOperationNode +): void { + file.addImportDeclaration({ + namedImports: [client.contextTypeName], + moduleSpecifier: `${"../".repeat(node.prefixes.length + 1)}api/index.js` + }); +} + +function emitClassicalOperationFile( + file: SourceFile, + client: TSClient, + node: ClassicalOperationNode, + settings: TSGenerationSettings +): void { + const interfaceNamePrefix = getNodeNamePrefix(node); + const interfaceName = `${interfaceNamePrefix}Operations`; + const properties: PropertySignatureStructure[] = [ + ...getChildProperties(node), + ...node.methods.flatMap((method) => getMethodProperties(method, settings)) + ]; + + addDeclaration( + file, + { + kind: StructureKind.Interface, + name: interfaceName, + isExported: true, + properties, + docs: [`Interface representing a ${interfaceNamePrefix} operations.`] + } satisfies InterfaceDeclarationStructure, + refkey(interfaceName, node.prefixes.length - 1, "classicOperations") + ); + + if (node.methods.length > 0) { + addDeclaration( + file, + getMethodFactory(node, client), + refkey( + `_get${interfaceNamePrefix}`, + node.prefixes.length - 1, + "getClassicOperation" + ) + ); + } + + addDeclaration( + file, + getOperationsFactory(node, client), + refkey( + `_get${interfaceNamePrefix}Operations`, + node.prefixes.length - 1, + "getClassicOperations" + ) + ); +} + +function getNodeNamePrefix(node: ClassicalOperationNode): string { + return getClassicalLayerPrefix( + node.prefixes, + NameType.Interface, + "", + node.prefixes.length - 1 + ); +} + +function getChildProperties( + node: ClassicalOperationNode +): PropertySignatureStructure[] { + return [...node.children.values()] + .sort((left, right) => { + const leftName = left.prefixes[left.prefixes.length - 1] ?? ""; + const rightName = right.prefixes[right.prefixes.length - 1] ?? ""; + return leftName.localeCompare(rightName); + }) + .map((child) => { + const childName = child.prefixes[child.prefixes.length - 1] ?? ""; + const childPrefix = getNodeNamePrefix(child); + return { + kind: StructureKind.PropertySignature, + name: normalizeName(childName, NameType.Property), + type: resolveReference( + refkey( + `${childPrefix}Operations`, + child.prefixes.length - 1, + "classicOperations" + ) + ) + } satisfies PropertySignatureStructure; + }); +} + +function getMethodProperties( + method: TSMethod, + settings: TSGenerationSettings +): PropertySignatureStructure[] { + const methodName = getClassicalMethodName(method); + const paramStr = getSignatureParameters(method); + const properties: PropertySignatureStructure[] = [ + { + kind: StructureKind.PropertySignature, + name: methodName, + type: `(${paramStr}) => ${method.apiFunction.returnType}`, + docs: method.apiFunction.docs + } + ]; + + if (!settings.compatibilityLro) { + return properties; + } + + if (method.kind === "lro") { + const operationStateReference = resolveReference( + AzurePollingDependencies.OperationState + ); + const simplePollerLikeReference = resolveReference( + SimplePollerHelpers.SimplePollerLike + ); + const returnType = method.compatibilityLroReturnType ?? "void"; + const beginName = normalizeName(`begin_${methodName}`, NameType.Method); + const beginAndWaitName = normalizeName( + `${beginName}_andWait`, + NameType.Method + ); + + properties.push({ + kind: StructureKind.PropertySignature, + name: beginName, + type: `(${paramStr}) => Promise<${simplePollerLikeReference}<${operationStateReference}<${returnType}>, ${returnType}>>`, + docs: [`@deprecated use ${methodName} instead`] + }); + properties.push({ + kind: StructureKind.PropertySignature, + name: beginAndWaitName, + type: `(${paramStr}) => Promise<${returnType}>`, + docs: [`@deprecated use ${methodName} instead`] + }); + } + + if (method.kind === "lroPaging") { + properties.push({ + kind: StructureKind.PropertySignature, + name: normalizeName(getPagingLROMethodName(methodName), NameType.Method), + type: `(${paramStr}) => ${resolveReference( + PagingHelpers.PagedAsyncIterableIterator + )}<${method.compatibilityLroPagingReturnType ?? "void"}>`, + docs: [`@deprecated use ${methodName} instead`] + }); + } + + return properties; +} + +function getMethodFactory( + node: ClassicalOperationNode, + client: TSClient +): FunctionDeclarationStructure { + const interfaceNamePrefix = getNodeNamePrefix(node); + return { + kind: StructureKind.Function, + name: `_get${interfaceNamePrefix}`, + parameters: [ + { + name: "context", + type: client.contextTypeName + } + ], + statements: `return {\n${node.methods + .map((method) => getMethodImplementation(method)) + .join(",\n")}\n}` + }; +} + +function getOperationsFactory( + node: ClassicalOperationNode, + client: TSClient +): FunctionDeclarationStructure { + const interfaceNamePrefix = getNodeNamePrefix(node); + const properties = [...node.children.values()] + .sort((left, right) => + (left.prefixes[left.prefixes.length - 1] ?? "").localeCompare( + right.prefixes[right.prefixes.length - 1] ?? "" + ) + ) + .map((child) => { + const childName = normalizeName( + child.prefixes[child.prefixes.length - 1] ?? "", + NameType.Property + ); + const childPrefix = getNodeNamePrefix(child); + return `${childName}: ${resolveReference( + refkey( + `_get${childPrefix}Operations`, + child.prefixes.length - 1, + "getClassicOperations" + ) + )}(context)`; + }); + + if (node.methods.length > 0) { + properties.push(`..._get${interfaceNamePrefix}(context)`); + } + + return { + kind: StructureKind.Function, + name: `_get${interfaceNamePrefix}Operations`, + isExported: true, + parameters: [ + { + name: "context", + type: client.contextTypeName + } + ], + returnType: resolveReference( + refkey( + interfaceNamePrefix + "Operations", + node.prefixes.length - 1, + "classicOperations" + ) + ), + statements: `return {\n${properties.join(",\n")}\n}` + }; +} + +function getMethodImplementation(method: TSMethod): string { + const methodName = getClassicalMethodName(method); + const signatureParams = getSignatureParameters(method); + const apiParams = [ + "context", + ...method.apiFunction.parameters + .map((parameter) => parameter.name) + .filter((name) => name !== "context") + ].join(", "); + const entries = [ + `${methodName}: (${signatureParams}) => ${resolveReference(method.apiRefKey)}(${apiParams})` + ]; + + if (method.kind === "lro") { + const getSimplePollerReference = resolveReference( + SimplePollerHelpers.getSimplePoller + ); + const beginName = normalizeName(`begin_${methodName}`, NameType.Method); + const beginAndWaitName = normalizeName( + `${beginName}_andWait`, + NameType.Method + ); + entries.push(`${beginName}: async (${signatureParams}) => { + const poller = ${resolveReference(method.apiRefKey)}(${apiParams}); + await poller.submitted(); + return ${getSimplePollerReference}(poller); + }`); + entries.push(`${beginAndWaitName}: async (${signatureParams}) => { + return await ${resolveReference(method.apiRefKey)}(${apiParams}); + }`); + } + + if (method.kind === "lroPaging") { + const beginListAndWaitName = normalizeName( + getPagingLROMethodName(methodName), + NameType.Method + ); + entries.push(`${beginListAndWaitName}: (${signatureParams}) => { + return ${resolveReference(method.apiRefKey)}(${apiParams}); + }`); + } + + return entries.join(",\n"); +} + +function getSignatureParameters(method: TSMethod): string { + return method.apiFunction.parameters + .filter((parameter) => parameter.name !== "context") + .map((parameter) => { + const isOptional = + parameter.hasQuestionToken || + parameter.type?.toString().endsWith("operationOptions__"); + return `${parameter.name}${isOptional ? "?" : ""}: ${parameter.type}`; + }) + .join(", "); +} + +function getClassicalMethodName(method: TSMethod): string { + return normalizeName( + method.originalName ?? + method.apiFunction.propertyName ?? + method.apiFunction.name ?? + method.name, + NameType.Method + ); +} diff --git a/packages/typespec-ts/src/codegen/clients.ts b/packages/typespec-ts/src/codegen/clients.ts new file mode 100644 index 0000000000..aa2296aacd --- /dev/null +++ b/packages/typespec-ts/src/codegen/clients.ts @@ -0,0 +1,382 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Client context file generator. + * + * Generates `api/{name}Context.ts` from a TSClient node in the code model. + * Produces: + * - Client interface (e.g., `FooContext extends Client`) + * - Options interface (e.g., `FooClientOptionalParams extends ClientOptions`) + * - Factory function (e.g., `createFoo(endpoint, options): FooContext`) + * + * Zero TCGC imports — only code model types + ts-morph. + */ + +import { NameType, normalizeName } from "@azure-tools/rlc-common"; +import { Project, SourceFile } from "ts-morph"; +import type { + TSClient, + TSClientParameter, + TSGenerationSettings +} from "../codemodel/index.js"; +import { resolveReference } from "../framework/reference.js"; +import { useDependencies } from "../framework/hooks/useDependencies.js"; +import { refkey } from "../framework/refkey.js"; +import { CloudSettingHelpers } from "../modular/static-helpers-metadata.js"; + +/** + * Emit the client context file for a single client. + */ +export function emitClientContext( + project: Project, + client: TSClient, + settings: TSGenerationSettings +): SourceFile | undefined { + const dependencies = useDependencies(); + const subfolder = client.path.join("/"); + + const filePath = `${settings.sourceRoot}/${ + subfolder && subfolder !== "" ? subfolder + "/" : "" + }api/${normalizeName(client.modularName, NameType.File)}Context.ts`; + + const file = project.createSourceFile(filePath); + + // ── Logger import (Azure only) ── + if (settings.flavor === "azure") { + file.addImportDeclaration({ + moduleSpecifier: "../".repeat(client.path.length + 1) + "logger.js", + namedImports: ["logger"] + }); + } + + // ── Client interface ── + const requiredProperties = client.parameters + .filter((p) => !p.isEndpoint && !p.isCredential && p.required) + .map((p) => ({ + name: p.name, + type: p.type, + hasQuestionToken: false, + docs: buildParamDocs(p, client) + })); + + const requiredPropertyNames = new Set( + requiredProperties.map((property) => property.name) + ); + + const optionalProperties = client.parameters + .filter((p) => !p.required || p.hasDefaultValue) + .filter( + (p) => + !p.isEndpoint && !p.isCredential && !requiredPropertyNames.has(p.name) + ) + .map((p) => ({ + name: p.name, + type: p.type, + hasQuestionToken: true, + docs: buildParamDocs(p, client) + })); + + file.addInterface({ + isExported: true, + name: client.contextTypeName, + extends: [resolveReference(dependencies.Client)], + docs: client.docs, + properties: [...requiredProperties, ...optionalProperties] + }); + + // ── Options interface ── + const useStringForApiVersion = + client.apiVersion?.parameterName.toLowerCase() === "apiversion"; + const optionsProperties = client.parameters + .filter((p) => p.hasDefaultValue || !p.required) + .filter((p) => p.name !== "endpoint") + .map((p) => ({ + name: p.name, + type: p.isApiVersion && useStringForApiVersion ? "string" : p.type, + hasQuestionToken: true, + docs: buildParamDocs(p, client) + })); + + if (settings.isArm) { + optionsProperties.push({ + name: "cloudSetting", + type: `${resolveReference(CloudSettingHelpers.AzureSupportedClouds)}`, + hasQuestionToken: true, + docs: ["Specifies the Azure cloud environment for the client."] + }); + } + + file.addInterface({ + name: `${client.name}OptionalParams`, + isExported: true, + extends: [resolveReference(dependencies.ClientOptions)], + properties: optionsProperties, + docs: ["Optional parameters for the client."] + }); + + // ── Factory function ── + const factoryParams = client.parameters + .filter((p) => p.required && !p.hasDefaultValue && !p.isApiVersion) + .map((p) => ({ + name: p.name, + type: p.type + })); + factoryParams.push({ + name: "options", + type: `${client.name}OptionalParams` + }); + + const fn = file.addFunction({ + docs: client.docs, + name: `create${client.modularName}`, + returnType: client.contextTypeName, + parameters: factoryParams.map((p) => ({ + name: p.name, + type: p.type, + ...(p.name === "options" ? { initializer: "{}" } : {}) + })), + isExported: true + }); + + // Factory body: endpoint setup + const assignedOptionalParams = emitEndpointSetup(fn, client, settings); + + // Factory body: options setup + emitOptionsSetup(fn, client, settings); + + // Factory body: getClient call + fn.addStatements( + `const clientContext = ${resolveReference( + dependencies.getClient + )}(endpointUrl, ${client.credential.parameterName}, updatedOptions);` + ); + + // Factory body: custom auth policy + if ( + settings.customHttpAuthHeaderName && + settings.customHttpAuthSharedKeyPrefix + ) { + fn.addStatements(` + if(${resolveReference(dependencies.isKeyCredential)}(credential)) { + clientContext.pipeline.addPolicy({ + name: "customKeyCredentialPolicy", + sendRequest(request, next) { + request.headers.set("${settings.customHttpAuthHeaderName}", "${settings.customHttpAuthSharedKeyPrefix} " + credential.key); + return next(request); + } + }); + }`); + } + + // Factory body: api version handling + emitApiVersionHandling(fn, client, settings); + + // Factory body: return statement + emitReturnStatement(fn, client, assignedOptionalParams); + + // Fix imports + file.fixMissingImports({}, { importModuleSpecifierEnding: "js" }); + file.fixUnusedIdentifiers(); + + return file; +} + +// ─── Factory body helpers ───────────────────────────────────────────── + +function emitEndpointSetup( + fn: any, + client: TSClient, + settings: TSGenerationSettings +): Set { + const assignedOptionalParams = new Set(); + const coreEndpoint = settings.isArm + ? `options.endpoint ?? ${resolveReference(CloudSettingHelpers.getArmEndpoint)}(options.cloudSetting)` + : "options.endpoint"; + + const ep = client.endpoint; + if (ep.isParameterized && ep.serverUrl) { + for (const tp of ep.templateParameters) { + if (tp.clientDefaultValue) { + const defaultStr = + typeof tp.clientDefaultValue === "string" + ? `"${tp.clientDefaultValue}"` + : tp.clientDefaultValue; + fn.addStatements( + `const ${tp.name} = options.${tp.name} ?? ${defaultStr};` + ); + assignedOptionalParams.add(tp.name); + } else if (tp.isOptional) { + fn.addStatements(`const ${tp.name} = options.${tp.name};`); + assignedOptionalParams.add(tp.name); + } + } + + let url = ep.serverUrl; + for (const tp of ep.templateParameters) { + url = url.replace(`{${tp.tcgcName}}`, `\${${tp.name}}`); + } + fn.addStatements(`const endpointUrl = ${coreEndpoint} ?? \`${url}\`;`); + return assignedOptionalParams; + } + + if (ep.templateParameters.length > 0) { + const firstArg = ep.templateParameters[0]; + const defaultStr = firstArg?.clientDefaultValue + ? typeof firstArg.clientDefaultValue === "string" + ? `"${firstArg.clientDefaultValue}"` + : firstArg.clientDefaultValue + : `String(${getEndpointParamName(client)})`; + fn.addStatements(`const endpointUrl = ${coreEndpoint} ?? ${defaultStr};`); + return assignedOptionalParams; + } + + fn.addStatements(`const endpointUrl = ${coreEndpoint};`); + return assignedOptionalParams; +} + +function emitOptionsSetup( + fn: any, + client: TSClient, + settings: TSGenerationSettings +): void { + // User agent prefix + fn.addStatements( + `const prefixFromOptions = options?.userAgentOptions?.userAgentPrefix;` + ); + + const pkgName = settings.packageName ?? ""; + const pkgVersion = settings.packageVersion ?? ""; + if (pkgName && pkgVersion) { + fn.addStatements( + `const userAgentInfo = \`azsdk-js-${pkgName}/${pkgVersion}\`;` + ); + fn.addStatements( + `const userAgentPrefix = prefixFromOptions ? \`\${prefixFromOptions} azsdk-js-api \${userAgentInfo}\` : \`azsdk-js-api \${userAgentInfo}\`;` + ); + } else { + fn.addStatements( + `const userAgentPrefix = prefixFromOptions ? \`\${prefixFromOptions} azsdk-js-api\` : \`azsdk-js-api\`;` + ); + } + + // Build options destructure + const apiVersionParam = client.apiVersion?.parameterName ?? "apiVersion"; + let optionsExpr = `const { ${apiVersionParam}: _, ...updatedOptions } = {...options,`; + optionsExpr += `userAgentOptions: { userAgentPrefix },`; + + if (settings.flavor === "azure") { + optionsExpr += `loggingOptions: { logger: options.loggingOptions?.logger ?? logger.info },`; + } + + if (settings.addCredentials) { + const scopesStr = settings.credentialScopes + ? settings.credentialScopes.map((cs) => `"${cs}"`).join(", ") || + "`${endpointUrl}/.default`" + : ""; + const scopes = scopesStr + ? `scopes: options.credentials?.scopes ?? [${scopesStr}],` + : ""; + const apiKeyHeader = settings.credentialKeyHeaderName + ? `apiKeyHeaderName: options.credentials?.apiKeyHeaderName ?? "${settings.credentialKeyHeaderName}",` + : ""; + if (scopes || apiKeyHeader) { + optionsExpr += `credentials: { ${scopes}${apiKeyHeader} },`; + } + } + + optionsExpr += `};`; + fn.addStatements(optionsExpr); +} + +function emitApiVersionHandling( + fn: any, + client: TSClient, + settings: TSGenerationSettings +): void { + if (client.apiVersion) { + if ( + !client.apiVersion.isInEndpointTemplate && + client.apiVersion.clientDefaultValue + ) { + fn.addStatements( + `const ${client.apiVersion.parameterName} = options.${client.apiVersion.parameterName};` + ); + } + } else if (settings.flavor === "azure") { + fn.addStatements(` + if (options.apiVersion) { + logger.warning("This client does not support client api-version, please change it at the operation level"); + }`); + } else { + fn.addStatements(` + if (options.apiVersion) { + console.warn("This client does not support client api-version, please change it at the operation level"); + }`); + } +} + +function emitReturnStatement( + fn: any, + client: TSClient, + assignedOptionalParams: Set +): void { + const contextRequiredParams = client.parameters.filter( + (p) => + !p.isEndpoint && !p.isCredential && p.name !== "options" && p.required + ); + + const requiredParamNames = new Set( + contextRequiredParams.map((param) => param.name) + ); + + const contextOptionalParams = client.parameters.filter( + (p) => + !p.isEndpoint && + !p.isCredential && + p.name !== "options" && + !requiredParamNames.has(p.name) && + (!p.required || p.hasDefaultValue) + ); + + const allContextParams = [ + ...contextRequiredParams.map((p) => p.name), + ...contextOptionalParams.map((p) => { + if ( + requiredParamNames.has(p.name) || + assignedOptionalParams.has(p.name) + ) { + return p.name; + } + return `${p.name}: options.${p.name}`; + }) + ]; + + if (allContextParams.length) { + fn.addStatements( + `return { ...clientContext, ${allContextParams.join(", ")}} as ${client.contextTypeName};` + ); + } else { + fn.addStatements(`return clientContext;`); + } +} + +// ─── Helpers ────────────────────────────────────────────────────────── + +function getEndpointParamName(client: TSClient): string { + return client.parameters.find((p) => p.isEndpoint)?.name ?? "endpointParam"; +} + +function buildParamDocs(param: TSClientParameter, client: TSClient): string[] { + const docs = [...param.docs]; + if ( + param.isApiVersion && + client.apiVersion?.knownValuesEnumName && + client.apiVersion.parameterName.toLowerCase() === "apiversion" + ) { + docs.push( + `Known values of {@link ${resolveReference(refkey(client.apiVersion.knownValuesEnumName, "knownValues"))}} that the service accepts.` + ); + } + return docs; +} diff --git a/packages/typespec-ts/src/codegen/emitter.ts b/packages/typespec-ts/src/codegen/emitter.ts new file mode 100644 index 0000000000..f3d6a07570 --- /dev/null +++ b/packages/typespec-ts/src/codegen/emitter.ts @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Codegen orchestrator — walks TSCodeModel and dispatches to file generators. + * + * Analogous to Go's `Emitter.emit()` and Rust's `CodeGenerator.emitContent()`. + */ + +import { Project, SourceFile } from "ts-morph"; +import type { TSCodeModel } from "../codemodel/index.js"; +import { emitApiOptions } from "./apiOptions.js"; +import { emitClassicalClient } from "./classicalClient.js"; +import { emitClientContext } from "./clients.js"; +import { emitLroHelpers } from "./lroHelpers.js"; +import { emitOperations } from "./operations.js"; + +/** + * Generate all source files from the code model. + * + * This is the main entry point for codegen. It walks the code model + * tree and generates source files for each component. + * + * Currently supports: operation files, client context files, and classical clients. + * Returns the list of generated source files. + */ +export function emitFromCodeModel( + project: Project, + codeModel: TSCodeModel +): SourceFile[] { + const files: SourceFile[] = []; + + for (const client of codeModel.clients) { + files.push(...emitApiOptions(project, client, codeModel.settings)); + files.push(...emitOperations(project, client, codeModel.settings)); + + const contextFile = emitClientContext(project, client, codeModel.settings); + if (contextFile) { + files.push(contextFile); + } + + const classicalClientFile = emitClassicalClient( + project, + client, + codeModel.settings + ); + if (classicalClientFile) { + files.push(classicalClientFile); + } + + const lroHelpersFile = emitLroHelpers(project, client, codeModel.settings); + if (lroHelpersFile) { + files.push(lroHelpersFile); + } + } + + return files; +} diff --git a/packages/typespec-ts/src/codegen/index.ts b/packages/typespec-ts/src/codegen/index.ts new file mode 100644 index 0000000000..2315c94a4d --- /dev/null +++ b/packages/typespec-ts/src/codegen/index.ts @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Codegen emitter — walks the TSCodeModel tree and generates source files. + * + * This is the TypeScript equivalent of: + * - Go's `codegen.go/src/emitter.ts` → `Emitter.emit()` + * - Rust's `src/codegen/codeGenerator.ts` → `CodeGenerator.emitContent()` + * + * This layer has ZERO TCGC imports. It consumes only the code model types. + * It uses ts-morph for file generation and the framework binder for + * import/reference resolution. + */ + +export { emitApiOptions } from "./apiOptions.js"; +export { emitFromCodeModel } from "./emitter.js"; +export { emitClassicalOperationFiles } from "./classicalOperations.js"; +export { emitLroHelpers } from "./lroHelpers.js"; +export { + emitRootIndex, + emitSubClientIndex, + emitSubpathIndexFiles +} from "./indexFiles.js"; diff --git a/packages/typespec-ts/src/codegen/indexFiles.ts b/packages/typespec-ts/src/codegen/indexFiles.ts new file mode 100644 index 0000000000..15fb14121d --- /dev/null +++ b/packages/typespec-ts/src/codegen/indexFiles.ts @@ -0,0 +1,577 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { NameType, normalizeName } from "@azure-tools/rlc-common"; +import { join } from "path/posix"; +import { Node, Project, SourceFile } from "ts-morph"; +import type { TSClient, TSGenerationSettings } from "../codemodel/index.js"; +import { resolveReference } from "../framework/reference.js"; +import { + CloudSettingHelpers, + MultipartHelpers, + PagingHelpers, + PlatformTypeHelpers +} from "../modular/static-helpers-metadata.js"; + +export interface EmitSubpathIndexOptions { + exportIndex?: boolean; + interfaceOnly?: boolean; + recursive?: boolean; +} + +export function emitSubpathIndexFiles( + project: Project, + settings: TSGenerationSettings, + subpath: string, + client?: TSClient, + options: EmitSubpathIndexOptions = {} +): SourceFile[] { + const subfolder = client?.path.join("/") ?? ""; + const srcPath = settings.sourceRoot; + const skipFiles = ["pagingHelpers.ts", "pollingHelpers.ts"]; + const folders = options.recursive + ? project + .getDirectories() + .filter((dir) => { + const formattedDir = dir.getPath().replace(/\\/g, "/"); + const targetPath = join(srcPath, subfolder, subpath).replace( + /\\/g, + "/" + ); + return ( + formattedDir.startsWith(targetPath) && + !project.getSourceFile(`${formattedDir}/index.ts`) + ); + }) + .map((dir) => dir.getPath().replace(/\\/g, "/")) + .sort((left, right) => left.localeCompare(right)) + : [join(srcPath, subfolder, subpath).replace(/\\/g, "/")]; + const indexFiles: SourceFile[] = []; + + for (const folder of folders) { + const apiFilePattern = + subpath === "models" ? join(folder, "models.ts") : folder; + const apiFiles = project + .getSourceFiles() + .filter((file) => { + if (subpath === "api" && options.recursive) { + return ( + file.getDirectoryPath().replace(/\\/g, "/") === + apiFilePattern.replace(/\\/g, "/") + ); + } + return file + .getFilePath() + .replace(/\\/g, "/") + .startsWith( + apiFilePattern.replace(/\\/g, "/") + + (apiFilePattern.endsWith("models.ts") ? "" : "/") + ); + }) + .sort((left, right) => + left.getFilePath().localeCompare(right.getFilePath()) + ); + + if (apiFiles.length === 0) { + continue; + } + + const indexFile = project.createSourceFile(`${folder}/index.ts`, "", { + overwrite: true + }); + for (const file of apiFiles) { + const filePath = file.getFilePath(); + const serializerOrDeserializerRegex = + /.*(Serializer|Deserializer)(_\d+)?$/; + if (!options.exportIndex && filePath.endsWith("index.ts")) { + continue; + } + if (skipFiles.some((skipFile) => filePath.endsWith(skipFile))) { + continue; + } + if (filePath === indexFile.getFilePath()) { + continue; + } + + let filteredDeclarations = [ + ...file.getExportedDeclarations().entries() + ].filter(([name, declarations]) => { + if (name.startsWith("_")) { + return false; + } + return declarations.some((declaration) => { + if ( + options.interfaceOnly && + declaration.getKindName() !== "InterfaceDeclaration" + ) { + return false; + } + if ( + subpath === "models" && + declaration.getKindName() === "FunctionDeclaration" && + serializerOrDeserializerRegex.test(name) + ) { + return false; + } + return true; + }); + }); + + if (filePath.endsWith("pagingTypes.ts")) { + filteredDeclarations = filteredDeclarations.filter( + ([name]) => + !["PagedResult", "BuildPagedAsyncIteratorOptions"].includes(name) + ); + } + + if (filteredDeclarations.length === 0) { + continue; + } + + const moduleSpecifier = `.${filePath + .replace(indexFile.getDirectoryPath(), "") + .replace(/\\/g, "/") + .replace(".ts", "")}.js`; + partitionAndEmitExports(indexFile, moduleSpecifier, filteredDeclarations); + } + indexFile.fixMissingImports({}, { importModuleSpecifierEnding: "js" }); + indexFile.fixUnusedIdentifiers(); + indexFiles.push(indexFile); + } + + return indexFiles; +} + +export function emitRootIndex( + project: Project, + settings: TSGenerationSettings, + rootIndexFile: SourceFile, + client?: TSClient +): SourceFile { + if (!client) { + exportModels(project, settings, rootIndexFile); + exportRestErrorTypes(settings, rootIndexFile); + rootIndexFile.fixMissingImports({}, { importModuleSpecifierEnding: "js" }); + rootIndexFile.fixUnusedIdentifiers(); + return rootIndexFile; + } + + const subfolder = client.path.join("/"); + const clientName = client.name; + exportClassicalClient(client, rootIndexFile, subfolder); + exportSimplePollerLike( + client, + settings, + rootIndexFile, + project, + subfolder, + true + ); + exportRestoreHelpers( + rootIndexFile, + project, + settings, + clientName, + subfolder, + true + ); + exportModels(project, settings, rootIndexFile, clientName); + exportModules(project, rootIndexFile, settings, clientName, "api", { + subfolder, + interfaceOnly: true, + isTopLevel: true, + recursive: true + }); + exportModules(project, rootIndexFile, settings, clientName, "classic", { + subfolder, + isTopLevel: true + }); + exportPagingTypes(client, rootIndexFile); + exportFileContentsType(project, settings, rootIndexFile); + exportAzureCloudTypes(settings, rootIndexFile); + exportRestErrorTypes(settings, rootIndexFile); + rootIndexFile.fixMissingImports({}, { importModuleSpecifierEnding: "js" }); + rootIndexFile.fixUnusedIdentifiers(); + return rootIndexFile; +} + +export function emitSubClientIndex( + project: Project, + settings: TSGenerationSettings, + client: TSClient +): SourceFile { + const subfolder = client.path.join("/"); + const subClientIndexFile = project.createSourceFile( + `${settings.sourceRoot}/${subfolder && subfolder !== "" ? subfolder + "/" : ""}index.ts`, + "", + { overwrite: true } + ); + exportClassicalClient(client, subClientIndexFile, subfolder, true); + exportSimplePollerLike( + client, + settings, + subClientIndexFile, + project, + subfolder + ); + exportRestoreHelpers( + subClientIndexFile, + project, + settings, + client.name, + subfolder + ); + exportModules(project, subClientIndexFile, settings, client.name, "api", { + subfolder, + interfaceOnly: true, + recursive: true + }); + exportModules(project, subClientIndexFile, settings, client.name, "classic", { + subfolder + }); + subClientIndexFile.fixMissingImports( + {}, + { importModuleSpecifierEnding: "js" } + ); + subClientIndexFile.fixUnusedIdentifiers(); + return subClientIndexFile; +} + +function exportModels( + project: Project, + settings: TSGenerationSettings, + rootIndexFile: SourceFile, + clientName: string = "" +): void { + const modelsExportsIndex = rootIndexFile + .getExportDeclarations() + .find((declaration) => + declaration.getModuleSpecifierValue()?.startsWith("./models/") + ); + if (!modelsExportsIndex) { + exportModules(project, rootIndexFile, settings, clientName, "models", { + isTopLevel: true, + recursive: true + }); + } +} + +function exportAzureCloudTypes( + settings: TSGenerationSettings, + rootIndexFile: SourceFile +): void { + if (!settings.isArm) { + return; + } + + addExportsToIndex(rootIndexFile, [ + resolveReference(CloudSettingHelpers.AzureClouds) + ]); + addExportsToIndex( + rootIndexFile, + [resolveReference(CloudSettingHelpers.AzureSupportedClouds)], + true + ); +} + +function exportRestErrorTypes( + settings: TSGenerationSettings, + rootIndexFile: SourceFile +): void { + if (settings.flavor !== "azure") { + return; + } + + const existingExports = getExistingExports(rootIndexFile); + const namedExports = ["RestError", "isRestError"].filter( + (name) => !existingExports.has(name) + ); + if (namedExports.length > 0) { + rootIndexFile.addExportDeclaration({ + moduleSpecifier: "@azure/core-rest-pipeline", + namedExports + }); + } +} + +function exportPagingTypes(client: TSClient, rootIndexFile: SourceFile): void { + if (!hasPaging(client)) { + return; + } + + addExportsToIndex( + rootIndexFile, + [ + resolveReference(PagingHelpers.PageSettings), + resolveReference(PagingHelpers.ContinuablePage), + resolveReference(PagingHelpers.PagedAsyncIterableIterator) + ], + true + ); +} + +function hasPaging(client: TSClient): boolean { + const currentClientHasPaging = [ + ...client.methods, + ...client.operationGroups.flatMap((group) => group.methods) + ].some((method) => method.kind === "paging" || method.kind === "lroPaging"); + if (currentClientHasPaging) { + return true; + } + + return client.children.some((child) => hasPaging(child)); +} + +function exportFileContentsType( + project: Project, + settings: TSGenerationSettings, + rootIndexFile: SourceFile +): void { + const hasMultipartFileParts = project + .getSourceFiles(`${settings.sourceRoot}/models/**/*.ts`) + .some((file) => file.getText().includes("FileContents")); + + if (!hasMultipartFileParts) { + return; + } + + addExportsToIndex( + rootIndexFile, + [ + resolveReference(MultipartHelpers.FileContents), + resolveReference(PlatformTypeHelpers.NodeReadableStream) + ], + true + ); +} + +function getExistingExports(rootIndexFile: SourceFile): Set { + return new Set( + rootIndexFile + .getExportDeclarations() + .flatMap((exportDeclaration) => + exportDeclaration + .getNamedExports() + .map((namedExport) => namedExport.getName()) + ) + ); +} + +function addExportsToIndex( + indexFile: SourceFile, + namedExports: string[], + isTypeOnly: boolean = false +): void { + const existingExports = getExistingExports(indexFile); + const newNamedExports = namedExports.filter( + (namedExport) => !existingExports.has(namedExport) + ); + if (newNamedExports.length > 0) { + indexFile.addExportDeclaration({ + isTypeOnly, + namedExports: newNamedExports + }); + } +} + +function exportSimplePollerLike( + client: TSClient, + settings: TSGenerationSettings, + indexFile: SourceFile, + project: Project, + subfolder: string = "", + isTopLevel: boolean = false +): void { + const hasLro = [ + ...client.methods, + ...client.operationGroups.flatMap((group) => group.methods) + ].some((method) => method.kind === "lro"); + if (!hasLro || settings.compatibilityLro !== true) { + return; + } + const helperFile = project.getSourceFile( + `${settings.sourceRoot}/${ + subfolder && subfolder !== "" ? subfolder + "/" : "" + }static-helpers/simplePollerHelpers.ts` + ); + if (!helperFile) { + return; + } + indexFile.addExportDeclaration({ + isTypeOnly: true, + moduleSpecifier: `./${ + isTopLevel && subfolder && subfolder !== "" ? subfolder + "/" : "" + }static-helpers/simplePollerHelpers.js`, + namedExports: ["SimplePollerLike"] + }); +} + +function exportRestoreHelpers( + indexFile: SourceFile, + project: Project, + settings: TSGenerationSettings, + clientName: string, + subfolder: string = "", + isTopLevel: boolean = false +): void { + const helperFile = project.getSourceFile( + `${settings.sourceRoot}/${ + subfolder && subfolder !== "" ? subfolder + "/" : "" + }restorePollerHelpers.ts` + ); + if (!helperFile) { + return; + } + const exported = new Set(indexFile.getExportedDeclarations().keys()); + const allEntries = [...helperFile.getExportedDeclarations().entries()]; + const moduleSpecifier = `./${ + isTopLevel && subfolder && subfolder !== "" ? subfolder + "/" : "" + }restorePollerHelpers.js`; + const renamer = (name: string) => + exported.has(name) ? `${name} as ${clientName}${name}` : name; + partitionAndEmitExports(indexFile, moduleSpecifier, allEntries, renamer); +} + +function exportClassicalClient( + client: TSClient, + indexFile: SourceFile, + subfolder: string, + isSubClient: boolean = false +): void { + indexFile.addExportDeclaration({ + namedExports: [client.name], + moduleSpecifier: `./${ + subfolder && subfolder !== "" && !isSubClient ? subfolder + "/" : "" + }${normalizeName(client.name, NameType.File)}.js` + }); +} + +interface ExportModulesOptions { + interfaceOnly?: boolean; + isTopLevel?: boolean; + subfolder?: string; + recursive?: boolean; +} + +function exportModules( + project: Project, + indexFile: SourceFile, + settings: TSGenerationSettings, + clientName: string, + moduleName: string, + options: ExportModulesOptions = { + interfaceOnly: false, + isTopLevel: false, + subfolder: "", + recursive: false + } +): void { + const subfolder = options.subfolder ?? ""; + const folders = options.recursive + ? project + .getDirectories() + .filter((dir) => { + const formattedDir = dir.getPath().replace(/\\/g, "/"); + const targetPath = join( + settings.sourceRoot, + subfolder, + moduleName + ).replace(/\\/g, "/"); + return formattedDir.startsWith(targetPath); + }) + .map((dir) => dir.getPath().replace(/\\/g, "/")) + .sort((left, right) => left.localeCompare(right)) + : [join(settings.sourceRoot, subfolder, moduleName).replace(/\\/g, "/")]; + + for (const folder of folders) { + const moduleFile = project.getSourceFile( + join(folder, "index.ts").replace(/\\/g, "/") + ); + if (!moduleFile) { + continue; + } + + const exported = new Set(indexFile.getExportedDeclarations().keys()); + const serializerOrDeserializerRegex = /.*(Serializer|Deserializer)(_\d+)?$/; + const filteredEntries = [ + ...moduleFile.getExportedDeclarations().entries() + ].filter(([name, declarations]) => { + if (name.startsWith("_")) { + return false; + } + return declarations.some((declaration) => { + if ( + options.interfaceOnly && + declaration.getKindName() !== "InterfaceDeclaration" + ) { + return false; + } + if ( + moduleName === "models" && + declaration.getKindName() === "FunctionDeclaration" && + serializerOrDeserializerRegex.test(name) + ) { + return false; + } + if ( + options.interfaceOnly && + options.isTopLevel && + name.endsWith("Context") + ) { + return false; + } + return true; + }); + }); + + const moduleSpecifier = `.${moduleFile + .getFilePath() + .replace(indexFile.getDirectoryPath(), "") + .replace(/\\/g, "/") + .replace(".ts", "")}.js`; + const renamer = (name: string) => + exported.has(name) ? `${name} as ${clientName}${name}` : name; + partitionAndEmitExports( + indexFile, + moduleSpecifier, + filteredEntries, + renamer + ); + } +} + +function isTypeOnlyNode(node: Node): boolean { + const kind = node.getKindName(); + return kind === "InterfaceDeclaration" || kind === "TypeAliasDeclaration"; +} + +function partitionAndEmitExports( + indexFile: SourceFile, + moduleSpecifier: string, + entries: [string, Node[]][], + mapName: (name: string) => string = (name) => name +): void { + const typeOnlyExports: string[] = []; + const valueExports: string[] = []; + for (const [name, declarations] of entries) { + const mappedName = mapName(name); + if (declarations.every(isTypeOnlyNode)) { + typeOnlyExports.push(mappedName); + } else { + valueExports.push(mappedName); + } + } + if (typeOnlyExports.length > 0) { + indexFile.addExportDeclaration({ + isTypeOnly: true, + moduleSpecifier, + namedExports: typeOnlyExports + }); + } + if (valueExports.length > 0) { + indexFile.addExportDeclaration({ + moduleSpecifier, + namedExports: valueExports + }); + } +} diff --git a/packages/typespec-ts/src/codegen/lroHelpers.ts b/packages/typespec-ts/src/codegen/lroHelpers.ts new file mode 100644 index 0000000000..1cfe40c139 --- /dev/null +++ b/packages/typespec-ts/src/codegen/lroHelpers.ts @@ -0,0 +1,238 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { NameType, normalizeName } from "@azure-tools/rlc-common"; +import { Project, SourceFile } from "ts-morph"; +import type { TSClient, TSGenerationSettings } from "../codemodel/index.js"; +import { useDependencies } from "../framework/hooks/useDependencies.js"; +import { resolveReference } from "../framework/reference.js"; +import { AzurePollingDependencies } from "../modular/external-dependencies.js"; +import { PollingHelpers } from "../modular/static-helpers-metadata.js"; + +export function emitLroHelpers( + project: Project, + client: TSClient, + settings: TSGenerationSettings +): SourceFile | undefined { + if (!client.lroConfig) { + return undefined; + } + + const dependencies = useDependencies(); + const subfolder = client.path.join("/"); + const file = project.createSourceFile( + `${settings.sourceRoot}/${ + subfolder && subfolder !== "" ? subfolder + "/" : "" + }restorePollerHelpers.ts`, + "", + { overwrite: true } + ); + + file.addImportDeclaration({ + namedImports: [client.lroConfig.clientName], + moduleSpecifier: `./${normalizeName(client.name, NameType.File)}.js` + }); + + const groupedImports = new Map< + string, + Array<{ exportName: string; localName: string }> + >(); + for (const deserializer of [...client.lroConfig.deserializers].sort( + (left, right) => left.path.localeCompare(right.path) + )) { + const imports = groupedImports.get(deserializer.moduleSpecifier) ?? []; + imports.push({ + exportName: deserializer.exportName, + localName: deserializer.localName + }); + groupedImports.set(deserializer.moduleSpecifier, imports); + } + + for (const moduleSpecifier of [...groupedImports.keys()].sort((left, right) => + left.localeCompare(right) + )) { + const namedImports = groupedImports + .get(moduleSpecifier)! + .map((entry) => + entry.exportName === entry.localName + ? entry.exportName + : `${entry.exportName} as ${entry.localName}` + ); + file.addImportDeclaration({ + namedImports, + moduleSpecifier + }); + } + + const pathUncheckedReference = resolveReference( + dependencies.PathUncheckedResponse + ); + const operationOptionsReference = resolveReference( + dependencies.OperationOptions + ); + const abortSignalLikeReference = resolveReference( + dependencies.AbortSignalLike + ); + const pollerLikeReference = resolveReference( + AzurePollingDependencies.PollerLike + ); + const operationStateReference = resolveReference( + AzurePollingDependencies.OperationState + ); + const deserializeStateReference = resolveReference( + AzurePollingDependencies.DeserializeState + ); + const resourceLocationConfigReference = resolveReference( + AzurePollingDependencies.ResourceLocationConfig + ); + const getLongRunningPollerReference = resolveReference( + PollingHelpers.GetLongRunningPoller + ); + const deserializeMapEntries = client.lroConfig.deserializers + .map( + (detail) => + `"${detail.path}": { deserializer: ${detail.localName}, expectedStatuses: ${detail.expectedStatusesExpression} }` + ) + .join(",\n"); + + file.addStatements(` + export interface RestorePollerOptions< + TResult, + TResponse extends ${pathUncheckedReference} = ${pathUncheckedReference} + > extends ${operationOptionsReference} { + /** Delay to wait until next poll, in milliseconds. */ + updateIntervalInMs?: number; + /** + * The signal which can be used to abort requests. + */ + abortSignal?: ${abortSignalLikeReference}; + /** Deserialization function for raw response body */ + processResponseBody?: (result: TResponse) => Promise; + } + + /** + * Creates a poller from the serialized state of another poller. This can be + * useful when you want to create pollers on a different host or a poller + * needs to be constructed after the original one is not in scope. + */ + export function restorePoller( + client: ${client.lroConfig.clientName}, + serializedState: string, + sourceOperation: ( + ...args: any[] + ) => ${pollerLikeReference}<${operationStateReference}, TResult>, + options?: RestorePollerOptions + ): ${pollerLikeReference}<${operationStateReference}, TResult> { + const pollerConfig = ${deserializeStateReference}(serializedState).config; + const { initialRequestUrl, requestMethod, metadata } = pollerConfig; + if (!initialRequestUrl || !requestMethod) { + throw new Error( + \`Invalid serialized state: \${serializedState} for sourceOperation \${sourceOperation?.name}\` + ); + } + const resourceLocationConfig = metadata?.["resourceLocationConfig"] as + | ${resourceLocationConfigReference} + | undefined; + const { deserializer, expectedStatuses = [] } = + getDeserializationHelper(initialRequestUrl, requestMethod) ?? {}; + const deserializeHelper = options?.processResponseBody ?? deserializer; + if (!deserializeHelper) { + throw new Error( + \`Please ensure the operation is in this client! We can't find its deserializeHelper for \${sourceOperation?.name}.\` + ); + } + const apiVersion = getApiVersionFromUrl(initialRequestUrl); + return ${getLongRunningPollerReference}( + (client as any)["_client"] ?? client, + deserializeHelper as (result: TResponse) => Promise, + expectedStatuses, + { + updateIntervalInMs: options?.updateIntervalInMs, + abortSignal: options?.abortSignal, + resourceLocationConfig, + restoreFrom: serializedState, + initialRequestUrl, + apiVersion, + } + ); + } + + interface DeserializationHelper { + deserializer: (result: ${pathUncheckedReference}) => Promise; + expectedStatuses: string[]; + } + + const deserializeMap: Record = { + ${deserializeMapEntries} + }; + + function getDeserializationHelper( + urlStr: string, + method: string + ): DeserializationHelper | undefined { + const path = new URL(urlStr).pathname; + const pathParts = path.split("/"); + + let matchedLen = -1, + matchedValue: DeserializationHelper | undefined; + + for (const [key, value] of Object.entries(deserializeMap)) { + if (!key.startsWith(method)) { + continue; + } + const candidatePath = getPathFromMapKey(key); + const candidateParts = candidatePath.split("/"); + + let found = true; + for ( + let i = candidateParts.length - 1, j = pathParts.length - 1; + i >= 1 && j >= 1; + i--, j-- + ) { + if ( + candidateParts[i]?.startsWith("{") && + candidateParts[i]?.indexOf("}") !== -1 + ) { + const start = candidateParts[i]!.indexOf("}") + 1, + end = candidateParts[i]?.length; + const isMatched = new RegExp( + \`\${candidateParts[i]?.slice(start, end)}\` + ).test(pathParts[j] || ""); + + if (!isMatched) { + found = false; + break; + } + continue; + } + + if (candidateParts[i] !== pathParts[j]) { + found = false; + break; + } + } + + if (found && candidatePath.length > matchedLen) { + matchedLen = candidatePath.length; + matchedValue = value; + } + } + + return matchedValue; + } + + function getPathFromMapKey(mapKey: string): string { + const pathStart = mapKey.indexOf("/"); + return mapKey.slice(pathStart); + } + + function getApiVersionFromUrl(urlStr: string): string | undefined { + const url = new URL(urlStr); + return url.searchParams.get("api-version") ?? undefined; + } + `); + + file.fixMissingImports({}, { importModuleSpecifierEnding: "js" }); + file.fixUnusedIdentifiers(); + return file; +} diff --git a/packages/typespec-ts/src/codegen/models.ts b/packages/typespec-ts/src/codegen/models.ts new file mode 100644 index 0000000000..f88d3f4f41 --- /dev/null +++ b/packages/typespec-ts/src/codegen/models.ts @@ -0,0 +1,205 @@ +import { Project, SourceFile } from "ts-morph"; +import type { TSCodeModel, TSEnum } from "../codemodel/index.js"; +import type { SdkContext } from "../utils/interfaces.js"; +import { addDeclaration } from "../framework/declaration.js"; +import { refkey } from "../framework/refkey.js"; +import { buildHelperTypeLookup } from "../tcgcadapter/helperTypes.js"; +import { + addSerializationFunctions, + buildEnumTypes, + emitType, + getModelNamespaces, + getModelsPath +} from "../modular/emitModels.js"; + +/** + * Emit model, enum, and union files from the code model. + * + * Serializers stay on the legacy helpers for now, but model selection comes + * from the filtered IR rather than the global TCGC emit queue. + */ +export function emitModelFiles( + project: Project, + codeModel: TSCodeModel, + sdkContext: SdkContext +): SourceFile[] { + const rawModelLookup = buildRawTypeLookup( + sdkContext.sdkPackage.models, + sdkContext + ); + const rawEnumLookup = buildRawTypeLookup( + sdkContext.sdkPackage.enums, + sdkContext + ); + const rawUnionLookup = buildRawTypeLookup( + sdkContext.sdkPackage.unions, + sdkContext + ); + const rawHelperLookup = buildHelperTypeLookup(sdkContext); + const includedModels: Array<{ properties: any[] }> = []; + + for (const model of codeModel.models) { + const rawModel = rawModelLookup.get( + getTypeKey(model.name, model.namespace) + ); + if (!rawModel) { + continue; + } + + includedModels.push(rawModel as { properties: any[] }); + const sourceFile = getOrCreateModelsFile( + project, + codeModel.settings.sourceRoot, + model.namespace + ); + emitType(sdkContext, rawModel, sourceFile); + } + + for (const enumType of codeModel.enums) { + const rawEnum = rawEnumLookup.get( + getTypeKey(enumType.name, enumType.namespace) + ); + if (!rawEnum) { + continue; + } + + const sourceFile = getOrCreateModelsFile( + project, + codeModel.settings.sourceRoot, + enumType.namespace + ); + emitEnumFromCodeModel(sdkContext, enumType, rawEnum as any, sourceFile); + } + + for (const unionType of codeModel.unions) { + const rawUnion = rawUnionLookup.get( + getTypeKey(unionType.name, unionType.namespace) + ); + if (!rawUnion) { + continue; + } + + const sourceFile = getOrCreateModelsFile( + project, + codeModel.settings.sourceRoot, + unionType.namespace + ); + emitType(sdkContext, rawUnion, sourceFile); + } + + for (const helperType of codeModel.helperTypes) { + const rawHelperType = rawHelperLookup.get(helperType.id); + if (!rawHelperType) { + continue; + } + + const sourceFile = getOrCreateModelsFile( + project, + codeModel.settings.sourceRoot, + helperType.namespace + ); + emitType(sdkContext, rawHelperType, sourceFile); + } + + for (const rawModel of includedModels) { + for (const property of rawModel.properties) { + if (!property.flatten || property.type.kind !== "model") { + continue; + } + + const sourceFile = getOrCreateModelsFile( + project, + codeModel.settings.sourceRoot, + getModelNamespaces(sdkContext, property.type) + ); + addSerializationFunctions(sdkContext, property, sourceFile); + } + } + + return cleanupEmptyModelFiles(project, codeModel.settings.sourceRoot); +} + +function buildRawTypeLookup( + types: readonly T[], + sdkContext: SdkContext +): Map { + return new Map( + types.map((type) => [getRawTypeKey(type, sdkContext), type] as const) + ); +} + +function getRawTypeKey(type: { name: string }, sdkContext: SdkContext): string { + return getTypeKey(type.name, getModelNamespaces(sdkContext, type as any)); +} + +function getTypeKey(name: string, namespace: string[]): string { + return `${namespace.join("/")}:${name}`; +} + +function emitEnumFromCodeModel( + sdkContext: SdkContext, + enumType: TSEnum, + rawEnum: any, + sourceFile: SourceFile +): void { + const [enumDeclaration, knownValuesEnum] = buildEnumTypes( + sdkContext, + rawEnum, + false, + enumType.isExtensible + ); + + if (enumDeclaration.name.startsWith("_")) { + return; + } + + if (enumType.isExtensible) { + addDeclaration(sourceFile, knownValuesEnum, refkey(rawEnum, "knownValues")); + } + + addDeclaration(sourceFile, enumDeclaration, rawEnum); +} + +function getOrCreateModelsFile( + project: Project, + sourceRoot: string, + namespace: string[] = [] +): SourceFile { + const filePath = getModelsPath(sourceRoot, namespace); + let sourceFile = project.getSourceFile(filePath); + if (!sourceFile) { + sourceFile = project.createSourceFile(filePath); + sourceFile.addStatements(`/** + * This file contains only generated model types and their (de)serializers. + * Disable the following rules for internal models with '_' prefix and deserializers which require 'any' for raw JSON input. + */ +/* eslint-disable @typescript-eslint/naming-convention */ +/* eslint-disable @typescript-eslint/explicit-module-boundary-types */`); + } + + return sourceFile; +} + +function cleanupEmptyModelFiles( + project: Project, + sourceRoot: string +): SourceFile[] { + const result: SourceFile[] = []; + + for (const modelFile of project.getSourceFiles( + `${sourceRoot}/models/**/*.ts` + )) { + if ( + modelFile.getInterfaces().length === 0 && + modelFile.getTypeAliases().length === 0 && + modelFile.getEnums().length === 0 + ) { + project.removeSourceFile(modelFile); + continue; + } + + result.push(modelFile); + } + + return result; +} diff --git a/packages/typespec-ts/src/codegen/operations.ts b/packages/typespec-ts/src/codegen/operations.ts new file mode 100644 index 0000000000..08f699b857 --- /dev/null +++ b/packages/typespec-ts/src/codegen/operations.ts @@ -0,0 +1,133 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { NameType, normalizeName } from "@azure-tools/rlc-common"; +import { + FunctionDeclarationStructure, + Project, + SourceFile, + StructureKind +} from "ts-morph"; +import type { + TSClient, + TSFunctionDeclaration, + TSGenerationSettings, + TSOperationGroup +} from "../codemodel/index.js"; +import { addDeclaration } from "../framework/declaration.js"; +import { dedupePagedAsyncIterableIteratorImports } from "./pagingImports.js"; + +/** + * Emit modular operation source files from the TypeScript code model. + * + * Each operation group produces an operation file under `api/` containing the + * public operation function plus its private send/deserialize helpers. + */ +export function emitOperations( + project: Project, + client: TSClient, + settings: TSGenerationSettings +): SourceFile[] { + const subfolder = client.path.join("/"); + + return getOperationGroups(client).map((group) => { + const filePath = `${settings.sourceRoot}/${ + subfolder && subfolder !== "" ? subfolder + "/" : "" + }api/${getOperationFileName(group)}.ts`; + const file = project.createSourceFile(filePath, "", { + overwrite: true + }); + + for (const method of group.methods) { + file.addFunctions(getHelperFunctions(method)); + addDeclaration( + file, + toFunctionDeclaration(method.apiFunction), + method.apiRefKey + ); + } + + const indexPathPrefix = "../".repeat(group.prefixes.length) || "./"; + file.addImportDeclaration({ + namedImports: [`${client.contextTypeName} as Client`], + moduleSpecifier: `${indexPathPrefix}index.js` + }); + file.fixMissingImports({}, { importModuleSpecifierEnding: "js" }); + dedupePagedAsyncIterableIteratorImports(file); + file.fixUnusedIdentifiers(); + + return file; + }); +} + +function getOperationGroups(client: TSClient): TSOperationGroup[] { + const groups: TSOperationGroup[] = []; + + if (client.methods.length > 0) { + groups.push({ + name: "", + prefixes: [], + methods: client.methods + }); + } + + groups.push(...client.operationGroups); + return groups.sort((left, right) => { + const leftFileName = getOperationFileName(left); + const rightFileName = getOperationFileName(right); + return leftFileName.localeCompare(rightFileName); + }); +} + +function getOperationFileName(group: TSOperationGroup): string { + if (group.prefixes.length === 0) { + return "operations"; + } + + return `${group.prefixes + .map((prefix) => normalizeName(prefix, NameType.File)) + .join("/")}/operations`; +} + +function getHelperFunctions(method: { + sendFunction: TSFunctionDeclaration; + deserializeFunction: TSFunctionDeclaration; + deserializeHeadersFunction?: TSFunctionDeclaration; + deserializeExceptionHeadersFunction?: TSFunctionDeclaration; +}): FunctionDeclarationStructure[] { + return [ + method.sendFunction, + method.deserializeFunction, + method.deserializeHeadersFunction, + method.deserializeExceptionHeadersFunction + ] + .filter( + (declaration): declaration is TSFunctionDeclaration => + declaration !== undefined + ) + .map(toFunctionDeclaration); +} + +function toFunctionDeclaration( + declaration: TSFunctionDeclaration +): FunctionDeclarationStructure { + return { + kind: StructureKind.Function as const, + docs: declaration.docs, + isAsync: declaration.isAsync, + isExported: declaration.isExported, + name: declaration.name, + returnType: declaration.returnType, + parameters: declaration.parameters.map((parameter) => ({ + name: parameter.name, + type: parameter.type, + initializer: parameter.initializer, + hasQuestionToken: parameter.hasQuestionToken, + docs: parameter.docs + })), + statements: declaration.statements, + ...(declaration.propertyName + ? { propertyName: declaration.propertyName } + : {}) + }; +} diff --git a/packages/typespec-ts/src/codegen/pagingImports.ts b/packages/typespec-ts/src/codegen/pagingImports.ts new file mode 100644 index 0000000000..afa2c56f94 --- /dev/null +++ b/packages/typespec-ts/src/codegen/pagingImports.ts @@ -0,0 +1,46 @@ +import { SourceFile } from "ts-morph"; + +const pagedAsyncIterableIteratorName = "PagedAsyncIterableIterator"; + +export function dedupePagedAsyncIterableIteratorImports( + file: SourceFile +): void { + const hasPagingHelpersImport = file + .getImportDeclarations() + .some( + (declaration) => + declaration + .getModuleSpecifierValue() + ?.includes("static-helpers/pagingHelpers.js") && + declaration + .getNamedImports() + .some( + (namedImport) => + namedImport.getName() === pagedAsyncIterableIteratorName + ) + ); + + if (!hasPagingHelpersImport) { + return; + } + + for (const declaration of file.getImportDeclarations()) { + if (!declaration.getModuleSpecifierValue()?.endsWith("index.js")) { + continue; + } + + for (const namedImport of declaration.getNamedImports()) { + if (namedImport.getName() === pagedAsyncIterableIteratorName) { + namedImport.remove(); + } + } + + if ( + declaration.getNamedImports().length === 0 && + !declaration.getDefaultImport() && + !declaration.getNamespaceImport() + ) { + declaration.remove(); + } + } +} diff --git a/packages/typespec-ts/src/codegen/responseTypes.ts b/packages/typespec-ts/src/codegen/responseTypes.ts new file mode 100644 index 0000000000..f6ed4cc04b --- /dev/null +++ b/packages/typespec-ts/src/codegen/responseTypes.ts @@ -0,0 +1,81 @@ +import { + Project, + StructureKind, + TypeAliasDeclarationStructure +} from "ts-morph"; +import type { + TSClient, + TSGenerationSettings, + TSMethod, + TSResponseTypeAlias +} from "../codemodel/index.js"; +import { addDeclaration } from "../framework/declaration.js"; +import { resolveReference } from "../framework/reference.js"; +import { PlatformTypeHelpers } from "../modular/static-helpers-metadata.js"; +import { getModelsPath } from "../modular/emitModels.js"; + +export function emitResponseTypes( + project: Project, + clients: TSClient[], + settings: TSGenerationSettings +): void { + const responseTypes = getAllMethods(clients) + .map((method) => method.responseTypeAlias) + .filter((alias): alias is TSResponseTypeAlias => alias !== undefined); + + if (responseTypes.length === 0) { + return; + } + + const modelsFile = + project.getSourceFile(getModelsPath(settings.sourceRoot)) ?? + project.createSourceFile(getModelsPath(settings.sourceRoot)); + + for (const responseType of responseTypes) { + addDeclaration( + modelsFile, + buildResponseTypeDeclaration(responseType), + responseType.refKey + ); + } +} + +function getAllMethods(clients: TSClient[]): TSMethod[] { + return clients.flatMap((client) => [ + ...client.methods, + ...client.operationGroups.flatMap((group) => group.methods), + ...getAllMethods(client.children) + ]); +} + +function buildResponseTypeDeclaration( + responseType: TSResponseTypeAlias +): TypeAliasDeclarationStructure { + const typeBody = + responseType.kind === "binary" + ? `{ + /** + * BROWSER ONLY + * + * The response body as a browser Blob. + * Always \`undefined\` in node.js. + */ + blobBody?: Promise; + /** + * NODEJS ONLY + * + * The response body as a node.js Readable stream. + * Always \`undefined\` in the browser. + */ + readableStreamBody?: ${resolveReference(PlatformTypeHelpers.NodeReadableStream)}; + }` + : `{ body: ${responseType.bodyType ?? "never"} }`; + + return { + kind: StructureKind.TypeAlias, + name: responseType.name, + type: typeBody, + isExported: true, + leadingTrivia: "\n" + }; +} diff --git a/packages/typespec-ts/src/codemodel/index.ts b/packages/typespec-ts/src/codemodel/index.ts new file mode 100644 index 0000000000..6cff039264 --- /dev/null +++ b/packages/typespec-ts/src/codemodel/index.ts @@ -0,0 +1,480 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * TypeScript Code Model — Language-specific intermediate representation. + * + * This is the TypeScript equivalent of Go's `CodeModel` and Rust's `Crate`. + * It represents the complete target client library as a tree of + * language-specific data. All TCGC interpretation happens in the adapter + * (Phase 1) — the code model is TCGC-free. All rendering happens in + * codegen (Phase 3) — the code model is ts-morph-free. + * + * The code model is: + * - Self-contained: no external dependencies, no global hooks + * - Snapshot-testable: pure data, can be serialized/compared + * - Renderer-agnostic: consumed by ts-morph codegen today, Alloy.js tomorrow + */ + +// ─── Code Model Root ────────────────────────────────────────────────── + +/** + * Root of the TypeScript code model. Contains everything needed to + * generate a complete TypeScript client library. + * + * Analogous to Go's `CodeModel` and Rust's `Crate`. + */ +export interface TSCodeModel { + /** All clients in the package (may be hierarchical) */ + clients: TSClient[]; + + /** Named model/interface declarations */ + models: TSModel[]; + + /** Named enum declarations */ + enums: TSEnum[]; + + /** Named union declarations */ + unions: TSUnion[]; + + /** Helper wrapper types that still need legacy addDeclaration registration */ + helperTypes: TSHelperType[]; + + /** Generation settings derived from emitter options */ + settings: TSGenerationSettings; +} + +export type TSHelperTypeKind = "array" | "dict" | "nullable"; + +export interface TSHelperType { + /** Stable semantic ID used to recover the raw helper type during transition */ + id: string; + /** Helper kind — determines how the legacy renderer registers declarations */ + kind: TSHelperTypeKind; + /** Display name for diagnostics/debugging */ + name: string; + /** Namespace segments for file placement */ + namespace: string[]; + /** Wrapped element/value type */ + elementType: TSTypeReference; + /** Whether this helper is a named nullable alias */ + isNamedAlias: boolean; +} + +export function buildHelperTypeId( + helperType: Pick< + TSHelperType, + "kind" | "name" | "namespace" | "elementType" | "isNamedAlias" + > +): string { + return [ + helperType.namespace.join("/"), + helperType.kind, + helperType.name, + helperType.elementType, + helperType.isNamedAlias ? "named" : "generated" + ].join(":"); +} + +/** Normalized generation settings (not raw emitter options) */ +export interface TSGenerationSettings { + flavor: "azure" | "unbranded"; + isArm: boolean; + sourceRoot: string; + packageName?: string; + packageVersion?: string; + addCredentials: boolean; + credentialScopes?: string[]; + credentialKeyHeaderName?: string; + customHttpAuthHeaderName?: string; + customHttpAuthSharedKeyPrefix?: string; + compatibilityLro?: boolean; + isMultiService?: boolean; + hierarchyClient?: boolean; +} + +// ─── Client ─────────────────────────────────────────────────────────── + +/** + * A client in the TypeScript SDK. + * + * Represents both the "modular client context" (factory function + + * context interface) and the "classical client" (class wrapper). + */ +export interface TSClient { + /** Stable semantic ID for cross-referencing */ + id: string; + + /** Classical client name (e.g., "FooClient") */ + name: string; + + /** Modular client name (e.g., "Foo") */ + modularName: string; + + /** RLC context type name (e.g., "FooContext") */ + contextTypeName: string; + + /** Client documentation */ + docs: string[]; + + /** Client hierarchy path (e.g., ["Storage", "Blob"]) */ + path: string[]; + + /** Endpoint configuration */ + endpoint: TSEndpointConfig; + + /** Credential configuration */ + credential: TSCredentialConfig; + + /** All client initialization parameters (from TCGC clientInitialization) */ + parameters: TSClientParameter[]; + + /** API version configuration */ + apiVersion?: TSApiVersionConfig; + + /** Operation methods on this client */ + methods: TSMethod[]; + + /** Named operation groups (non-empty prefix key) */ + operationGroups: TSOperationGroup[]; + + /** Generated operation options files under the api/ tree */ + apiOptions: TSApiOptions[]; + + /** Restore-poller helper metadata when compatibility LROs are enabled */ + lroConfig?: TSLroConfig; + + /** Child clients (hierarchical client pattern) */ + children: TSClient[]; + + /** Whether children are initialized by parent */ + hasParentInitializedChildren: boolean; + + /** Whether ARM subscriptionId overloads should be emitted */ + allowOptionalSubscriptionId: boolean; + + /** Whether operation helper declarations use a namespaced client type */ + usesNamespacedContextType: boolean; +} + +// ─── Endpoint Configuration ─────────────────────────────────────────── + +export interface TSEndpointConfig { + /** Whether the endpoint is parameterized (has template variables) */ + isParameterized: boolean; + + /** Server URL template (e.g., "{endpoint}/api/v1") */ + serverUrl?: string; + + /** Template parameters in the endpoint URL */ + templateParameters: TSEndpointTemplateParam[]; + + /** Whether to use ARM cloud endpoint resolution */ + useArmCloudEndpoint: boolean; +} + +export interface TSEndpointTemplateParam { + name: string; + clientDefaultValue?: unknown; + isOptional: boolean; + /** The raw TCGC param name (for URL template replacement) */ + tcgcName: string; +} + +// ─── Credential Configuration ───────────────────────────────────────── + +export interface TSCredentialConfig { + /** Whether credentials are used */ + hasCredentials: boolean; + /** The parameter name for the credential (e.g., "credential") */ + parameterName: string; +} + +// ─── API Version Configuration ──────────────────────────────────────── + +export interface TSApiVersionConfig { + /** Parameter name (e.g., "apiVersion") */ + parameterName: string; + /** Whether the API version is embedded in the endpoint template */ + isInEndpointTemplate: boolean; + /** Default value if not in endpoint */ + clientDefaultValue?: unknown; + /** Known values enum name (if versioned) */ + knownValuesEnumName?: string; +} + +// ─── Parameters ─────────────────────────────────────────────────────── + +export interface TSClientParameter { + /** Parameter name (normalized to TypeScript conventions) */ + name: string; + /** TypeScript type expression */ + type: string; + /** Whether this parameter is required */ + required: boolean; + /** Whether this parameter has a default value */ + hasDefaultValue: boolean; + /** Default value expression */ + defaultValue?: unknown; + /** Parameter documentation */ + docs: string[]; + /** Whether this is the API version parameter */ + isApiVersion: boolean; + /** Whether this is the endpoint parameter */ + isEndpoint: boolean; + /** Whether this is the credential parameter */ + isCredential: boolean; +} + +// ─── Methods / Operations ───────────────────────────────────────────── + +export type TSMethodKind = "basic" | "lro" | "paging" | "lroPaging"; + +export type TSParameterLocation = "query" | "header" | "path" | "body"; + +export interface TSFunctionParameter { + name: string; + type?: string; + initializer?: string; + hasQuestionToken?: boolean; + docs?: string[]; +} + +export interface TSFunctionDeclaration { + name: string; + docs?: string[]; + isAsync?: boolean; + isExported?: boolean; + propertyName?: string; + returnType?: string; + parameters: TSFunctionParameter[]; + statements?: string | string[]; +} + +/** + * An operation method on a client. This is a plain data view of the + * operation shape that modular rendering currently derives from TCGC. + */ +export interface TSMethod { + /** Stable semantic ID */ + id: string; + /** Method name for the classical client */ + name: string; + /** Original operation name before operation-group prefixing */ + originalName?: string; + /** Binder refkey for the public api function */ + apiRefKey: string; + /** Operation kind */ + kind: TSMethodKind; + /** Summary/description from the operation doc comment */ + description?: string; + /** HTTP method for the request */ + httpMethod: string; + /** HTTP route info */ + route: TSRoute; + /** Operation parameters */ + parameters: TSParameter[]; + /** Method return type */ + returnType: TSReturnType; + /** Non-model response alias metadata when the operation wraps its return type */ + responseTypeAlias?: TSResponseTypeAlias; + /** Public api function declaration */ + apiFunction: TSFunctionDeclaration; + /** Private send helper declaration */ + sendFunction: TSFunctionDeclaration; + /** Private deserialize helper declaration */ + deserializeFunction: TSFunctionDeclaration; + /** Optional response headers helper declaration */ + deserializeHeadersFunction?: TSFunctionDeclaration; + /** Optional exception headers helper declaration */ + deserializeExceptionHeadersFunction?: TSFunctionDeclaration; + /** Compatibility LRO final return type for deprecated helpers */ + compatibilityLroReturnType?: string; + /** Compatibility LRO paging return type for deprecated helpers */ + compatibilityLroPagingReturnType?: string; +} + +export interface TSParameter { + name: string; + type: string; + optional: boolean; + defaultValue?: unknown; + httpLocation: TSParameterLocation; +} + +export interface TSReturnType { + /** Full TypeScript type expression returned by the method */ + type: string; + /** Whether the logical payload/result type is nullable */ + nullable: boolean; + /** Whether the logical payload/result type is void */ + isVoid: boolean; +} + +export interface TSResponseTypeAlias { + name: string; + refKey: string; + kind: "binary" | "body" | "headAsBoolean"; + bodyType?: string; +} + +export interface TSRoute { + pathTemplate: string; + verb: string; +} + +// ─── Operation Groups ───────────────────────────────────────────────── + +export interface TSOperationGroup { + /** Group name (normalized) */ + name: string; + /** Prefix keys for hierarchical grouping */ + prefixes: string[]; + /** Operations in this group */ + methods: TSMethod[]; +} + +export interface TSApiOptions { + /** Prefix keys for the api/options file path */ + prefixes: string[]; + /** Operation option interfaces emitted into this file */ + interfaces: TSApiOptionsInterface[]; +} + +export interface TSApiOptionsInterface { + /** TypeScript interface name */ + name: string; + /** Binder refkey for import resolution */ + refKey: string; + /** Interface properties */ + properties: TSApiOptionsProperty[]; +} + +export interface TSApiOptionsProperty { + name: string; + type: string; + docs: string[]; +} + +export interface TSLroConfig { + /** Classical client type accepted by restorePoller */ + clientName: string; + /** Deserialization helpers indexed by operation path */ + deserializers: TSLroDeserializer[]; +} + +export interface TSLroDeserializer { + /** Import path for the deserialize helper */ + moduleSpecifier: string; + /** Exported helper name */ + exportName: string; + /** Local alias used when duplicate helper names exist */ + localName: string; + /** HTTP method + route key */ + path: string; + /** Expected status expression emitted into the helper map */ + expectedStatusesExpression: string; +} + +// ─── Models / Types ───────────────────────────────────────────────────── + +export type TSTypeReference = string; + +export interface TSModel { + /** Stable semantic ID */ + id: string; + /** TypeScript model/interface name */ + name: string; + /** Relative namespace segments used for file placement */ + namespace: string[]; + /** Model documentation */ + docs: string[]; + /** Direct model properties */ + properties: TSProperty[]; + /** Base model reference for inheritance */ + baseType?: TSTypeReference; + /** Additional properties bag value type */ + additionalPropertiesType?: TSTypeReference; + /** Polymorphism metadata */ + discriminator?: TSDiscriminator; +} + +export interface TSProperty { + /** TypeScript property name */ + name: string; + /** Referenced TypeScript type */ + type: TSTypeReference; + /** Whether the property is optional */ + optional: boolean; + /** Whether the property is readonly */ + readonly: boolean; + /** Serialized wire name */ + serializedName?: string; + /** Whether the property is a discriminator */ + isDiscriminator: boolean; + /** Whether the property is flattened in serialization */ + isFlattened: boolean; +} + +export interface TSDiscriminator { + /** TypeScript discriminator property name */ + propertyName: string; + /** Wire name used during serialization */ + serializedName?: string; + /** Discriminator value for derived types */ + value?: string; + /** Known derived model type names */ + derivedTypes: TSTypeReference[]; +} + +export interface TSEnum { + /** Stable semantic ID */ + id: string; + /** TypeScript enum alias name */ + name: string; + /** Relative namespace segments used for file placement */ + namespace: string[]; + /** Enum documentation */ + docs: string[]; + /** Enum members */ + members: TSEnumMember[]; + /** Whether the enum is fixed/exhaustive */ + isFixed: boolean; + /** Whether the enum is extensible/non-exhaustive */ + isExtensible: boolean; + /** Underlying value type */ + valueType: TSTypeReference; +} + +export interface TSEnumMember { + name: string; + value: string | number; +} + +export interface TSUnion { + /** Stable semantic ID */ + id: string; + /** TypeScript union alias name */ + name: string; + /** Relative namespace segments used for file placement */ + namespace: string[]; + /** Union documentation */ + docs: string[]; + /** Union variants */ + variants: TSUnionVariant[]; + /** Discriminator metadata when present */ + discriminator?: TSUnionDiscriminator; +} + +export interface TSUnionVariant { + /** Variant label when declared in TypeSpec */ + name?: string; + /** Variant type reference */ + type: TSTypeReference; +} + +export interface TSUnionDiscriminator { + propertyName: string; + envelope: "object" | "none"; + envelopePropertyName?: string; +} diff --git a/packages/typespec-ts/src/index.ts b/packages/typespec-ts/src/index.ts index 35ed204f33..290bcd7186 100644 --- a/packages/typespec-ts/src/index.ts +++ b/packages/typespec-ts/src/index.ts @@ -69,26 +69,28 @@ import { buildSnippets, buildTsSampleConfig } from "@azure-tools/rlc-common"; -import { - buildRootIndex, - buildSubClientIndexFile -} from "./modular/buildRootIndex.js"; import { emitContentByBuilder, emitModels } from "./utils/emitUtil.js"; import { provideContext, useContext } from "./contextManager.js"; import { EmitterOptions } from "./lib.js"; import { ModularEmitterOptions } from "./modular/interfaces.js"; import { Project } from "ts-morph"; -import { buildClassicOperationFiles } from "./modular/buildClassicalOperationGroups.js"; -import { buildClassicalClient } from "./modular/buildClassicalClient.js"; +import { getClientContextPath } from "./modular/buildClientContext.js"; +import { adaptSingleClient, adaptToCodeModel } from "./tcgcadapter/adapter.js"; +import { emitClassicalClient } from "./codegen/classicalClient.js"; +import { emitClassicalOperationFiles } from "./codegen/classicalOperations.js"; +import { emitClientContext } from "./codegen/clients.js"; import { - getClientContextPath, - buildClientContext -} from "./modular/buildClientContext.js"; + emitRootIndex, + emitSubClientIndex, + emitSubpathIndexFiles +} from "./codegen/indexFiles.js"; +import { emitOperations } from "./codegen/operations.js"; +import { emitModelFiles } from "./codegen/models.js"; +import { emitResponseTypes } from "./codegen/responseTypes.js"; +import { dedupePagedAsyncIterableIteratorImports } from "./codegen/pagingImports.js"; import { buildApiOptions } from "./modular/emitModelsOptions.js"; -import { buildOperationFiles } from "./modular/buildOperations.js"; import { buildRestorePoller } from "./modular/buildRestorePoller.js"; -import { buildSubpathIndexFile } from "./modular/buildSubpathIndex.js"; import { createSdkContext, listAllServiceNamespaces, @@ -97,7 +99,6 @@ import { } from "@azure-tools/typespec-client-generator-core"; import { transformModularEmitterOptions } from "./modular/buildModularOptions.js"; import { emitLoggerFile } from "./modular/emitLoggerFile.js"; -import { emitTypes, emitNonModelResponseTypes } from "./modular/emitModels.js"; import { existsSync } from "fs"; import { getModuleExports } from "./modular/buildProjectFiles.js"; import { @@ -336,6 +337,12 @@ export async function $onEmit(context: EmitContext) { emitLoggerFile(modularEmitterOptions, modularSourcesRoot); + const codeModel = adaptToCodeModel({ + sdkContext: dpgContext, + emitterOptions: modularEmitterOptions + }); + const generationSettings = codeModel.settings; + const rootIndexFile = project.createSourceFile( `${modularSourcesRoot}/index.ts`, "", @@ -344,51 +351,51 @@ export async function $onEmit(context: EmitContext) { } ); - emitTypes(dpgContext, { sourceRoot: modularSourcesRoot }); - emitNonModelResponseTypes(dpgContext, { sourceRoot: modularSourcesRoot }); - buildSubpathIndexFile(modularEmitterOptions, "models", undefined, { + emitModelFiles(project, codeModel, dpgContext); + emitResponseTypes(project, codeModel.clients, generationSettings); + const clientMap = getClientHierarchyMap(dpgContext); + emitSubpathIndexFiles(project, generationSettings, "models", undefined, { recursive: true }); - const clientMap = getClientHierarchyMap(dpgContext); if (clientMap.length === 0) { // If no clients, we still need to build the root index file - buildRootIndex(dpgContext, modularEmitterOptions, rootIndexFile); + emitRootIndex(project, generationSettings, rootIndexFile); } for (const subClient of clientMap) { await renameClientName(subClient[1], modularEmitterOptions); buildApiOptions(dpgContext, subClient, modularEmitterOptions); - buildOperationFiles(dpgContext, subClient, modularEmitterOptions); - buildClientContext(dpgContext, subClient, modularEmitterOptions); + const tsClient = adaptSingleClient( + subClient, + dpgContext, + modularEmitterOptions + ); + emitOperations(project, tsClient, generationSettings); + emitClientContext(project, tsClient, generationSettings); buildRestorePoller(dpgContext, subClient, modularEmitterOptions); if (dpgContext.rlcOptions?.hierarchyClient) { - buildSubpathIndexFile(modularEmitterOptions, "api", subClient, { + emitSubpathIndexFiles(project, generationSettings, "api", tsClient, { exportIndex: false, recursive: true }); } else { - buildSubpathIndexFile(modularEmitterOptions, "api", subClient, { + emitSubpathIndexFiles(project, generationSettings, "api", tsClient, { recursive: true, exportIndex: true }); } - buildClassicalClient(dpgContext, subClient, modularEmitterOptions); - buildClassicOperationFiles(dpgContext, subClient, modularEmitterOptions); - buildSubpathIndexFile(modularEmitterOptions, "classic", subClient, { + emitClassicalClient(project, tsClient, generationSettings); + emitClassicalOperationFiles(project, tsClient, generationSettings); + emitSubpathIndexFiles(project, generationSettings, "classic", tsClient, { exportIndex: true, interfaceOnly: true }); const { subfolder } = getModularClientOptions(subClient); // Generate index file for clients with subfolders (multi-client scenarios and nested clients) if (subfolder) { - buildSubClientIndexFile(dpgContext, subClient, modularEmitterOptions); + emitSubClientIndex(project, generationSettings, tsClient); } - buildRootIndex( - dpgContext, - modularEmitterOptions, - rootIndexFile, - subClient - ); + emitRootIndex(project, generationSettings, rootIndexFile, tsClient); } // Enable modular sample generation when explicitly set to true or MPG if (emitterOptions["generate-sample"] === true) { @@ -408,6 +415,10 @@ export async function $onEmit(context: EmitContext) { return; } + for (const file of project.getSourceFiles()) { + dedupePagedAsyncIterableIteratorImports(file); + } + for (const file of project.getSourceFiles()) { await emitContentByBuilder( program, diff --git a/packages/typespec-ts/src/modular/emitModels.ts b/packages/typespec-ts/src/modular/emitModels.ts index 34d59c29be..c6c240aa8b 100644 --- a/packages/typespec-ts/src/modular/emitModels.ts +++ b/packages/typespec-ts/src/modular/emitModels.ts @@ -211,7 +211,11 @@ export function emitNonModelResponseTypes( } } -function emitType(context: SdkContext, type: SdkType, sourceFile: SourceFile) { +export function emitType( + context: SdkContext, + type: SdkType, + sourceFile: SourceFile +) { if (type.kind === "model") { if (isAzureCoreErrorType(context.program, type.__raw)) { return; @@ -441,7 +445,7 @@ function getModelAndAncestorProperties( return properties; } -function addSerializationFunctions( +export function addSerializationFunctions( context: SdkContext, typeOrProperty: SdkType | SdkModelPropertyType, sourceFile: SourceFile, @@ -597,7 +601,8 @@ function buildNullableType(context: SdkContext, type: SdkNullableType) { export function buildEnumTypes( context: SdkContext, type: SdkEnumType, - reportMemberNameDiagnostic = false // if reportMemberNameDiagnostic is true, it will report diagnostic for enum member name + reportMemberNameDiagnostic = false, // if reportMemberNameDiagnostic is true, it will report diagnostic for enum member name + treatAsExtensible = isExtensibleEnum(context, type) ): [TypeAliasDeclarationStructure, EnumDeclarationStructure] { const rawMembers = type.values.map((value) => emitEnumMember(context, value, reportMemberNameDiagnostic) @@ -613,14 +618,14 @@ export function buildEnumTypes( kind: StructureKind.TypeAlias, name: normalizeModelName(context, type), isExported: true, - type: !isExtensibleEnum(context, type) + type: !treatAsExtensible ? type.values.map((v) => getTypeExpression(context, v)).join(" | ") : getTypeExpression(context, type.valueType) }; const docs = type.doc ? type.doc : "Type of " + enumAsUnion.name; enumAsUnion.docs = - isExtensibleEnum(context, type) && type.doc + treatAsExtensible && type.doc ? [getExtensibleEnumDescription(context, type) ?? docs] : [docs]; enumDeclaration.docs = type.doc diff --git a/packages/typespec-ts/src/tcgcadapter/adapter.ts b/packages/typespec-ts/src/tcgcadapter/adapter.ts new file mode 100644 index 0000000000..72035d7f3a --- /dev/null +++ b/packages/typespec-ts/src/tcgcadapter/adapter.ts @@ -0,0 +1,1232 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * TCGC Adapter — transforms TCGC's language-neutral SDK model into + * the TypeScript-specific code model (TSCodeModel). + * + * This is the TypeScript equivalent of: + * - Go's `tcgcadapter/adapter.ts` → `tcgcToGoCodeModel()` + * - Rust's `tcgcadapter/adapter.ts` → `tcgcToCrate()` + * + * This is the ONLY layer that imports TCGC types. The code model and + * codegen layers have zero TCGC knowledge. + * + * The adapter receives all dependencies explicitly — no global hooks. + */ + +import type { + SdkBodyParameter, + SdkClientType, + SdkEnumType, + SdkHttpParameter, + SdkMethodParameter, + SdkModelPropertyType, + SdkModelType, + SdkServiceOperation, + SdkType, + SdkUnionType +} from "@azure-tools/typespec-client-generator-core"; +import { + InitializedByFlags, + UsageFlags, + isReadOnly +} from "@azure-tools/typespec-client-generator-core"; +import { NameType, normalizeName } from "@azure-tools/rlc-common"; +import type { SdkContext } from "../utils/interfaces.js"; +import type { ModularEmitterOptions } from "../modular/interfaces.js"; +import { + getClientName, + getClassicalClientName, + getOperationName +} from "../modular/helpers/namingHelpers.js"; +import { getDocsFromDescription } from "../modular/helpers/docsHelpers.js"; +import { getTypeExpression } from "../modular/type-expressions/get-type-expression.js"; +import { + getModularClientOptions, + getClientHierarchyMap, + isRLCMultiEndpoint +} from "../utils/clientUtils.js"; +import { + getClientParameters, + getClientParameterName, + buildGetClientCredentialParam +} from "../modular/helpers/clientHelpers.js"; +import { + getApiVersionEnum, + buildEnumTypes, + getModelNamespaces +} from "../modular/emitModels.js"; +import { adaptHelperTypes } from "./helperTypes.js"; +import { + getMethodHierarchiesMap, + hasDualFormatSupport, + isTenantLevelOperation, + type ServiceOperation +} from "../utils/operationUtil.js"; +import { + checkWrapNonModelReturn, + getDeserializeExceptionHeadersPrivateFunction, + getDeserializeHeadersPrivateFunction, + getDeserializePrivateFunction, + getExpectedStatuses, + getOperationFunction, + getOperationOptionsName, + getOperationResponseTypeName, + getPropertySerializedName, + getSendPrivateFunction, + isLroAndPagingOperation, + isLroOnlyOperation, + isPagingOnlyOperation +} from "../modular/helpers/operationHelpers.js"; +import { isTypeNullable } from "../modular/helpers/typeHelpers.js"; +import { isExtensibleEnum } from "../modular/type-expressions/get-enum-expression.js"; +import { isOrExtendsHttpFile } from "@typespec/http"; +import { isAzureCoreErrorType } from "../utils/modelUtils.js"; +import { refkey } from "../framework/refkey.js"; + +import type { + TSApiOptions, + TSApiOptionsInterface, + TSApiOptionsProperty, + TSApiVersionConfig, + TSClient, + TSClientParameter, + TSCodeModel, + TSCredentialConfig, + TSEndpointConfig, + TSEnum, + TSFunctionDeclaration, + TSGenerationSettings, + TSLroConfig, + TSLroDeserializer, + TSMethod, + TSMethodKind, + TSModel, + TSOperationGroup, + TSProperty, + TSResponseTypeAlias, + TSTypeReference, + TSUnion, + TSUnionVariant +} from "../codemodel/index.js"; + +// ─── Type alias for TCGC parameter union ────────────────────────────── + +// Used internally by parameter adapters + +// ─── Adapter Input ──────────────────────────────────────────────────── + +export interface AdapterInput { + sdkContext: SdkContext; + emitterOptions: ModularEmitterOptions; +} + +// ─── Main Adapter ───────────────────────────────────────────────────── + +/** + * Transform TCGC SDK model into a TypeScript code model. + * + * This is the single entry point for all TCGC interpretation. + * After this function returns, no TCGC types should be needed. + */ +export function adaptToCodeModel(input: AdapterInput): TSCodeModel { + const { sdkContext, emitterOptions } = input; + + const settings = adaptSettings(sdkContext, emitterOptions); + const clientMaps = getClientHierarchyMap(sdkContext); + const clients = clientMaps.map((clientMap) => + adaptClient(sdkContext, clientMap, emitterOptions, settings) + ); + const models = adaptModels(sdkContext); + const enums = adaptEnums(sdkContext); + const unions = adaptUnions(sdkContext); + const helperTypes = adaptHelperTypes(sdkContext); + + return { clients, models, enums, unions, helperTypes, settings }; +} + +/** + * Adapt a single client from a client map entry. + * Used when the emitter iterates clients individually. + */ +export function adaptSingleClient( + clientMap: [string[], SdkClientType], + sdkContext: SdkContext, + emitterOptions: ModularEmitterOptions +): TSClient { + const settings = adaptSettings(sdkContext, emitterOptions); + return adaptClient(sdkContext, clientMap, emitterOptions, settings); +} + +// ─── Settings Adapter ───────────────────────────────────────────────── + +export function adaptSettings( + sdkContext: SdkContext, + emitterOptions: ModularEmitterOptions +): TSGenerationSettings { + return { + flavor: sdkContext.rlcOptions?.flavor === "azure" ? "azure" : "unbranded", + isArm: !!sdkContext.arm, + sourceRoot: emitterOptions.modularOptions.sourceRoot, + packageName: + emitterOptions.options.packageDetails?.nameWithoutScope ?? + emitterOptions.options.packageDetails?.name, + packageVersion: emitterOptions.options.packageDetails?.version, + addCredentials: !!emitterOptions.options.addCredentials, + credentialScopes: emitterOptions.options.credentialScopes, + credentialKeyHeaderName: emitterOptions.options.credentialKeyHeaderName, + customHttpAuthHeaderName: emitterOptions.options.customHttpAuthHeaderName, + customHttpAuthSharedKeyPrefix: + emitterOptions.options.customHttpAuthSharedKeyPrefix, + compatibilityLro: sdkContext.rlcOptions?.compatibilityLro, + isMultiService: sdkContext.rlcOptions?.isMultiService, + hierarchyClient: sdkContext.rlcOptions?.hierarchyClient + }; +} + +// ─── Client Adapter ─────────────────────────────────────────────────── + +function adaptClient( + sdkContext: SdkContext, + clientMap: [string[], SdkClientType], + emitterOptions: ModularEmitterOptions, + settings: TSGenerationSettings +): TSClient { + const [hierarchy, client] = clientMap; + const name = getClassicalClientName(client); + const modularName = getClientName(client); + const { rlcClientName } = getModularClientOptions(clientMap); + + const parameters = adaptClientParameters(sdkContext, client); + const endpoint = adaptEndpoint(sdkContext, client, settings); + const credential = adaptCredential(client, emitterOptions); + const apiVersion = adaptApiVersion(sdkContext, client); + const usesNamespacedContextType = isRLCMultiEndpoint(sdkContext); + const operationClientType = usesNamespacedContextType + ? `Client.${rlcClientName}` + : "Client"; + const methods = adaptMethods(client, sdkContext, operationClientType); + const operationGroups = adaptOperationGroups( + client, + sdkContext, + operationClientType + ); + const apiOptions = adaptApiOptions(client, sdkContext); + const lroConfig = adaptLroConfig(client, sdkContext); + + const hasParentInitializedChildren = !!( + client.children && + client.children.some( + (c) => c.clientInitialization.initializedBy & InitializedByFlags.Parent + ) + ); + + const children: TSClient[] = []; + if (client.children) { + for (const childClient of client.children) { + if ( + childClient.clientInitialization.initializedBy & + InitializedByFlags.Parent + ) { + // Minimal child client representation for accessor generation + const childName = getClassicalClientName(childClient); + const childParams = adaptClientParameters(sdkContext, childClient); + children.push({ + id: `client:${childName}`, + name: childName, + modularName: getClientName(childClient), + contextTypeName: rlcClientName, + docs: getDocsFromDescription(childClient.doc), + path: [...hierarchy, childClient.name], + endpoint: adaptEndpoint(sdkContext, childClient, settings), + credential: adaptCredential(childClient, emitterOptions), + parameters: childParams, + methods: [], + operationGroups: [], + apiOptions: [], + children: [], + hasParentInitializedChildren: false, + allowOptionalSubscriptionId: shouldAllowOptionalSubscriptionId( + childClient, + sdkContext, + childParams + ), + usesNamespacedContextType + }); + } + } + } + + return { + id: `client:${name}`, + name, + modularName, + contextTypeName: rlcClientName, + docs: getDocsFromDescription(client.doc), + path: hierarchy, + endpoint, + credential, + parameters, + apiVersion, + methods, + operationGroups, + apiOptions, + lroConfig, + children, + hasParentInitializedChildren, + allowOptionalSubscriptionId: shouldAllowOptionalSubscriptionId( + client, + sdkContext, + parameters + ), + usesNamespacedContextType + }; +} + +// ─── Parameter Adapter ──────────────────────────────────────────────── + +function adaptClientParameters( + sdkContext: SdkContext, + client: SdkClientType +): TSClientParameter[] { + const allParams = getClientParameters(client, sdkContext, { + onClientOnly: false + }); + const endpointParam = getClientParameters(client, sdkContext, { + onClientOnly: true, + skipEndpointTemplate: true, + skipArmSpecific: true + }).find( + (parameter) => parameter.kind === "endpoint" || parameter.kind === "path" + ); + const endpointParamName = endpointParam + ? getClientParameterName(endpointParam) + : undefined; + + return allParams.map((p) => { + const hasEndpointTemplateDefaultValue = + p.type.kind === "endpoint" && + !!( + p.type.templateArguments[0]?.clientDefaultValue || + p.type.templateArguments[0]?.__raw?.defaultValue || + p.type.templateArguments[0]?.type?.kind === "constant" + ); + const hasDefaultValue = !!( + p.clientDefaultValue || + p.__raw?.defaultValue || + p.type.kind === "constant" || + hasEndpointTemplateDefaultValue + ); + + return { + name: getClientParameterName(p), + type: getTypeExpression(sdkContext, p.type), + required: !p.optional && !hasDefaultValue, + hasDefaultValue, + defaultValue: p.clientDefaultValue, + docs: getDocsFromDescription(p.doc), + isApiVersion: !!p.isApiVersionParam, + isEndpoint: + getClientParameterName(p) === endpointParamName || + (p.kind === "endpoint" && p.type.kind !== "union") || + (p.kind === "endpoint" && + p.type.kind === "union" && + p.type.variantTypes.some((v) => v.kind === "endpoint")), + isCredential: p.kind === "credential" + }; + }); +} + +// ─── Endpoint Adapter ───────────────────────────────────────────────── + +function adaptEndpoint( + sdkContext: SdkContext, + client: SdkClientType, + settings: TSGenerationSettings +): TSEndpointConfig { + const endpointParam = getClientParameters(client, sdkContext, { + onClientOnly: true, + skipEndpointTemplate: true, + skipArmSpecific: true + }).find((x) => x.kind === "endpoint" || x.kind === "path"); + + if (!endpointParam) { + return { + isParameterized: false, + templateParameters: [], + useArmCloudEndpoint: settings.isArm + }; + } + + if ( + endpointParam.type.kind === "union" && + endpointParam.type.variantTypes[0]?.kind === "endpoint" + ) { + const templateArgs = endpointParam.type.variantTypes[0].templateArguments; + return { + isParameterized: true, + serverUrl: endpointParam.type.variantTypes[0].serverUrl, + templateParameters: templateArgs.map((tp) => ({ + name: getClientParameterName(tp), + clientDefaultValue: tp.clientDefaultValue, + isOptional: !!tp.optional, + tcgcName: tp.name + })), + useArmCloudEndpoint: settings.isArm + }; + } + + if (endpointParam.type.kind === "endpoint") { + const firstArg = endpointParam.type.templateArguments[0]; + return { + isParameterized: false, + serverUrl: endpointParam.type.serverUrl, + templateParameters: firstArg + ? [ + { + name: getClientParameterName(firstArg), + clientDefaultValue: firstArg.clientDefaultValue, + isOptional: !!firstArg.optional, + tcgcName: firstArg.name + } + ] + : [], + useArmCloudEndpoint: settings.isArm + }; + } + + return { + isParameterized: false, + templateParameters: [], + useArmCloudEndpoint: settings.isArm + }; +} + +// ─── Credential Adapter ─────────────────────────────────────────────── + +function adaptCredential( + client: SdkClientType, + emitterOptions: ModularEmitterOptions +): TSCredentialConfig { + const credParam = buildGetClientCredentialParam(client, emitterOptions); + return { + hasCredentials: credParam !== "undefined", + parameterName: credParam + }; +} + +// ─── API Version Adapter ────────────────────────────────────────────── + +function adaptApiVersion( + sdkContext: SdkContext, + client: SdkClientType +): TSApiVersionConfig | undefined { + const params = getClientParameters(client, sdkContext); + const apiVersionParam = params.find((x) => x.isApiVersionParam); + if (!apiVersionParam) return undefined; + + const paramName = getClientParameterName(apiVersionParam); + + // Check if api version is in endpoint template + const endpointParam = getClientParameters(client, sdkContext, { + onClientOnly: false, + requiredOnly: true, + skipEndpointTemplate: true + }).find((x) => x.kind === "endpoint"); + + let isInEndpointTemplate = false; + if (endpointParam) { + const templateArgs = + endpointParam.type.kind === "endpoint" + ? endpointParam.type.templateArguments + : endpointParam.type.kind === "union" + ? endpointParam.type.variantTypes[0]?.templateArguments + : []; + isInEndpointTemplate = !!( + templateArgs && templateArgs.find((p) => p.isApiVersionParam) + ); + } + + // Get known values enum name + let knownValuesEnumName: string | undefined; + const apiVersionEnum = getApiVersionEnum(sdkContext); + if (apiVersionEnum) { + const [_, knownValuesEnum] = buildEnumTypes( + sdkContext, + apiVersionEnum, + true + ); + knownValuesEnumName = knownValuesEnum.name; + } + + return { + parameterName: paramName, + isInEndpointTemplate, + clientDefaultValue: apiVersionParam.clientDefaultValue, + knownValuesEnumName + }; +} + +// ─── API Options / LRO Adapter ───────────────────────────────────────── + +function adaptApiOptions( + client: SdkClientType, + sdkContext: SdkContext +): TSApiOptions[] { + const methodMap = getMethodHierarchiesMap(sdkContext, client); + + return [...methodMap.entries()].map(([prefixKey, operations]) => { + const prefixes = getGroupPrefixes(prefixKey); + return { + prefixes, + interfaces: operations.map((operation) => + adaptApiOptionsInterface(operation, prefixes, sdkContext) + ) + }; + }); +} + +function adaptApiOptionsInterface( + operation: ServiceOperation, + prefixes: string[], + sdkContext: SdkContext +): TSApiOptionsInterface { + return { + name: getOperationOptionsName([prefixes, operation], true), + refKey: refkey(operation, "operationOptions"), + properties: adaptApiOptionsProperties(operation, sdkContext) + }; +} + +function adaptApiOptionsProperties( + operation: ServiceOperation, + sdkContext: SdkContext +): TSApiOptionsProperty[] { + const properties: TSApiOptionsProperty[] = []; + + if (isLroOnlyOperation(operation) || isLroAndPagingOperation(operation)) { + properties.push({ + name: "updateIntervalInMs", + type: "number", + docs: ["Delay to wait until next poll, in milliseconds."] + }); + } + + const bodyContentTypes = operation.operation.bodyParam?.contentTypes ?? []; + if (hasDualFormatSupport(bodyContentTypes)) { + properties.push({ + name: "contentType", + type: "string", + docs: [ + 'The content type for the request body. Defaults to "application/json". Use "application/xml" for XML serialization.' + ] + }); + } + + for (const parameter of operation.parameters) { + if ( + parameter.onClient || + !(parameter.optional || parameter.clientDefaultValue) + ) { + continue; + } + + if ( + parameter.isGeneratedName && + (parameter.name === "contentType" || parameter.name !== "accept") + ) { + continue; + } + + properties.push({ + name: normalizeName(parameter.name, NameType.Parameter), + type: getTypeExpression(sdkContext, parameter.type, { isOptional: true }), + docs: getDocsFromDescription(parameter.doc) + }); + } + + return properties; +} + +function adaptLroConfig( + client: SdkClientType, + sdkContext: SdkContext +): TSLroConfig | undefined { + const methodMap = getMethodHierarchiesMap(sdkContext, client); + const deserializers: TSLroDeserializer[] = []; + const existingNames = new Set(); + + for (const [prefixKey, operations] of methodMap) { + const prefixes = getGroupPrefixes(prefixKey); + const operationFileName = getOperationFileName(prefixes); + + for (const operation of operations.filter((candidate) => + isLroOnlyOperation(candidate) + )) { + const { name } = getOperationName(operation); + const exportName = `_${name}Deserialize`; + const localName = existingNames.has(exportName) + ? `_${name}Deserialize${normalizeName( + operationFileName.split("/").slice(0, -1).join("_"), + NameType.Interface + )}` + : exportName; + + existingNames.add(exportName); + deserializers.push({ + moduleSpecifier: `./api/${operationFileName}.js`, + exportName, + localName, + path: `${operation.operation.verb.toUpperCase()} ${operation.operation.path}`, + expectedStatusesExpression: getExpectedStatuses(operation) + }); + } + } + + if (deserializers.length === 0) { + return undefined; + } + + return { + clientName: getClassicalClientName(client), + deserializers + }; +} + +function getGroupPrefixes(prefixKey: string): string[] { + return prefixKey === "" ? [] : prefixKey.split("/"); +} + +function getOperationFileName(prefixes: string[]): string { + if (prefixes.length === 0) { + return "operations"; + } + + return `${prefixes + .map((prefix) => normalizeName(prefix, NameType.File)) + .join("/")}/operations`; +} + +// ─── Method Adapter ─────────────────────────────────────────────────── + +export function adaptMethods( + client: SdkClientType, + sdkContext: SdkContext, + clientType: string = "Client" +): TSMethod[] { + const methodMap = getMethodHierarchiesMap(sdkContext, client); + const methods: TSMethod[] = []; + + for (const [prefixKey, operations] of methodMap) { + if (prefixKey !== "") { + continue; + } + + for (const operation of operations) { + methods.push(adaptMethod(operation, sdkContext, [], clientType)); + } + } + + return methods; +} + +function adaptMethod( + operation: ServiceOperation, + sdkContext: SdkContext, + prefixes: string[] = [], + clientType: string = "Client" +): TSMethod { + const operationRef = [prefixes, operation] as [string[], ServiceOperation]; + const declaration = getOperationFunction( + sdkContext, + operationRef, + clientType + ); + const sendDeclaration = getSendPrivateFunction( + sdkContext, + operationRef, + clientType + ); + const deserializeDeclaration = getDeserializePrivateFunction( + sdkContext, + operationRef + ); + const deserializeHeadersDeclaration = getDeserializeHeadersPrivateFunction( + sdkContext, + operation + ); + const deserializeExceptionHeadersDeclaration = + getDeserializeExceptionHeadersPrivateFunction(sdkContext, operation); + const methodName = + declaration.propertyName ?? declaration.name ?? operation.name; + const description = + getDocsFromDescription(operation.doc).join("\n") || undefined; + + return { + id: `method:${methodName}`, + name: methodName, + originalName: operation.oriName, + apiRefKey: refkey(operation, "api"), + kind: adaptMethodKind(operation), + description, + httpMethod: operation.operation.verb.toUpperCase(), + route: { + pathTemplate: operation.operation.path, + verb: operation.operation.verb.toUpperCase() + }, + parameters: adaptMethodParameters(operation, sdkContext), + returnType: adaptMethodReturnType( + operation, + sdkContext, + declaration.returnType?.toString() + ), + responseTypeAlias: adaptResponseTypeAlias(operation, sdkContext, prefixes), + apiFunction: adaptFunctionDeclaration(declaration), + sendFunction: adaptFunctionDeclaration(sendDeclaration), + deserializeFunction: adaptFunctionDeclaration(deserializeDeclaration), + deserializeHeadersFunction: deserializeHeadersDeclaration + ? adaptFunctionDeclaration(deserializeHeadersDeclaration) + : undefined, + deserializeExceptionHeadersFunction: deserializeExceptionHeadersDeclaration + ? adaptFunctionDeclaration(deserializeExceptionHeadersDeclaration) + : undefined, + compatibilityLroReturnType: declaration.lroFinalReturnType, + compatibilityLroPagingReturnType: declaration.lropagingFinalReturnType + }; +} + +function adaptResponseTypeAlias( + operation: ServiceOperation, + sdkContext: SdkContext, + prefixes: string[] +): TSResponseTypeAlias | undefined { + const { shouldWrap, isBinary } = checkWrapNonModelReturn( + sdkContext, + operation + ); + if (!shouldWrap) { + return undefined; + } + + const isHeadAsBoolean = + !operation.response.type && + operation.operation.verb.toLowerCase() === "head"; + + return { + name: getOperationResponseTypeName([prefixes, operation]), + refKey: refkey(operation, "response"), + kind: isBinary ? "binary" : isHeadAsBoolean ? "headAsBoolean" : "body", + bodyType: + isBinary || isHeadAsBoolean + ? isHeadAsBoolean + ? "boolean" + : undefined + : getTypeExpression(sdkContext, operation.response.type!) + }; +} + +function adaptMethodKind(operation: ServiceOperation): TSMethodKind { + if (isLroAndPagingOperation(operation)) { + return "lroPaging"; + } + + if (isLroOnlyOperation(operation)) { + return "lro"; + } + + if (isPagingOnlyOperation(operation)) { + return "paging"; + } + + return "basic"; +} + +function adaptMethodParameters( + operation: ServiceOperation, + sdkContext: SdkContext +): TSMethod["parameters"] { + const parameters: TSMethod["parameters"] = []; + const seen = new Set(); + + for (const parameter of operation.parameters) { + const httpLocation = getOperationParameterLocation(operation, parameter); + if (!httpLocation || !shouldIncludeOperationParameter(parameter)) { + continue; + } + + parameters.push(adaptMethodParameter(parameter, httpLocation, sdkContext)); + seen.add(parameter.name); + } + + const bodyParameter = operation.operation.bodyParam; + if ( + bodyParameter && + shouldIncludeOperationParameter(bodyParameter) && + !seen.has(bodyParameter.name) + ) { + parameters.push(adaptMethodParameter(bodyParameter, "body", sdkContext)); + } + + return parameters; +} + +function adaptMethodParameter( + parameter: SdkMethodParameter | SdkBodyParameter, + httpLocation: TSMethod["parameters"][number]["httpLocation"], + sdkContext: SdkContext +): TSMethod["parameters"][number] { + const defaultValue = + parameter.clientDefaultValue ?? + (parameter as { __raw?: { defaultValue?: unknown } }).__raw?.defaultValue; + + return { + name: parameter.name, + type: getTypeExpression(sdkContext, parameter.type), + optional: !!parameter.optional || defaultValue !== undefined, + defaultValue, + httpLocation + }; +} + +function getOperationParameterLocation( + operation: ServiceOperation, + parameter: SdkMethodParameter | SdkBodyParameter +): TSMethod["parameters"][number]["httpLocation"] | undefined { + if (operation.operation.bodyParam === parameter) { + return "body"; + } + + const httpParameter = operation.operation.parameters.find((candidate) => + isDirectMethodParameter(candidate, parameter) + ); + + if (!httpParameter) { + return undefined; + } + + if ( + httpParameter.kind === "query" || + httpParameter.kind === "header" || + httpParameter.kind === "path" + ) { + return httpParameter.kind; + } + + return undefined; +} + +function isDirectMethodParameter( + httpParameter: SdkHttpParameter, + parameter: SdkMethodParameter | SdkBodyParameter +): boolean { + return ( + httpParameter.methodParameterSegments.length === 1 && + httpParameter.methodParameterSegments[0]?.length === 1 && + httpParameter.methodParameterSegments[0]?.[0] === parameter + ); +} + +function shouldIncludeOperationParameter( + parameter: SdkMethodParameter | SdkBodyParameter +): boolean { + return !( + parameter.onClient || + parameter.type.kind === "constant" || + (parameter.isGeneratedName && + (parameter.name === "contentType" || parameter.name === "accept")) + ); +} + +function adaptMethodReturnType( + operation: ServiceOperation, + sdkContext: SdkContext, + declarationReturnType: string | undefined +): TSMethod["returnType"] { + const logicalReturnType = getLogicalReturnType(operation); + + return { + type: String(declarationReturnType ?? "Promise"), + nullable: logicalReturnType ? isTypeNullable(logicalReturnType) : false, + isVoid: + !logicalReturnType && !isHeadAsBooleanOperation(operation, sdkContext) + }; +} + +function getLogicalReturnType(operation: ServiceOperation) { + if (isLroOnlyOperation(operation)) { + return operation.lroMetadata?.finalResponse?.result; + } + + return operation.response.type; +} + +function isHeadAsBooleanOperation( + operation: ServiceOperation, + sdkContext: SdkContext +): boolean { + if (operation.operation.verb.toLowerCase() !== "head") { + return false; + } + + return ( + (operation.response.type as { kind?: string } | undefined)?.kind === + "boolean" || !!sdkContext.rlcOptions?.headAsBoolean + ); +} + +// ─── Operation Group Adapter ────────────────────────────────────────── + +export function adaptOperationGroups( + client: SdkClientType, + sdkContext: SdkContext, + clientType: string = "Client" +): TSOperationGroup[] { + const methodMap = getMethodHierarchiesMap(sdkContext, client); + const groups: TSOperationGroup[] = []; + + for (const [prefixKey, operations] of methodMap) { + if (prefixKey === "") { + continue; + } + + const prefixes = prefixKey.split("/"); + const groupName = normalizeName( + prefixes[prefixes.length - 1] ?? "", + NameType.Interface + ); + + groups.push({ + name: groupName, + prefixes, + methods: operations.map((operation) => + adaptMethod(operation, sdkContext, prefixes, clientType) + ) + }); + } + + return groups; +} + +function adaptFunctionDeclaration(declaration: any): TSFunctionDeclaration { + const params = (declaration.parameters ?? []).map((parameter: any) => { + const paramType = + typeof parameter.type === "string" + ? parameter.type + : parameter.type?.toString?.(); + const paramInitializer = + typeof parameter.initializer === "string" + ? parameter.initializer + : typeof parameter.initializer === "function" + ? undefined + : parameter.initializer?.toString?.(); + return { + name: parameter.name ?? "", + type: paramType, + initializer: paramInitializer, + hasQuestionToken: parameter.hasQuestionToken, + docs: Array.isArray(parameter.docs) + ? parameter.docs.filter((d: any) => typeof d === "string") + : undefined + }; + }); + + const returnTypeValue = + typeof declaration.returnType === "string" + ? declaration.returnType + : declaration.returnType?.toString?.(); + + const docsValue = Array.isArray(declaration.docs) + ? declaration.docs.filter((d: any) => typeof d === "string") + : undefined; + + const statementsValue = + typeof declaration.statements === "string" + ? declaration.statements + : Array.isArray(declaration.statements) + ? (declaration.statements as any[]) + .filter((s: any) => typeof s === "string") + .join("\n") + : undefined; + + return { + name: declaration.name ?? "", + docs: docsValue, + isAsync: declaration.isAsync, + isExported: declaration.isExported, + propertyName: declaration.propertyName, + returnType: returnTypeValue, + parameters: params, + statements: statementsValue + }; +} + +function shouldAllowOptionalSubscriptionId( + client: SdkClientType, + sdkContext: SdkContext, + parameters: TSClientParameter[] +): boolean { + return ( + !!sdkContext.arm && + parameters.some( + (parameter) => parameter.name.toLowerCase() === "subscriptionid" + ) && + hasTenantLevelOperations(client, sdkContext) + ); +} + +function hasTenantLevelOperations( + client: SdkClientType, + sdkContext: SdkContext +): boolean { + const methodMap = getMethodHierarchiesMap(sdkContext, client); + + for (const [, operations] of methodMap) { + for (const operation of operations) { + if (isTenantLevelOperation(operation, client)) { + return true; + } + } + } + + return false; +} + +// ─── Model / Enum / Union Adapters ────────────────────────────────────── + +export function adaptModels(sdkContext: SdkContext): TSModel[] { + return sdkContext.sdkPackage.models + .filter((model) => shouldAdaptModel(sdkContext, model)) + .map((model) => adaptModel(model, sdkContext)); +} + +export function adaptEnums(sdkContext: SdkContext): TSEnum[] { + return sdkContext.sdkPackage.enums + .filter((enumType) => shouldAdaptEnum(sdkContext, enumType)) + .map((enumType) => adaptEnum(enumType, sdkContext)); +} + +export function adaptUnions(sdkContext: SdkContext): TSUnion[] { + return sdkContext.sdkPackage.unions + .filter( + (unionType): unionType is SdkUnionType => unionType.kind === "union" + ) + .filter((unionType) => shouldAdaptUnion(unionType)) + .map((unionType) => adaptUnion(unionType, sdkContext)); +} + +function adaptModel(model: SdkModelType, sdkContext: SdkContext): TSModel { + return { + id: `model:${model.name}`, + name: model.name, + namespace: getModelNamespaces(sdkContext, model), + docs: getDocsFromDescription(model.doc), + properties: model.properties.map((property) => + adaptModelProperty(property, sdkContext) + ), + baseType: model.baseModel + ? adaptTypeReference(sdkContext, model.baseModel) + : undefined, + additionalPropertiesType: model.additionalProperties + ? adaptTypeReference(sdkContext, model.additionalProperties) + : undefined, + discriminator: adaptModelDiscriminator(model, sdkContext) + }; +} + +function adaptModelProperty( + property: SdkModelPropertyType, + sdkContext: SdkContext +): TSProperty { + return { + name: adaptPropertyName(property, sdkContext), + type: adaptTypeReference(sdkContext, property.type), + optional: property.optional, + readonly: isReadOnly(property), + serializedName: getPropertySerializedName(property), + isDiscriminator: property.discriminator, + isFlattened: property.flatten + }; +} + +function adaptModelDiscriminator( + model: SdkModelType, + sdkContext: SdkContext +): TSModel["discriminator"] { + const discriminatorProperty = + model.discriminatorProperty ?? model.baseModel?.discriminatorProperty; + if ( + !discriminatorProperty && + !model.discriminatorValue && + !model.discriminatedSubtypes + ) { + return undefined; + } + + return { + propertyName: discriminatorProperty + ? adaptPropertyName(discriminatorProperty, sdkContext) + : "discriminator", + serializedName: discriminatorProperty + ? getPropertySerializedName(discriminatorProperty) + : undefined, + value: model.discriminatorValue, + derivedTypes: Object.values(model.discriminatedSubtypes ?? {}).map( + (subtype) => adaptTypeReference(sdkContext, subtype) + ) + }; +} + +function adaptEnum(enumType: SdkEnumType, sdkContext: SdkContext): TSEnum { + return { + id: `enum:${enumType.name}`, + name: enumType.name, + namespace: getModelNamespaces(sdkContext, enumType), + docs: getDocsFromDescription(enumType.doc), + members: enumType.values.map((member) => ({ + name: member.name, + value: member.value + })), + isFixed: enumType.isFixed, + isExtensible: !enumType.isFixed, + valueType: adaptTypeReference(sdkContext, enumType.valueType) + }; +} + +function adaptUnion(unionType: SdkUnionType, sdkContext: SdkContext): TSUnion { + return { + id: `union:${unionType.name}`, + name: unionType.name, + namespace: getModelNamespaces(sdkContext, unionType), + docs: getDocsFromDescription(unionType.doc), + variants: adaptUnionVariants(unionType, sdkContext), + discriminator: unionType.discriminatedOptions + ? { + propertyName: + unionType.discriminatedOptions.discriminatorPropertyName, + envelope: unionType.discriminatedOptions.envelope, + envelopePropertyName: + unionType.discriminatedOptions.envelopePropertyName + } + : undefined + }; +} + +function adaptUnionVariants( + unionType: SdkUnionType, + sdkContext: SdkContext +): TSUnionVariant[] { + const rawVariantNames = getRawUnionVariantNames(unionType); + + return unionType.variantTypes.map((variant, index) => ({ + name: rawVariantNames[index], + type: adaptTypeReference(sdkContext, variant) + })); +} + +function getRawUnionVariantNames( + unionType: SdkUnionType +): Array { + const rawUnion = unionType.__raw; + if (!rawUnion || !("variants" in rawUnion)) { + return []; + } + + return [...rawUnion.variants.keys()].map((name) => + typeof name === "string" ? name : undefined + ); +} + +function adaptTypeReference( + sdkContext: SdkContext, + type: SdkType +): TSTypeReference { + switch (type.kind) { + case "model": + case "enum": + case "union": + return type.name; + case "array": + return `Array<${adaptTypeReference(sdkContext, type.valueType)}>`; + case "dict": + return `Record`; + case "nullable": + return `${adaptTypeReference(sdkContext, type.type)} | null`; + case "constant": + case "enumvalue": + return JSON.stringify(type.value); + default: + if ("name" in type && typeof type.name === "string") { + return type.name; + } + return getTypeExpression(sdkContext, type); + } +} + +function adaptPropertyName( + property: SdkModelPropertyType, + sdkContext: SdkContext +): string { + return sdkContext.rlcOptions?.ignorePropertyNameNormalize + ? property.name + : normalizeName(property.name, NameType.Property); +} + +function shouldAdaptModel( + sdkContext: SdkContext, + model: SdkModelType +): boolean { + if (isAzureCoreErrorType(sdkContext.program, model.__raw)) { + return false; + } + + if (isOrExtendsHttpFile(sdkContext.program, model.__raw!)) { + return false; + } + + if (!model.name && model.isGeneratedName) { + return false; + } + + return hasModelUsage(model.usage); +} + +function hasModelUsage(usage: UsageFlags | undefined): boolean { + if (!usage) { + return false; + } + + return ( + (usage & UsageFlags.Input) === UsageFlags.Input || + (usage & UsageFlags.Output) === UsageFlags.Output || + (usage & UsageFlags.Exception) === UsageFlags.Exception + ); +} + +function shouldAdaptEnum( + sdkContext: SdkContext, + enumType: SdkEnumType +): boolean { + if (!enumType.usage) { + return false; + } + + const apiVersionEnumOnly = enumType.usage === UsageFlags.ApiVersionEnum; + if (apiVersionEnumOnly && sdkContext.rlcOptions?.isMultiService) { + return false; + } + + if (enumType.name.startsWith("_")) { + return false; + } + + return ( + apiVersionEnumOnly || + hasModelUsage(enumType.usage) || + isExtensibleEnum(sdkContext, enumType) + ); +} + +function shouldAdaptUnion(unionType: SdkUnionType): boolean { + return !!unionType.name; +} diff --git a/packages/typespec-ts/src/tcgcadapter/helperTypes.ts b/packages/typespec-ts/src/tcgcadapter/helperTypes.ts new file mode 100644 index 0000000000..88d937ddc2 --- /dev/null +++ b/packages/typespec-ts/src/tcgcadapter/helperTypes.ts @@ -0,0 +1,232 @@ +import type { + SdkArrayType, + SdkDictionaryType, + SdkHttpOperation, + SdkNullableType, + SdkServiceMethod, + SdkType +} from "@azure-tools/typespec-client-generator-core"; +import { getTypeExpression } from "../modular/type-expressions/get-type-expression.js"; +import { + buildHelperTypeId, + type TSHelperType, + type TSTypeReference +} from "../codemodel/index.js"; +import { getAllOperationsFromClient } from "../framework/hooks/sdkTypes.js"; +import { getModelNamespaces } from "../modular/emitModels.js"; +import type { SdkContext } from "../utils/interfaces.js"; + +type RawHelperType = SdkArrayType | SdkDictionaryType | SdkNullableType; + +interface HelperTypeEntry { + helper: TSHelperType; + rawType: RawHelperType; +} + +export function adaptHelperTypes(sdkContext: SdkContext): TSHelperType[] { + return [...collectHelperTypeEntries(sdkContext).values()] + .map((entry) => entry.helper) + .sort(compareHelperTypes); +} + +export function buildHelperTypeLookup( + sdkContext: SdkContext +): Map { + return new Map( + [...collectHelperTypeEntries(sdkContext).values()].map((entry) => [ + entry.helper.id, + entry.rawType + ]) + ); +} + +function collectHelperTypeEntries( + sdkContext: SdkContext +): Map { + const entries = new Map(); + const visited = new Set(); + + for (const model of sdkContext.sdkPackage.models) { + visitTypeForHelpers(sdkContext, model, visited, entries); + } + + for (const unionType of sdkContext.sdkPackage.unions) { + if (unionType.kind === "union") { + visitTypeForHelpers(sdkContext, unionType, visited, entries); + } + } + + for (const client of sdkContext.sdkPackage.clients) { + for (const method of getAllOperationsFromClient(client)) { + visitMethodForHelpers(sdkContext, method, visited, entries); + } + } + + return entries; +} + +function visitMethodForHelpers( + sdkContext: SdkContext, + method: SdkServiceMethod, + visited: Set, + entries: Map +): void { + for (const parameter of method.parameters) { + visitTypeForHelpers(sdkContext, parameter.type, visited, entries); + } + + visitTypeForHelpers(sdkContext, method.response.type, visited, entries); + visitTypeForHelpers( + sdkContext, + method.operation.bodyParam?.type, + visited, + entries + ); + + for (const exception of method.operation.exceptions) { + visitTypeForHelpers(sdkContext, exception.type, visited, entries); + } + + for (const parameter of method.operation.parameters) { + visitTypeForHelpers(sdkContext, parameter.type, visited, entries); + } + + for (const response of method.operation.responses) { + visitTypeForHelpers(sdkContext, response.type, visited, entries); + } +} + +function visitTypeForHelpers( + sdkContext: SdkContext, + type: SdkType | undefined, + visited: Set, + entries: Map +): void { + if (!type || visited.has(type)) { + return; + } + + visited.add(type); + + switch (type.kind) { + case "model": + visitTypeForHelpers( + sdkContext, + type.additionalProperties, + visited, + entries + ); + for (const property of type.properties) { + visitTypeForHelpers(sdkContext, property.type, visited, entries); + } + for (const subtype of Object.values(type.discriminatedSubtypes ?? {})) { + visitTypeForHelpers(sdkContext, subtype, visited, entries); + } + return; + case "union": + for (const variant of type.variantTypes) { + visitTypeForHelpers(sdkContext, variant, visited, entries); + } + return; + case "array": + case "dict": + case "nullable": { + if (shouldAdaptHelperType(type)) { + const helper = buildHelperType(sdkContext, type); + entries.set(helper.id, { helper, rawType: type }); + } + + const nestedType = type.kind === "nullable" ? type.type : type.valueType; + visitTypeForHelpers(sdkContext, nestedType, visited, entries); + return; + } + default: + return; + } +} + +function shouldAdaptHelperType(type: RawHelperType): boolean { + return ( + type.kind !== "nullable" || + (Boolean(type.name) && type.isGeneratedName !== true) + ); +} + +function buildHelperType( + sdkContext: SdkContext, + type: RawHelperType +): TSHelperType { + const namespace = getModelNamespaces(sdkContext, type); + const elementType = + type.kind === "nullable" + ? adaptHelperTypeReference(sdkContext, type.type) + : adaptHelperTypeReference(sdkContext, type.valueType); + const name = getHelperTypeName(sdkContext, type); + const isNamedAlias = type.kind === "nullable"; + + return { + id: buildHelperTypeId({ + kind: type.kind, + name, + namespace, + elementType, + isNamedAlias + }), + kind: type.kind, + name, + namespace, + elementType, + isNamedAlias + }; +} + +function getHelperTypeName( + sdkContext: SdkContext, + type: RawHelperType +): string { + if (type.kind === "nullable") { + return type.name; + } + + const elementType = adaptHelperTypeReference(sdkContext, type.valueType); + return type.kind === "array" ? `${elementType}Array` : `${elementType}Record`; +} + +function adaptHelperTypeReference( + sdkContext: SdkContext, + type: SdkType +): TSTypeReference { + switch (type.kind) { + case "model": + case "enum": + case "union": + return type.name; + case "array": + return `Array<${adaptHelperTypeReference(sdkContext, type.valueType)}>`; + case "dict": + return `Record`; + case "nullable": + return `${adaptHelperTypeReference(sdkContext, type.type)} | null`; + case "constant": + case "enumvalue": + return JSON.stringify(type.value); + default: + if ("name" in type && typeof type.name === "string") { + return type.name; + } + return getTypeExpression(sdkContext, type); + } +} + +function compareHelperTypes(left: TSHelperType, right: TSHelperType): number { + return [left.namespace.join("/"), left.kind, left.name, left.elementType] + .join(":") + .localeCompare( + [ + right.namespace.join("/"), + right.kind, + right.name, + right.elementType + ].join(":") + ); +} diff --git a/packages/typespec-ts/test/azureModularIntegration/generated/azure/core/basic/src/index.d.ts b/packages/typespec-ts/test/azureModularIntegration/generated/azure/core/basic/src/index.d.ts deleted file mode 100644 index a52fa265ae..0000000000 --- a/packages/typespec-ts/test/azureModularIntegration/generated/azure/core/basic/src/index.d.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { ClientOptions } from '@azure-rest/core-client'; -import { isRestError } from '@azure/core-rest-pipeline'; -import { OperationOptions } from '@azure-rest/core-client'; -import { Pipeline } from '@azure/core-rest-pipeline'; -import { RestError } from '@azure/core-rest-pipeline'; - -export declare class BasicClient { - private _client; - readonly pipeline: Pipeline; - constructor(options?: BasicClientOptionalParams); - exportAllUsers(format: string, options?: ExportAllUsersOptionalParams): Promise; - export(id: number, format: string, options?: ExportOptionalParams): Promise; - delete(id: number, options?: DeleteOptionalParams): Promise; - list(options?: ListOptionalParams): PagedAsyncIterableIterator; - get(id: number, options?: GetOptionalParams): Promise; - createOrReplace(id: number, resource: User, options?: CreateOrReplaceOptionalParams): Promise; - createOrUpdate(id: number, resource: User, options?: CreateOrUpdateOptionalParams): Promise; -} - -export declare interface BasicClientOptionalParams extends ClientOptions { - apiVersion?: string; -} - -export declare type ContinuablePage = TPage & { - continuationToken?: string; -}; - -export declare interface CreateOrReplaceOptionalParams extends OperationOptions { -} - -export declare interface CreateOrUpdateOptionalParams extends OperationOptions { -} - -export declare interface DeleteOptionalParams extends OperationOptions { -} - -export declare interface ExportAllUsersOptionalParams extends OperationOptions { -} - -export declare interface ExportOptionalParams extends OperationOptions { -} - -export declare interface GetOptionalParams extends OperationOptions { -} - -export { isRestError } - -export declare enum KnownVersions { - V20221201Preview = "2022-12-01-preview" -} - -export declare interface ListOptionalParams extends OperationOptions { - top?: number; - skip?: number; - maxpagesize?: number; - orderby?: string[]; - filter?: string; - select?: string[]; - expand?: string[]; -} - -export declare interface PagedAsyncIterableIterator { - next(): Promise>; - [Symbol.asyncIterator](): PagedAsyncIterableIterator; - byPage: (settings?: TPageSettings) => AsyncIterableIterator>; -} - -export declare interface PageSettings { - continuationToken?: string; -} - -export { RestError } - -export declare interface User { - readonly id: number; - name: string; - orders?: UserOrder[]; - readonly etag: string; -} - -export declare interface UserList { - users: User[]; -} - -export declare interface UserOrder { - readonly id: number; - userId: number; - detail: string; -} - -export { } diff --git a/packages/typespec-ts/test/azureModularIntegration/generated/azure/special-headers/client-request-id/src/index.d.ts b/packages/typespec-ts/test/azureModularIntegration/generated/azure/special-headers/client-request-id/src/index.d.ts deleted file mode 100644 index b3ae7d976f..0000000000 --- a/packages/typespec-ts/test/azureModularIntegration/generated/azure/special-headers/client-request-id/src/index.d.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { ClientOptions } from '@azure-rest/core-client'; -import { isRestError } from '@azure/core-rest-pipeline'; -import { OperationOptions } from '@azure-rest/core-client'; -import { Pipeline } from '@azure/core-rest-pipeline'; -import { RestError } from '@azure/core-rest-pipeline'; - -export declare interface GetOptionalParams extends OperationOptions { - clientRequestId?: string; -} - -export { isRestError } - -export { RestError } - -export declare class XmsClientRequestIdClient { - private _client; - readonly pipeline: Pipeline; - constructor(options?: XmsClientRequestIdClientOptionalParams); - get(options?: GetOptionalParams): Promise; -} - -export declare interface XmsClientRequestIdClientOptionalParams extends ClientOptions { -} - -export { } diff --git a/packages/typespec-ts/test/azureModularIntegration/generated/client/structure/multi-client/src/index.d.ts b/packages/typespec-ts/test/azureModularIntegration/generated/client/structure/multi-client/src/index.d.ts deleted file mode 100644 index 6f9294074b..0000000000 --- a/packages/typespec-ts/test/azureModularIntegration/generated/client/structure/multi-client/src/index.d.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { ClientOptions } from '@azure-rest/core-client'; -import { isRestError } from '@azure/core-rest-pipeline'; -import { OperationOptions } from '@azure-rest/core-client'; -import { Pipeline } from '@azure/core-rest-pipeline'; -import { RestError } from '@azure/core-rest-pipeline'; - -export declare class ClientAClient { - private _client; - readonly pipeline: Pipeline; - constructor(endpointParam: string, clientParam: ClientType, options?: ClientAClientOptionalParams); - renamedFive(options?: RenamedFiveOptionalParams): Promise; - renamedThree(options?: RenamedThreeOptionalParams): Promise; - renamedOne(options?: RenamedOneOptionalParams): Promise; -} - -export declare interface ClientAClientOptionalParams extends ClientOptions { -} - -export declare class ClientBClient { - private _client; - readonly pipeline: Pipeline; - constructor(endpointParam: string, clientParam: ClientType, options?: ClientBClientOptionalParams); - renamedSix(options?: RenamedSixOptionalParams): Promise; - renamedFour(options?: RenamedFourOptionalParams): Promise; - renamedTwo(options?: RenamedTwoOptionalParams): Promise; -} - -export declare interface ClientBClientOptionalParams extends ClientOptions { -} - -export declare type ClientType = "default" | "multi-client" | "renamed-operation" | "two-operation-group" | "client-operation-group"; - -export { isRestError } - -export declare interface RenamedFiveOptionalParams extends OperationOptions { -} - -export declare interface RenamedFourOptionalParams extends OperationOptions { -} - -export declare interface RenamedOneOptionalParams extends OperationOptions { -} - -export declare interface RenamedSixOptionalParams extends OperationOptions { -} - -export declare interface RenamedThreeOptionalParams extends OperationOptions { -} - -export declare interface RenamedTwoOptionalParams extends OperationOptions { -} - -export { RestError } - -export { } diff --git a/packages/typespec-ts/test/azureModularIntegration/generated/parameters/collection-format/src/index.d.ts b/packages/typespec-ts/test/azureModularIntegration/generated/parameters/collection-format/src/index.d.ts deleted file mode 100644 index 00e4052115..0000000000 --- a/packages/typespec-ts/test/azureModularIntegration/generated/parameters/collection-format/src/index.d.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { ClientOptions } from '@azure-rest/core-client'; -import { isRestError } from '@azure/core-rest-pipeline'; -import { OperationOptions } from '@azure-rest/core-client'; -import { Pipeline } from '@azure/core-rest-pipeline'; -import { RestError } from '@azure/core-rest-pipeline'; - -export declare class CollectionFormatClient { - private _client; - readonly pipeline: Pipeline; - constructor(options?: CollectionFormatClientOptionalParams); - readonly header: HeaderOperations; - readonly query: QueryOperations; -} - -export declare interface CollectionFormatClientOptionalParams extends ClientOptions { -} - -export declare interface HeaderCsvOptionalParams extends OperationOptions { -} - -export declare interface HeaderOperations { - csv: (colors: string[], options?: HeaderCsvOptionalParams) => Promise; -} - -export { isRestError } - -export declare interface QueryCsvOptionalParams extends OperationOptions { -} - -export declare interface QueryMultiOptionalParams extends OperationOptions { -} - -export declare interface QueryOperations { - csv: (colors: string[], options?: QueryCsvOptionalParams) => Promise; - pipes: (colors: string[], options?: QueryPipesOptionalParams) => Promise; - ssv: (colors: string[], options?: QuerySsvOptionalParams) => Promise; - multi: (colors: string[], options?: QueryMultiOptionalParams) => Promise; -} - -export declare interface QueryPipesOptionalParams extends OperationOptions { -} - -export declare interface QuerySsvOptionalParams extends OperationOptions { -} - -export { RestError } - -export { } diff --git a/packages/typespec-ts/test/azureModularIntegration/generated/versioning/removed/v1/src/index.d.ts b/packages/typespec-ts/test/azureModularIntegration/generated/versioning/removed/v1/src/index.d.ts index 33879400cb..6914a09c69 100644 --- a/packages/typespec-ts/test/azureModularIntegration/generated/versioning/removed/v1/src/index.d.ts +++ b/packages/typespec-ts/test/azureModularIntegration/generated/versioning/removed/v1/src/index.d.ts @@ -44,7 +44,7 @@ export declare class RemovedClient { } export declare interface RemovedClientOptionalParams extends ClientOptions { - version?: Versions; + version?: string; } export { RestError } diff --git a/packages/typespec-ts/test/azureModularIntegration/generated/versioning/removed/v2preview/src/index.d.ts b/packages/typespec-ts/test/azureModularIntegration/generated/versioning/removed/v2preview/src/index.d.ts index 9feb91da01..14a9bba06c 100644 --- a/packages/typespec-ts/test/azureModularIntegration/generated/versioning/removed/v2preview/src/index.d.ts +++ b/packages/typespec-ts/test/azureModularIntegration/generated/versioning/removed/v2preview/src/index.d.ts @@ -41,7 +41,7 @@ export declare class RemovedClient { } export declare interface RemovedClientOptionalParams extends ClientOptions { - version?: Versions; + version?: string; } export { RestError } diff --git a/packages/typespec-ts/test/modularUnit/adapter-models.spec.ts b/packages/typespec-ts/test/modularUnit/adapter-models.spec.ts new file mode 100644 index 0000000000..e1e30edc70 --- /dev/null +++ b/packages/typespec-ts/test/modularUnit/adapter-models.spec.ts @@ -0,0 +1,399 @@ +import { describe, expect, it } from "vitest"; + +import type { + TSCodeModel, + TSEnum, + TSModel, + TSProperty, + TSUnion +} from "../../src/codemodel/index.js"; +import { renameClientName } from "../../src/index.js"; +import { transformModularEmitterOptions } from "../../src/modular/buildModularOptions.js"; +import { adaptToCodeModel } from "../../src/tcgcadapter/adapter.js"; +import { getClientHierarchyMap } from "../../src/utils/clientUtils.js"; +import type { SdkContext } from "../../src/utils/interfaces.js"; +import { + createDpgContextTestHelper, + rlcEmitterFor, + type RLCEmitterOptions +} from "../util/testUtil.js"; + +function buildAdapterTypeSpec(tspContent: string): string { + return ` + import "@typespec/http"; + import "@typespec/rest"; + import "@typespec/versioning"; + import "@azure-tools/typespec-client-generator-core"; + import "@azure-tools/typespec-azure-core"; + + using Http; + using Rest; + using Versioning; + using Azure.ClientGenerator.Core; + using Azure.Core; + using Azure.Core.Traits; + + ${tspContent} + `; +} + +function buildServiceTypeSpec( + body: string, + namespaceDecorators: string = "" +): string { + return ` + ${namespaceDecorators} + @service(#{ + title: "Azure TypeScript Testing" + }) + namespace Azure.TypeScript.Testing { + ${body} + } + `; +} + +async function buildAdapterFixture( + tspContent: string, + configs: Record = {}, + hostOptions: RLCEmitterOptions = { withRawContent: true } +): Promise<{ + sdkContext: SdkContext; + emitterOptions: ReturnType; +}> { + const host = await rlcEmitterFor( + buildAdapterTypeSpec(tspContent), + hostOptions + ); + const sdkContext = await createDpgContextTestHelper(host.program, false, { + isModularLibrary: true, + ...configs + }); + sdkContext.rlcOptions!.isModularLibrary = true; + + const emitterOptions = transformModularEmitterOptions(sdkContext, "", { + casing: "camel" + }); + + for (const client of sdkContext.sdkPackage.clients) { + await renameClientName(client, emitterOptions); + } + + expect(getClientHierarchyMap(sdkContext)).toHaveLength(1); + return { sdkContext, emitterOptions }; +} + +async function adaptCodeModelFromTypeSpec( + tspContent: string, + configs: Record = {} +): Promise { + const { sdkContext, emitterOptions } = await buildAdapterFixture( + tspContent, + configs + ); + + return adaptToCodeModel({ sdkContext, emitterOptions }); +} + +function findByName( + items: T[], + kind: string, + name: string +): T { + const item = items.find((candidate) => candidate.name === name); + expect(item, `Expected ${kind} ${name} to exist`).toBeDefined(); + return item!; +} + +function getModelProperties(model: TSModel): TSProperty[] { + return model.properties; +} + +function findProperty(model: TSModel, name: string): TSProperty { + const property = getModelProperties(model).find( + (candidate) => candidate.name === name + ); + expect( + property, + `Expected property ${name} on model ${model.name}` + ).toBeDefined(); + return property!; +} + +function getEnumMembers(enumType: TSEnum) { + return enumType.members; +} + +function getUnionVariants(unionType: TSUnion) { + return unionType.variants; +} + +function readTypeText(value: unknown, depth: number = 0): string { + if (depth > 3 || value === undefined || value === null) { + return ""; + } + + if (typeof value === "string") { + return value; + } + + if (typeof value !== "object") { + return String(value); + } + + const candidate = value as Record; + const direct = [candidate.name, candidate.kind, candidate.typeName] + .filter((item): item is string => typeof item === "string") + .join(" "); + const nested = [ + candidate.type, + candidate.valueType, + candidate.elementType, + candidate.target, + candidate.model, + candidate.modelType, + candidate.ref, + candidate.reference + ] + .map((item) => readTypeText(item, depth + 1)) + .filter(Boolean) + .join(" "); + + return `${direct} ${nested}`.trim(); +} + +function getDiscriminatorPropertyName(model: TSModel): string | undefined { + return model.discriminator?.propertyName; +} + +function getSerializedName(property: TSProperty): string | undefined { + return property.serializedName; +} + +function isOptionalProperty(property: TSProperty): boolean { + return property.optional; +} + +function isReadonlyProperty(property: TSProperty): boolean { + return property.readonly; +} + +describe("tcgc adapter model adapters", () => { + it("adapts a simple model into a TSModel", async () => { + const { models } = await adaptCodeModelFromTypeSpec( + buildServiceTypeSpec(` + model Foo { + name: string; + age: int32; + } + + @route("/foos") + @get + op getFoo(): Foo; + `) + ); + + const foo = findByName(models, "model", "Foo"); + const name = findProperty(foo, "name"); + const age = findProperty(foo, "age"); + + expect(getModelProperties(foo).map((property) => property.name)).toEqual([ + "name", + "age" + ]); + expect(readTypeText(name.type).toLowerCase()).toContain("string"); + expect(readTypeText(age.type).toLowerCase()).toMatch(/number|int32/); + }); + + it("marks optional model properties as optional", async () => { + const { models } = await adaptCodeModelFromTypeSpec( + buildServiceTypeSpec(` + model Bar { + name?: string; + } + + @route("/bars") + @get + op getBar(): Bar; + `) + ); + + const bar = findByName(models, "model", "Bar"); + const name = findProperty(bar, "name"); + + expect(isOptionalProperty(name)).toBe(true); + }); + + it("captures nested model references", async () => { + const { models } = await adaptCodeModelFromTypeSpec( + buildServiceTypeSpec(` + model Foo { + name: string; + } + + model Baz { + foo: Foo; + } + + @route("/baz") + @get + op getBaz(): Baz; + `) + ); + + const baz = findByName(models, "model", "Baz"); + const foo = findProperty(baz, "foo"); + + expect(readTypeText(foo.type)).toContain("Foo"); + }); + + it("captures polymorphic discriminator metadata", async () => { + const { models } = await adaptCodeModelFromTypeSpec( + buildServiceTypeSpec(` + @discriminator("kind") + model Pet { + name: string; + } + + model Cat extends Pet { + kind: "cat"; + meow: string; + } + + @route("/pets") + @get + op getPet(): Pet; + `) + ); + + const pet = findByName(models, "model", "Pet"); + + expect(getDiscriminatorPropertyName(pet)).toBe("kind"); + }); + + it("adapts fixed enums into TSEnum values", async () => { + const { enums } = await adaptCodeModelFromTypeSpec( + buildServiceTypeSpec(` + enum Color { + Red, + Green, + Blue + } + + model Paint { + color: Color; + } + + @route("/paint") + @get + op getPaint(): Paint; + `) + ); + + const color = findByName(enums, "enum", "Color"); + + expect(getEnumMembers(color).map((member) => member.name)).toEqual([ + "Red", + "Green", + "Blue" + ]); + expect(color.isFixed).toBe(true); + }); + + it("adapts extensible enums from string unions", async () => { + const { enums } = await adaptCodeModelFromTypeSpec( + buildServiceTypeSpec(` + union PetKind { + dog: "dog", + cat: "cat", + string + } + + model PetEnvelope { + kind: PetKind; + } + + @route("/petKinds") + @get + op getPetKind(): PetEnvelope; + `) + ); + + const petKind = findByName(enums, "enum", "PetKind"); + + expect( + getEnumMembers(petKind).map((member) => member.name ?? member.value) + ).toEqual(["dog", "cat"]); + expect( + petKind.isFixed ?? !(petKind.extensible || petKind.isExtensible) + ).toBe(false); + }); + + it("adapts discriminated unions into TSUnion variants", async () => { + const { unions } = await adaptCodeModelFromTypeSpec( + buildServiceTypeSpec(` + model CatVariant { + sound: "meow"; + } + + model DogVariant { + sound: "bark"; + } + + union PetResponse { + cat: CatVariant, + dog: DogVariant + } + + @route("/petResponse") + @get + op getPetResponse(): PetResponse; + `) + ); + + const petResponse = findByName(unions, "union", "PetResponse"); + + expect( + getUnionVariants(petResponse).map((variant) => variant.name) + ).toEqual(["cat", "dog"]); + }); + + it("captures serialized property names", async () => { + const { models } = await adaptCodeModelFromTypeSpec( + buildServiceTypeSpec(` + model Person { + @encodedName("application/json", "full_name") + name: string; + } + + @route("/people") + @get + op getPerson(): Person; + `) + ); + + const person = findByName(models, "model", "Person"); + const name = findProperty(person, "name"); + + expect(getSerializedName(name)).toBe("full_name"); + }); + + it("marks readonly model properties", async () => { + const { models } = await adaptCodeModelFromTypeSpec( + buildServiceTypeSpec(` + model ResourceModel { + @visibility(Lifecycle.Read) + id: string; + } + + @route("/resources") + @get + op getResource(): ResourceModel; + `) + ); + + const resourceModel = findByName(models, "model", "ResourceModel"); + const id = findProperty(resourceModel, "id"); + + expect(isReadonlyProperty(id)).toBe(true); + }); +}); diff --git a/packages/typespec-ts/test/modularUnit/adapter.spec.ts b/packages/typespec-ts/test/modularUnit/adapter.spec.ts new file mode 100644 index 0000000000..43b4108a70 --- /dev/null +++ b/packages/typespec-ts/test/modularUnit/adapter.spec.ts @@ -0,0 +1,797 @@ +import { describe, expect, it } from "vitest"; +import { Project } from "ts-morph"; + +import { emitClientContext } from "../../src/codegen/clients.js"; +import { provideBinder } from "../../src/framework/hooks/binder.js"; +import { renameClientName } from "../../src/index.js"; +import { transformModularEmitterOptions } from "../../src/modular/buildModularOptions.js"; +import { + adaptMethods, + adaptOperationGroups, + adaptSingleClient, + adaptToCodeModel +} from "../../src/tcgcadapter/adapter.js"; +import type { + TSClient, + TSMethod, + TSParameter +} from "../../src/codemodel/index.js"; +import { getClientHierarchyMap } from "../../src/utils/clientUtils.js"; +import { + createDpgContextTestHelper, + rlcEmitterFor, + type RLCEmitterOptions +} from "../util/testUtil.js"; + +function buildAdapterTypeSpec(tspContent: string): string { + return ` + import "@typespec/http"; + import "@typespec/rest"; + import "@typespec/versioning"; + import "@azure-tools/typespec-client-generator-core"; + import "@azure-tools/typespec-azure-core"; + + using Http; + using Rest; + using Versioning; + using Azure.ClientGenerator.Core; + using Azure.Core; + using Azure.Core.Traits; + + ${tspContent} + `; +} + +function buildServiceTypeSpec( + body: string, + namespaceDecorators: string = "" +): string { + return ` + ${namespaceDecorators} + @service(#{ + title: "Azure TypeScript Testing" + }) + namespace Azure.TypeScript.Testing { + ${body} + } + `; +} + +async function buildAdapterFixture( + tspContent: string, + configs: Record = {}, + hostOptions: RLCEmitterOptions = { withRawContent: true } +) { + const host = await rlcEmitterFor( + buildAdapterTypeSpec(tspContent), + hostOptions + ); + const sdkContext = await createDpgContextTestHelper(host.program, false, { + isModularLibrary: true, + ...configs + }); + sdkContext.rlcOptions!.isModularLibrary = true; + + const emitterOptions = transformModularEmitterOptions(sdkContext, "", { + casing: "camel" + }); + + for (const client of sdkContext.sdkPackage.clients) { + await renameClientName(client, emitterOptions); + } + + const clientMap = getClientHierarchyMap(sdkContext); + expect(clientMap).toHaveLength(1); + + return { + sdkContext, + emitterOptions, + clientMap: clientMap[0]! + }; +} + +async function adaptCodeModelFromTypeSpec( + tspContent: string, + configs: Record = {} +) { + const { sdkContext, emitterOptions } = await buildAdapterFixture( + tspContent, + configs + ); + + return adaptToCodeModel({ sdkContext, emitterOptions }); +} + +async function adaptFirstClientFromTypeSpec( + tspContent: string, + configs: Record = {} +) { + const { sdkContext, emitterOptions, clientMap } = await buildAdapterFixture( + tspContent, + configs + ); + + return adaptSingleClient(clientMap, sdkContext, emitterOptions); +} + +function findMethod(client: TSClient, name: string): TSMethod { + const method = [ + ...client.methods, + ...client.operationGroups.flatMap((group) => group.methods) + ].find( + (candidate) => candidate.name === name || candidate.originalName === name + ); + + expect(method, `Expected method ${name} to exist`).toBeDefined(); + return method!; +} + +function findParameter(method: TSMethod, name: string): TSParameter { + const parameter = method.parameters.find( + (candidate) => candidate.name === name + ); + expect( + parameter, + `Expected parameter ${name} on ${method.name}` + ).toBeDefined(); + return parameter!; +} + +function expectLocationIfAvailable( + parameter: TSParameter, + expectedLocation: string +): void { + const candidate = parameter as TSParameter & { + location?: string; + httpLocation?: string; + }; + + if (candidate.location !== undefined) { + expect(candidate.location).toBe(expectedLocation); + } + + if (candidate.httpLocation !== undefined) { + expect(candidate.httpLocation).toBe(expectedLocation); + } +} + +describe("tcgc adapter", () => { + it("adapts a single client with top-level methods into the TS client model", async () => { + const model = await adaptCodeModelFromTypeSpec( + buildServiceTypeSpec( + ` + @route("/widgets") + @doc("Pings the service") + op ping(@query message?: string): void; + `, + ` + @doc("Testing client docs") + @server("{endpoint}/widgets", "Widgets", { + endpoint: url + }) + ` + ) + ); + + expect(Object.keys(model).sort()).toEqual([ + "clients", + "enums", + "helperTypes", + "models", + "settings", + "unions" + ]); + expect(model.clients).toHaveLength(1); + expect(model.models).toEqual([]); + expect(model.enums).toEqual([]); + expect(model.unions).toEqual([]); + expect(model.helperTypes).toEqual([]); + expect(model.settings.flavor).toBe("azure"); + expect(model.settings.sourceRoot).toBe(""); + + const client = model.clients[0]!; + expect(Object.keys(client).sort()).toEqual([ + "allowOptionalSubscriptionId", + "apiOptions", + "apiVersion", + "children", + "contextTypeName", + "credential", + "docs", + "endpoint", + "hasParentInitializedChildren", + "id", + "lroConfig", + "methods", + "modularName", + "name", + "operationGroups", + "parameters", + "path", + "usesNamespacedContextType" + ]); + expect(client.name).toBe("TestingClient"); + expect(client.modularName).toBe("Testing"); + expect(client.contextTypeName).toBe("TestingContext"); + expect(client.docs).toEqual(["Testing client docs"]); + expect(client.path).toEqual([]); + expect(client.children).toEqual([]); + expect(client.operationGroups).toEqual([]); + expect(client.apiOptions).toHaveLength(1); + expect(client.apiOptions[0]).toMatchObject({ + prefixes: [], + interfaces: [{ name: "PingOptionalParams" }] + }); + expect(client.hasParentInitializedChildren).toBe(false); + expect(client.apiVersion).toBeUndefined(); + expect(client.lroConfig).toBeUndefined(); + expect(client.endpoint).toEqual({ + isParameterized: true, + serverUrl: "{endpoint}/widgets", + templateParameters: [ + { + name: "endpointParam", + clientDefaultValue: undefined, + isOptional: false, + tcgcName: "endpoint" + } + ], + useArmCloudEndpoint: false + }); + expect(client.methods).toHaveLength(1); + expect(Object.keys(client.methods[0]!).sort()).toEqual([ + "apiFunction", + "apiRefKey", + "compatibilityLroPagingReturnType", + "compatibilityLroReturnType", + "description", + "deserializeExceptionHeadersFunction", + "deserializeFunction", + "deserializeHeadersFunction", + "httpMethod", + "id", + "kind", + "name", + "originalName", + "parameters", + "responseTypeAlias", + "returnType", + "route", + "sendFunction" + ]); + expect(client.methods[0]).toMatchObject({ + id: "method:ping", + name: "ping", + kind: "basic", + description: "Pings the service", + httpMethod: "GET", + route: { + pathTemplate: "/widgets", + verb: "GET" + }, + returnType: { + isVoid: true, + nullable: false + } + }); + expect(client.methods[0]?.returnType.type).toContain("Promise"); + expect(client.methods[0]?.parameters).toEqual([ + { + name: "message", + type: "string", + optional: true, + defaultValue: undefined, + httpLocation: "query" + } + ]); + }); + + it("captures templated endpoint metadata for parameterized servers", async () => { + const client = await adaptFirstClientFromTypeSpec( + buildServiceTypeSpec( + ` + @route("/widgets") + op ping(): void; + `, + ` + @server("{endpoint}/widgets/{region}", "Widgets", { + endpoint: url, + @doc("Region") + region?: string = "westus" + }) + ` + ) + ); + + expect(client.endpoint.isParameterized).toBe(true); + expect(client.endpoint.serverUrl).toBe("{endpoint}/widgets/{region}"); + expect(client.endpoint.templateParameters).toEqual([ + { + name: "endpointParam", + clientDefaultValue: undefined, + isOptional: false, + tcgcName: "endpoint" + }, + { + name: "region", + clientDefaultValue: "westus", + isOptional: true, + tcgcName: "region" + } + ]); + }); + + it("does not synthesize client api-version metadata from operation-only query parameters", async () => { + const client = await adaptFirstClientFromTypeSpec( + buildServiceTypeSpec( + ` + model ApiVersionParameter { + @query + "api-version": string; + } + + @route("/widgets") + op ping(...ApiVersionParameter): void; + `, + ` + @server("{endpoint}/widgets", "Widgets", { + endpoint: url + }) + ` + ) + ); + + expect(client.apiVersion).toBeUndefined(); + expect(client.parameters.some((parameter) => parameter.isApiVersion)).toBe( + false + ); + }); + + it("tracks client api-version metadata when it is embedded in endpoint templates", async () => { + const client = await adaptFirstClientFromTypeSpec( + buildServiceTypeSpec( + ` + enum Versions { + v2026_05_15: "2026-05-15" + } + + @route("/widgets") + op ping(): void; + `, + ` + @versioned(Versions) + @server("{endpoint}/widgets/{apiVersion}", "Widgets", { + endpoint: url, + @path apiVersion: Versions + }) + ` + ) + ); + + expect(client.apiVersion).toMatchObject({ + parameterName: "apiVersion", + isInEndpointTemplate: true, + knownValuesEnumName: undefined + }); + expect(client.parameters.some((parameter) => parameter.isApiVersion)).toBe( + true + ); + }); + + it("keeps client-default api-version optional on emitted context interfaces", async () => { + const model = await adaptCodeModelFromTypeSpec( + buildServiceTypeSpec( + ` + enum Versions { + v2026_05_15: "2026-05-15" + } + + model ApiVersionParameter { + @query + "api-version": string; + } + + @route("/widgets") + op ping(...ApiVersionParameter): void; + `, + ` + @versioned(Versions) + @server("{endpoint}/widgets", "Widgets", { + endpoint: url + }) + ` + ) + ); + + const client = model.clients[0]!; + const apiVersion = client.parameters.find( + (parameter) => parameter.isApiVersion + ); + expect(apiVersion).toMatchObject({ + required: false, + hasDefaultValue: true + }); + + const project = new Project({ useInMemoryFileSystem: true }); + const binder = provideBinder(project); + const file = emitClientContext(project, client, model.settings); + binder.resolveAllReferences(""); + + expect(file?.getFullText()).toContain("apiVersion?: string;"); + expect(file?.getFullText()).not.toContain("apiVersion: string;"); + }); + + it("groups nested operations when operation groups are enabled", async () => { + const client = await adaptFirstClientFromTypeSpec( + buildServiceTypeSpec(` + namespace Reports { + namespace Daily { + @route("/reports/daily/run") + op run(): void; + } + } + `), + { + enableOperationGroup: true, + hierarchyClient: false + } + ); + + expect(client.methods).toEqual([]); + expect(client.operationGroups).toHaveLength(1); + expect(Object.keys(client.operationGroups[0]!).sort()).toEqual([ + "methods", + "name", + "prefixes" + ]); + expect(client.operationGroups[0]).toMatchObject({ + name: "Daily", + prefixes: ["Daily"] + }); + expect(client.operationGroups[0]?.methods).toHaveLength(1); + expect(client.operationGroups[0]?.methods[0]).toMatchObject({ + kind: "basic", + httpMethod: "GET", + route: { + pathTemplate: "/reports/daily/run", + verb: "GET" + } + }); + }); + + it("adapts a basic GET operation into a TS method", async () => { + const client = await adaptFirstClientFromTypeSpec( + buildServiceTypeSpec(` + model Widget { + name: string; + } + + @route("/widgets/{widgetId}") + @get + op getWidget(@path widgetId: string): Widget; + `) + ); + + expect(client.methods).toHaveLength(1); + expect(client.methods[0]).toMatchObject({ + id: "method:getWidget", + name: "getWidget", + kind: "basic", + httpMethod: "GET", + route: { + pathTemplate: "/widgets/{widgetId}", + verb: "GET" + } + }); + }); + + it("maps required path, query, and header parameters onto method parameters", async () => { + const client = await adaptFirstClientFromTypeSpec( + buildServiceTypeSpec(` + model Widget { + name: string; + } + + @route("/widgets/{widgetId}") + @get + op getWidget( + @path widgetId: string, + @query filter: string, + @header requestId: string + ): Widget; + `) + ); + + const method = findMethod(client, "getWidget"); + const widgetId = findParameter(method, "widgetId"); + const filter = findParameter(method, "filter"); + const requestId = findParameter(method, "requestId"); + + expect(widgetId).toMatchObject({ type: "string", optional: false }); + expect(filter).toMatchObject({ type: "string", optional: false }); + expect(requestId).toMatchObject({ type: "string", optional: false }); + expect(method.parameters.map((parameter) => parameter.name)).toEqual([ + "widgetId", + "filter", + "requestId" + ]); + expectLocationIfAvailable(widgetId, "path"); + expectLocationIfAvailable(filter, "query"); + expectLocationIfAvailable(requestId, "header"); + }); + + it("maps body parameters onto method parameters", async () => { + const client = await adaptFirstClientFromTypeSpec( + buildServiceTypeSpec(` + model Widget { + name: string; + } + + @route("/widgets") + @post + op createWidget(@body widget: Widget): Widget; + `) + ); + + const method = findMethod(client, "createWidget"); + const widget = findParameter(method, "widget"); + + expect(widget).toMatchObject({ + optional: false, + defaultValue: undefined, + httpLocation: "body" + }); + expect(widget.type).toBeTruthy(); + expect(method.returnType).toMatchObject({ + isVoid: false, + nullable: false + }); + expect(method.returnType.type).toContain("Promise<"); + expectLocationIfAvailable(widget, "body"); + }); + + it("marks long-running operations as lro methods", async () => { + const client = await adaptFirstClientFromTypeSpec( + buildServiceTypeSpec(` + @resource("widgets") + model Widget { + @key("widgetName") + @visibility(Lifecycle.Read) + name: string; + } + + interface Widgets { + getWidget is ResourceRead; + getWidgetOperationStatus is GetResourceOperationStatus; + + @pollingOperation(Widgets.getWidgetOperationStatus) + createOrUpdateWidget is StandardResourceOperations.LongRunningResourceCreateOrUpdate; + } + `), + { + enableOperationGroup: true, + hierarchyClient: false + } + ); + + const method = findMethod(client, "createOrUpdateWidget"); + + expect(method).toMatchObject({ + kind: "lro", + originalName: "createOrUpdateWidget", + httpMethod: "PATCH", + route: { + pathTemplate: "/widgets/{widgetName}", + verb: "PATCH" + }, + returnType: { + isVoid: false, + nullable: false + } + }); + expect(method.returnType.type).toBeTruthy(); + }); + + it("preserves paging operation metadata when the adapter surfaces it", async () => { + const client = await adaptFirstClientFromTypeSpec( + buildServiceTypeSpec(` + @resource("widgets") + model Widget { + @key("widgetName") + @visibility(Lifecycle.Read) + name: string; + } + + interface Widgets { + listWidgets is ResourceList; + } + `) + ); + + const method = findMethod(client, "listWidgets"); + + expect(method).toMatchObject({ + kind: "paging", + httpMethod: "GET", + route: { + pathTemplate: "/widgets", + verb: "GET" + }, + returnType: { + isVoid: false, + nullable: false + } + }); + expect(method.returnType.type).toBeTruthy(); + expect(method.returnType.type).not.toBe("Promise"); + }); + + it("creates separate operation groups for different prefixes", async () => { + const { clientMap, sdkContext } = await buildAdapterFixture( + buildServiceTypeSpec(` + namespace Reports { + @route("/reports/daily") + op getDaily(): void; + } + + namespace Admin { + @route("/admin/users") + op listUsers(): void; + } + `), + { + enableOperationGroup: true, + hierarchyClient: false + } + ); + + const methods = adaptMethods(clientMap[1], sdkContext); + const operationGroups = adaptOperationGroups(clientMap[1], sdkContext); + + expect(methods).toEqual([]); + expect( + operationGroups.map((group) => ({ + name: group.name, + prefixes: group.prefixes, + methods: group.methods.map((method) => method.name).sort(), + originalNames: group.methods + .map((method) => method.originalName ?? method.name) + .sort() + })) + ).toEqual([ + { + name: "Admin", + prefixes: ["Admin"], + methods: ["adminListUsers"], + originalNames: ["listUsers"] + }, + { + name: "Reports", + prefixes: ["Reports"], + methods: ["reportsGetDaily"], + originalNames: ["getDaily"] + } + ]); + }); + + it("keeps required parameters explicit while folding optional inputs into the options bag", async () => { + const client = await adaptFirstClientFromTypeSpec( + buildServiceTypeSpec(` + @route("/widgets/{widgetId}") + @delete + op deleteWidget( + @path widgetId: string, + @query force?: boolean, + @header requestId?: string + ): void; + `) + ); + + const method = findMethod(client, "deleteWidget"); + + expect(method.parameters).toEqual([ + { + name: "widgetId", + type: "string", + optional: false, + defaultValue: undefined, + httpLocation: "path" + }, + { + name: "force", + type: "boolean", + optional: true, + defaultValue: undefined, + httpLocation: "query" + }, + { + name: "requestId", + type: "string", + optional: true, + defaultValue: undefined, + httpLocation: "header" + } + ]); + }); + + it("maps model and void return types and keeps paged return types non-empty", async () => { + const client = await adaptFirstClientFromTypeSpec( + buildServiceTypeSpec(` + model Widget { + name: string; + } + + @route("/widgets/{widgetId}") + @get + op getWidget(@path widgetId: string): Widget; + + @route("/widgets/{widgetId}") + @delete + op deleteWidget(@path widgetId: string): void; + `) + ); + + const getWidget = findMethod(client, "getWidget"); + const deleteWidget = findMethod(client, "deleteWidget"); + + expect(getWidget.returnType).toMatchObject({ + isVoid: false, + nullable: false + }); + expect(getWidget.returnType.type).toContain("Promise<"); + expect(deleteWidget.returnType).toEqual({ + type: "Promise", + nullable: false, + isVoid: true + }); + + const pagingClient = await adaptFirstClientFromTypeSpec( + buildServiceTypeSpec(` + @resource("widgets") + model Widget { + @key("widgetName") + @visibility(Lifecycle.Read) + name: string; + } + + interface Widgets { + listWidgets is ResourceList; + } + `) + ); + + expect(findMethod(pagingClient, "listWidgets").returnType).toBeTruthy(); + }); + + it("falls back to the default endpoint-only client shape when no server is declared", async () => { + const client = await adaptFirstClientFromTypeSpec( + buildServiceTypeSpec(` + @client({ + name: "EmptyClient", + service: Azure.TypeScript.Testing + }) + interface Empty {} + `) + ); + + expect(client.name).toBe("EmptyClient"); + expect(client.methods).toEqual([]); + expect(client.operationGroups).toEqual([]); + expect(client.apiOptions).toEqual([]); + expect(client.lroConfig).toBeUndefined(); + expect(client.endpoint).toEqual({ + isParameterized: false, + serverUrl: "{endpoint}", + templateParameters: [ + { + name: "endpointParam", + clientDefaultValue: undefined, + isOptional: false, + tcgcName: "endpoint" + } + ], + useArmCloudEndpoint: false + }); + expect(client.apiVersion).toBeUndefined(); + }); +}); diff --git a/packages/typespec-ts/test/modularUnit/models-extensible-enums.spec.ts b/packages/typespec-ts/test/modularUnit/models-extensible-enums.spec.ts new file mode 100644 index 0000000000..b64e3d9824 --- /dev/null +++ b/packages/typespec-ts/test/modularUnit/models-extensible-enums.spec.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; +import { Project } from "ts-morph"; +import { useContext } from "../../src/contextManager.js"; +import { useBinder } from "../../src/framework/hooks/binder.js"; +import { adaptToCodeModel } from "../../src/tcgcadapter/adapter.js"; +import { transformModularEmitterOptions } from "../../src/modular/buildModularOptions.js"; +import { emitModelFiles } from "../../src/codegen/models.js"; +import { renameClientName } from "../../src/index.js"; +import { createDpgContextTestHelper, rlcEmitterFor } from "../util/testUtil.js"; + +async function emitModels(body: string): Promise { + const typeSpec = ` + import "@typespec/http"; + import "@typespec/rest"; + import "@typespec/versioning"; + import "@azure-tools/typespec-client-generator-core"; + import "@azure-tools/typespec-azure-core"; + + using Http; + using Rest; + using Versioning; + using Azure.ClientGenerator.Core; + using Azure.Core; + using Azure.Core.Traits; + + @service(#{ title: "Azure TypeScript Testing" }) + namespace Azure.TypeScript.Testing { + ${body} + } + `; + + const host = await rlcEmitterFor(typeSpec, { withRawContent: true }); + const sdkContext = await createDpgContextTestHelper(host.program, false, { + isModularLibrary: true + }); + sdkContext.rlcOptions!.isModularLibrary = true; + + const emitterOptions = transformModularEmitterOptions(sdkContext, "", { + casing: "camel" + }); + for (const client of sdkContext.sdkPackage.clients) { + await renameClientName(client, emitterOptions); + } + + const codeModel = adaptToCodeModel({ sdkContext, emitterOptions }); + const project = useContext("outputProject") as Project; + emitModelFiles(project, codeModel, sdkContext); + useBinder().resolveAllReferences(codeModel.settings.sourceRoot); + + return project + .getSourceFiles(`${codeModel.settings.sourceRoot}/models/**/*.ts`) + .map((file) => file.getFullText()) + .join("\n"); +} + +describe("models extensible enums", () => { + it("emits KnownXxx declarations from enum IR", async () => { + const modelsText = await emitModels(` + union PetKind { + dog: "dog", + cat: "cat", + string, + } + + model PetEnvelope { + kind: PetKind; + } + + @route("/pets") + @get + op getPet(): PetEnvelope; + `); + + expect(modelsText).toContain("export enum KnownPetKind"); + expect(modelsText).toContain("export type PetKind = string;"); + }); +}); diff --git a/packages/typespec-ts/test/modularUnit/models-helpers.spec.ts b/packages/typespec-ts/test/modularUnit/models-helpers.spec.ts new file mode 100644 index 0000000000..3824f261e5 --- /dev/null +++ b/packages/typespec-ts/test/modularUnit/models-helpers.spec.ts @@ -0,0 +1,165 @@ +import { describe, expect, it } from "vitest"; +import { Project } from "ts-morph"; +import { useContext } from "../../src/contextManager.js"; +import { useBinder } from "../../src/framework/hooks/binder.js"; +import { adaptToCodeModel } from "../../src/tcgcadapter/adapter.js"; +import { transformModularEmitterOptions } from "../../src/modular/buildModularOptions.js"; +import { emitModelFiles } from "../../src/codegen/models.js"; +import { renameClientName } from "../../src/index.js"; +import { createDpgContextTestHelper, rlcEmitterFor } from "../util/testUtil.js"; + +const PLACEHOLDER_PATTERN = /__PLACEHOLDER_/; + +function buildAzureTypeSpec(body: string): string { + return ` + import "@typespec/http"; + import "@typespec/rest"; + import "@typespec/versioning"; + import "@azure-tools/typespec-client-generator-core"; + import "@azure-tools/typespec-azure-core"; + + using Http; + using Rest; + using Versioning; + using Azure.ClientGenerator.Core; + using Azure.Core; + using Azure.Core.Traits; + + @service(#{ title: "Azure TypeScript Testing" }) + namespace Azure.TypeScript.Testing { + ${body} + } + `; +} + +async function emitModels(body: string): Promise { + const host = await rlcEmitterFor(buildAzureTypeSpec(body), { + withRawContent: true + }); + const sdkContext = await createDpgContextTestHelper(host.program, false, { + isModularLibrary: true + }); + sdkContext.rlcOptions!.isModularLibrary = true; + + const emitterOptions = transformModularEmitterOptions(sdkContext, "", { + casing: "camel" + }); + for (const client of sdkContext.sdkPackage.clients) { + await renameClientName(client, emitterOptions); + } + + const codeModel = adaptToCodeModel({ sdkContext, emitterOptions }); + const project = useContext("outputProject") as Project; + const binder = useBinder(); + const sourceRoot = codeModel.settings.sourceRoot; + + emitModelFiles(project, codeModel, sdkContext); + binder.resolveAllReferences(sourceRoot); + + return project + .getSourceFiles(`${sourceRoot}/models/**/*.ts`) + .map((file) => file.getFullText()); +} + +function expectResolvedHelpers( + modelTexts: string[], + expectedDeclaration: RegExp +): void { + expect(modelTexts.length).toBeGreaterThan(0); + + for (const text of modelTexts) { + expect(text).not.toMatch(PLACEHOLDER_PATTERN); + } + + expect(modelTexts.join("\n")).toMatch(expectedDeclaration); +} + +describe("models-helpers (Strategy B regression lock)", () => { + it("emits array-of-model helpers from IR without placeholders", async () => { + const modelTexts = await emitModels(` + model Item { + id: string; + value: int32; + } + + model ItemCollection { + items: Item[]; + } + + @route("/items") + @get + op listItems(): ItemCollection; + `); + + expectResolvedHelpers( + modelTexts, + /export function itemArrayDeserializer\(/ + ); + }); + + it("emits dict-of-model helpers from IR without placeholders", async () => { + const modelTexts = await emitModels(` + model Widget { + name: string; + weight: float32; + } + + model WidgetMap { + byName: Record; + } + + @route("/widgets") + @get + op getWidgets(): WidgetMap; + `); + + expectResolvedHelpers( + modelTexts, + /export function widgetRecordDeserializer\(/ + ); + }); + + it("emits named nullable aliases from IR without placeholders", async () => { + const modelTexts = await emitModels(` + union Prompt { + string, + string[], + null, + } + + @route("/prompts") + @get + op getPrompt(): Prompt; + `); + + expectResolvedHelpers( + modelTexts, + /export type Prompt = \(string \| \(string\)\[\]\) \| null;/ + ); + }); + + it("emits paged array-of-model helpers from IR without placeholders", async () => { + const modelTexts = await emitModels(` + model Entry { + id: string; + } + + model EntryPage { + @pageItems items: Entry[]; + + @nextLink + nextLink?: string; + } + + @route("/entries") + @get + @list + op listEntries(): EntryPage; + `); + + expectResolvedHelpers( + modelTexts, + /export function entryArrayDeserializer\(/ + ); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5ebed25a9b..720b0afa58 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -478,6 +478,34 @@ importers: specifier: ^4.1.0 version: 4.1.1(@opentelemetry/api@1.9.0)(@types/node@25.3.5)(vite@6.4.1(@types/node@25.3.5)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + packages/typespec-ts-pristine: + dependencies: + ts-morph: + specifier: ^23.0.0 + version: 23.0.0 + tslib: + specifier: ^2.3.1 + version: 2.8.1 + devDependencies: + '@azure-tools/typespec-client-generator-core': + specifier: ^0.68.0 + version: 0.68.0(c90cd83acd0ea02e752c62bdfeeead80) + '@typespec/compiler': + specifier: ^1.12.0 + version: 1.12.0(@types/node@25.3.5) + '@typespec/http': + specifier: ^1.12.0 + version: 1.12.0(@typespec/compiler@1.12.0(@types/node@25.3.5))(@typespec/streams@0.82.0(@typespec/compiler@1.12.0(@types/node@25.3.5))) + '@typespec/rest': + specifier: ^0.82.0 + version: 0.82.0(@typespec/compiler@1.12.0(@types/node@25.3.5))(@typespec/http@1.12.0(@typespec/compiler@1.12.0(@types/node@25.3.5))(@typespec/streams@0.82.0(@typespec/compiler@1.12.0(@types/node@25.3.5)))) + '@typespec/versioning': + specifier: ^0.82.0 + version: 0.82.0(@typespec/compiler@1.12.0(@types/node@25.3.5)) + typescript: + specifier: ~5.6.3 + version: 5.6.3 + packages: '@alloy-js/core@0.23.1': @@ -5084,6 +5112,11 @@ packages: peerDependencies: typescript: 5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x || 5.7.x || 5.8.x || 5.9.x || 6.0.x + typescript@5.6.3: + resolution: {integrity: sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==} + engines: {node: '>=14.17'} + hasBin: true + typescript@5.8.2: resolution: {integrity: sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==} engines: {node: '>=14.17'} @@ -10775,6 +10808,8 @@ snapshots: typescript: 5.9.3 yaml: 2.8.3 + typescript@5.6.3: {} + typescript@5.8.2: {} typescript@5.8.3: {}