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/.squad/agents/dallas/history.md b/.squad/agents/dallas/history.md new file mode 100644 index 0000000000..3f873879af --- /dev/null +++ b/.squad/agents/dallas/history.md @@ -0,0 +1,123 @@ +# Project Context + +- **Owner:** Maor Leger +- **Project:** autorest.typescript — TypeSpec TS emitter refactor to align with Rust/Go emitter architecture. +- **Stack:** TypeScript, pnpm, TypeSpec, ts-morph, vitest, TCGC. +- **Key paths:** + - Emitter source: `packages/typespec-ts/src/` (modular/, rlc/, codemodel/, framework/, static-helpers/) + - Shared RLC: `packages/rlc-common/` + - Smoke fixtures: `packages/typespec-test/test/` + - Unit tests: `packages/typespec-ts/test/unit` (RLC), `packages/typespec-ts/test/modularUnit` (Modular) + - Integration: `test/integration`, `test/modularIntegration`, `test/azureIntegration`, `test/azureModularIntegration` +- **Build/test commands:** `pnpm install`, `pnpm build`, `pnpm format`, `npm run unit-test`, `npm run lint`, `npm run integration-test-ci:{rlc|modular|azure-rlc|azure-modular}`, `npm run smoke-test`. +- **Reference architectures:** `~/workspace/emitter-chain/typespec-rust`, `~/workspace/emitter-chain/autorest.go`, doc at `/home/maorleger/workspace/emitter-chain/go-rust.md`. +- **Hands-off:** `packages/autorest.typescript/` is in maintenance mode. +- **Existing codemodel pattern:** `src/codemodel/` already uses types.ts + build-*.ts + render-*.ts separation — likely the seed for the broader refactor. +- **Created:** 2026-05-15 + +## Learnings + + + +### 2026-05-15 — Stage 1 client context pipeline swap + +- Replaced the modular `$onEmit` client-context call in `packages/typespec-ts/src/index.ts` from `buildClientContext(dpgContext, subClient, modularEmitterOptions)` to `adaptSingleClient(subClient, dpgContext, modularEmitterOptions)` plus `emitClientContext(project, tsClient, generationSettings)`. +- Kept `buildOperationFiles`, `buildClassicalClient`, and the rest of the modular source pipeline unchanged; only the client context path was swapped in-place. +- Adjusted `packages/typespec-ts/src/codegen/clients.ts` to preserve prior client-context output semantics, including nested subfolder paths, api-version required/interface behavior, options typing, and passthrough of endpoint-assigned optional params. +- Validation: `pnpm build` passed, `cd packages/typespec-ts && npm run unit-test` passed, and `cd packages/typespec-ts && npm run copy:typespec && npm run integration-test-ci:modular` passed. `npm run lint` currently fails with a pre-existing ESLint/@typescript-eslint rule loading error while linting `src/codegen/clients.ts`. + +### 2026-05-15 — Ripley Staged Refactor Plan: Three-Layer Pipeline (appended by Scribe) + +**Staged refactor plan approved and ready for implementation:** + +Ripley completed a 9-stage refactor plan (`.squad/decisions/ripley-staged-refactor-plan.md`) to decouple TCGC from rendering: + +1. **Three-layer architecture:** + - **CodeModel (IR):** `TSCodeModel` capturing emitter intent, zero TCGC. + - **TCGC Adapter:** Transforms TCGC → CodeModel; isolated in `src/tcgcadapter/`. + - **CodeGen:** Renders CodeModel → ts-morph AST; consumed by `src/codegen/`. + +2. **Stage structure (Stages 1–9; Stage 10 dropped):** + - Stages 1–2: Adapter validation (TCGC→CodeModel only). + - Stages 3–6: Codegen expansion (clients, operations, models, classicalClient). + - Stages 7–9: Helper migration, cleanup, polish. + +3. **Key directives applied:** + - ✅ No feature flag (swap-in-place migration). + - ✅ Adapter unit tests as primary validation surface. + - ✅ Readability-first file organization (monolithic until needed). + - ✅ Skip lint guard (trust patterns). + - ✅ Skip Stage 10 (package separation). + +**Your work (PRD 3 onwards) aligns with Stages 3–6.** See full plan for stage boundaries and test matrix. + +### 2026-05-15 — Lambert Cross-Agent Summary: Architecture Analysis Findings (appended by Scribe) + +**From Lambert's comparative analysis** (filed to decisions.md 2026-05-15): + +Key findings for **Dallas** (codegen layer development): +1. **Codegen target is well-defined.** The POC's `src/codegen/` provides a template. `emitFromCodeModel()` orchestrator + `emitClientContext()` renderer (for clients.ts category) show the pattern. Zero TCGC imports in this layer — verify via lint. +2. **IR shape (`TSCodeModel`) needs extension.** Current POC covers client context files only. Add to IR for **your** work scope: + - `TSOperationFile` for operations (PRD 3) + - `TSModel`, `TSEnum`, `TSUnion` for types (PRD 7) + - Model-scoped types: `TSProperty`, `TSPropertyConstraint`, `TSModelBase` (reference Rust's `codemodel/types.ts` pattern) +3. **Adapter helpers reuse is pragmatic but temporary.** POC imports from old `src/modular/helpers/` (clientHelpers, operationHelpers). As each old `build*` migrates, its helpers either move into `src/tcgcadapter/` (if TCGC-aware) or into `src/codegen/` (if IR-only). Plan this as part of each adapter extension. +4. **Rendering machinery is stable.** `resolveReference()` and `useDependencies()` from framework are acceptable as narrow hooks in codegen (not TCGC leakage). Equivalent to Go's `FsFacilities` injector — a contract for framework services. +5. **Test all codegen outputs via integration suites.** Each category (operations, models, classicalClient, etc.) is tested by modular/azure-modular integration suites; smoke test catches compilation failures. Use these as your regression oracle. + +### 2026-05-15 — Stage 0 Infrastructure Verification + +- The `origin/poc-emitter-separation` POC commit `4459962` was already present on `squad-rewrite` under commit `3542d9e8c`, with the same additive `src/codemodel/`, `src/tcgcadapter/`, and `src/codegen/` files plus the fixture `.d.ts` deletions. +- Verified the Stage 0 infrastructure remains unwired to `$onEmit`; no existing emitter entrypoints were changed as part of this slice. +- `pnpm build` passed at repo root and `npm run unit-test` passed in `packages/typespec-ts/` without needing follow-up fixes. + +### 2026-05-15 — Stage 2 operation IR expansion + +- `packages/typespec-ts/src/codemodel/index.ts` now models operations with data-only shapes: `TSMethod`, `TSParameter`, `TSReturnType`, `TSRoute`, and `TSOperationGroup`. +- `packages/typespec-ts/src/tcgcadapter/adapter.ts` now exports `adaptMethods()` and `adaptOperationGroups()` so operation extraction can be tested separately from full-client adaptation. +- The operation-group IR keeps `prefixes` alongside `name` and `methods` so future rendering can recover nested group paths without reaching back into TCGC. +- Validation for this slice was `pnpm build` at repo root and `npm run unit-test` in `packages/typespec-ts`. + +### 2026-05-16 — Stage 4 operations codegen wiring + +- `packages/typespec-ts/src/codegen/operations.ts` now serves as the modular operations renderer, consuming `TSClient`/`TSOperationGroup` IR and emitting stable `api/**/operations.ts` files directly through ts-morph. +- The emitter path keeps the generated operation helpers deterministic by sorting operation files by their normalized path and running `fixMissingImports(..., { importModuleSpecifierEnding: "js" })` before trimming unused imports. +- Validation for this slice was `pnpm build`, `cd packages/typespec-ts && npm run unit-test`, and `cd packages/typespec-ts && npm run copy:typespec && npm run integration-test-ci:modular`. + +### 2026-05-19 — squad-rewrite regression fixes + +- Restored top-level `api/**` recursion in `packages/typespec-ts/src/codegen/indexFiles.ts` so the root barrel once again reaches generated `./api//index.js` subbarrels and their `*OptionalParams` exports. +- Changed `packages/typespec-ts/src/codegen/clients.ts` to respect adapted `TSClientParameter.required` metadata for client contexts, and added a modular unit test that keeps defaulted client `apiVersion` optional in the generated `*Context` interface. +- Switched `packages/typespec-ts/src/codegen/models.ts` to select raw model/enum/union declarations from filtered `TSCodeModel` IR lookups instead of the legacy global emit queue, which keeps paging `*ListResult` shapes internal unless the adapter actually exposes them. +- Triaged user report regressions: 4 confirmed (indexFiles subpath barrel, clients apiVersion requiredness, models paging leak, dedupe workaround); 1 misdiagnosed (coreClient import text churn); 2 expected (import path normalization, beginX wrapper reappearance). +- Commits: d24c6178d (indexFiles), e35c3244d (apiVersion), e8b5a8022 (models). Pushed origin/squad-rewrite tip 56aa9c54f. +- Validation: `pnpm build` ✅, `npm run unit-test` in `packages/typespec-ts/` ✅. + +--- + +## 2026-05-19T23:30:29.807+00:00 — B8 Fix: Array/dict serializer helper placeholders + +**Task:** Fix P0 regression introduced by e8b5a8022 where `src/models/models.ts` contained +unresolved `__PLACEHOLDER_*__` tokens for array/dict serializer helpers. + +**Strategy chosen:** Strategy A (Renderer emits the missing helpers) + +**Rationale:** Strategy B would require adding `helperTypes` to `TSCodeModel` and updating the +tcgcadapter — a broader change. Strategy A is a targeted, correct fix: the renderer simply +needs to walk the same `emitQueue` entries (array/dict kinds) that the legacy `emitTypes()` +did, calling `emitType()` to register the serializer/deserializer refkeys with the binder. +A TODO comment and follow-up note were left for Strategy B migration. + +**Files touched:** +- `packages/typespec-ts/src/codegen/models.ts` — import `emitQueue`; add loop for array/dict types +- `packages/typespec-ts/test/modularUnit/models-helpers.spec.ts` — new regression-locking tests +- `.squad/decisions/inbox/dallas-models-helpers.md` — follow-up note for IR migration + +**Validation:** +- `pnpm build` — passed +- `npm run unit-test` (typespec-ts) — 664 tests passed, 0 failures +- Regenerated `NetworkAnalytics.Management` (azure-modular tag) — zero `__PLACEHOLDER_` matches +- `tsc --noEmit` on generated NetworkAnalytics package — only missing `@azure/identity` in samples (pre-existing, unrelated), zero TS2304 errors +- B8 regression tests (array + dict) — both pass green + +**Open follow-up:** `.squad/decisions/inbox/dallas-models-helpers.md` — migrate array/dict helper types into TSCodeModel IR to remove `emitQueue` side-channel dependency from the codegen layer. diff --git a/.squad/decisions/inbox/dallas-models-helpers.md b/.squad/decisions/inbox/dallas-models-helpers.md new file mode 100644 index 0000000000..9d1611635b --- /dev/null +++ b/.squad/decisions/inbox/dallas-models-helpers.md @@ -0,0 +1,45 @@ +# Follow-up: Migrate array/dict helper types into TSCodeModel IR + +**Date:** 2026-05-19T23:30:29.807+00:00 +**Author:** Dallas (Refactor Engineer) +**Status:** Open / Follow-up + +## Context + +B8 fix (Strategy A) in `src/codegen/models.ts` restores array/dict serializer helper +registration by walking the global `emitQueue` side-channel (the same set that the +legacy `emitTypes()` in `src/modular/emitModels.ts` used). + +## Problem with current approach + +`emitQueue` is a module-level `Set` populated by `visitPackageTypes()` (called +via `provideSdkTypes()`). The new filtered-IR renderer in `src/codegen/models.ts` is +supposed to work exclusively from `TSCodeModel` (pure IR, no TCGC) — but the B8 fix still +reaches back into `emitQueue`, which is a TCGC-layer artifact. + +This violates the layer boundary: + +``` +src/tcgcadapter → src/codemodel (IR) → src/codegen + ↑ + Should own array/dict helpers +``` + +## Recommended follow-up + +Add array and dictionary helper types explicitly to `TSCodeModel` so `emitModelFiles` can +emit them purely from IR: + +1. Add a `helperTypes` (or `arrayDictHelpers`) field to `TSCodeModel` in + `src/codemodel/index.ts` containing the array/dict types that serializer builders + will reference. +2. Populate it in `src/tcgcadapter/adapter.ts` by walking the types reachable from + `models`, `enums`, and `unions` and collecting all `SdkArrayType` / `SdkDictionaryType` + that require serializer helpers. +3. Update `src/codegen/models.ts` to iterate `codeModel.helperTypes` instead of `emitQueue`. +4. Remove the `emitQueue` import from `src/codegen/models.ts`. + +## Risk / priority + +Low risk to defer — Strategy A is a correct and complete fix for B8. +This is a cleanup task to keep the three-layer architecture clean. diff --git a/packages/typespec-ts/docs/ARCHITECTURE.md b/packages/typespec-ts/docs/ARCHITECTURE.md new file mode 100644 index 0000000000..79193b844c --- /dev/null +++ b/packages/typespec-ts/docs/ARCHITECTURE.md @@ -0,0 +1,441 @@ +# 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 match the structure used by +`typespec-rust` and `autorest.go`. 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. + +The pipeline mirrors `tcgcadapter → codemodel → codegen` in `typespec-rust` +and `tcgcadapter → codemodel → codegen` in `autorest.go`. Cross-references +to those repos are inlined in the source headers (e.g., +`src/tcgcadapter/adapter.ts:7-10`). + +--- + +## 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. Match `typespec-rust`'s explicit-context pattern by threading + the context through `emit*` calls. + +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..692e433cf0 --- /dev/null +++ b/packages/typespec-ts/src/codemodel/index.ts @@ -0,0 +1,481 @@ +// 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. Maps to Go's `go.Client` and + * Rust's `rust.Client`. + * + * 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\(/ + ); + }); +});