diff --git a/.chronus/changes/azurite-emitter.md b/.chronus/changes/azurite-emitter.md new file mode 100644 index 0000000000..bc09bd3a01 --- /dev/null +++ b/.chronus/changes/azurite-emitter.md @@ -0,0 +1,7 @@ +--- +changeKind: feature +packages: + - "@azure-tools/typespec-azurite-emitter" +--- + +Add @azure-tools/typespec-azurite-emitter, a TypeSpec emitter generating Azurite server artifacts (models, route metadata, serialization helpers, handler interfaces) from HTTP service definitions. diff --git a/packages/typespec-azurite-emitter/LICENSE b/packages/typespec-azurite-emitter/LICENSE new file mode 100644 index 0000000000..21071075c2 --- /dev/null +++ b/packages/typespec-azurite-emitter/LICENSE @@ -0,0 +1,21 @@ + MIT License + + Copyright (c) Microsoft Corporation. All rights reserved. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE diff --git a/packages/typespec-azurite-emitter/README.md b/packages/typespec-azurite-emitter/README.md new file mode 100644 index 0000000000..d8253035ce --- /dev/null +++ b/packages/typespec-azurite-emitter/README.md @@ -0,0 +1,134 @@ +# @azure-tools/typespec-azurite-emitter + +Prototype TypeSpec emitter for generating the Queue server artifacts Azurite needs from a +compiled HTTP service plus an `azurite.tsp` overlay. + +The package demonstrates a small but reusable emitter structure: + +1. `src/build-model.ts` reads `@typespec/http` operations and TypeSpec models into an + emitter-owned server model. Unsupported wire shapes are reported as emitter diagnostics rather + than rendered as `unknown` or substituted defaults. +2. `src/render/` writes TypeScript files from that model: + - `models.ts` — data-model interfaces plus colocated compact XML model descriptors. + - `operations.ts` — request/response types plus colocated compact HTTP operation descriptors. + - `metadata.ts` — a small service manifest that references the colocated descriptors without + duplicating the mapping graph. + - `handlers.ts` — handler interface methods with `(params, context)` signatures. + - `serialization.ts` — a tiny binding from `metadata.ts` to Azurite's shared serialization + runtime. + +## Usage + +```bash +tsp compile . --emit @azure-tools/typespec-azurite-emitter +``` + +or in `tspconfig.yaml`: + +```yaml +emit: + - "@azure-tools/typespec-azurite-emitter" +options: + "@azure-tools/typespec-azurite-emitter": + outputDir: "." # optional, relative to the emitter output dir + runtimeImport: "../runtime/serializationRuntime" # optional, as emitted in generated TS +``` + +## Azurite overlays + +An Azurite-owned overlay can import the shared Storage TypeSpec and apply emulator-specific +changes without editing the shared source. This package includes `@makeOptional` for required +properties that the emulator accepts as optional: + +```tsp +import "@azure-tools/typespec-azurite-emitter"; +import "./main.tsp"; + +using Azurite; +using Storage.Queues; + +@@makeOptional(Storage.Queues.AccessPolicy.start); +@@makeOptional(Storage.Queues.AccessPolicy.expiry); +@@makeOptional(Storage.Queues.AccessPolicy.permission); +``` + +Core TypeSpec augment decorators can be used for ordinary constraints when emulator semantics +intentionally differ from the shared source: + +```tsp +@@minValue(Storage.Queues.CustomParameter.value, 0); +``` + +The emitter requires exactly one HTTP service. HTTP-library diagnostics and unsupported operation +shapes (for example arbitrary unions or scalars without a known wire representation) are surfaced +as diagnostics and prevent incomplete generated artifacts from being written. + +## Tests + +- `test/build-model.test.ts` validates the intermediate model, including operation metadata, + referenced models, `Record`, anonymous models, operation-name collisions, and overlay + changes. +- `test/render.test.ts` validates rendering from a hand-built server model. +- `test/e2e.test.ts` compiles the fixture through the emitter and checks the generated files. +- `test/azurite-compat.test.ts` checks that generated handler and route-metadata shapes contain + what Azurite's Queue runtime needs to wire dispatch, handlers, and serialization. + +Run: + +```bash +pnpm --filter @azure-tools/typespec-azurite-emitter test +pnpm --filter @azure-tools/typespec-azurite-emitter build +pnpm --filter @azure-tools/typespec-azurite-emitter lint +``` + +## Azurite bridge contract + +The emitter generates TypeSpec-derived artifacts only. Azurite still owns the runtime bridge that +selects a generated operation, calls the generated deserializer, invokes the handwritten handler, +and passes the handler result to the generated serializer. That bridge must: + +1. Run Azurite's storage-context middleware before generated deserialization so route values are + available on the generated `Context` object. +2. Call `deserializeRequest(operationName, req, context)`, where `context` exposes path parameter + values by generated parameter name (for Queue today, `messageId`). The generated deserializer + returns the exact generated `Parameters` shape: parameter properties are flat, the + request body is assigned to `body`, and query/header/path values use the same type conversion + rules. +3. Invoke the generated handler interface as `(params, context) => Promise` with that + parameters object directly; the bridge must select the handler method but must not reshape the + request payload. +4. Call `serializeResponse(operationName, res, handlerResponse)` with the exact generated + `Response` union. Response headers are read from `handlerResponse.headers` while + wire-name mapping remains in generated response metadata; the bridge must not flatten response + headers before serialization. + +Stable request deserialization, primitive conversion, XML body conversion, response serialization, +header collection handling, and generated-operation lookup live in an Azurite-owned handwritten +runtime imported by generated artifacts. The runtime module path is controlled by `runtimeImport` +and defaults to `../runtime/serializationRuntime` from the generated directory. That module must +export invariant metadata types (`OperationMetadata`, `OperationTypeBinding`, +`XmlModelMetadata`, and related binding types), compact descriptor helpers (`defineOperation`, +`defineXmlModel`, `defineServiceMetadata`), and `createSerializationRuntime(serviceMetadata)`, +which returns `deserializeRequest`, `serializeResponse`, and `hasGeneratedSerialization`. This +follows the same persistence principle as `http-client-js`: TypeScript interfaces are erased, so +wire knowledge is emitted as runtime values next to the declaration it describes. Header +collections are described generically with `collectionPrefix` metadata (for example +`x-ms-meta-`), not with Queue-specific runtime special cases. XML property descriptors also carry +requiredness and sparse numeric constraints (`min`, `max`, `minExclusive`, `maxExclusive`) so +malformed bodies and numeric path/query/header values can be rejected by the runtime before they +reach handlers or persistence. `metadata.ts` may export `operations` as a compatibility alias, but +it references `serviceMetadata.operations` and does not duplicate descriptor data. + +Middleware/handler invocation code is intentionally not emitted by this package in the pilot: that +code is Azurite-owned integration logic, varies by storage service, and must compose with +Azurite's existing authentication, dispatch, error, and handler middleware. + +## Current scope + +This package is intentionally small. It covers the artifact shape and emitter architecture needed +for a Queue handoff, not a full Storage implementation. The companion Azurite PR validates the +generated Queue files against Azurite's real runtime. It includes query/header binding, +response-header serialization, and XML request/response body serialization for Queue operations +through shared helper functions instead of AutoRest-style mapper tables. Blob, Table/OData, +streaming, multipart bodies, versioning, pagination, and richer enum/union rendering are future +work. diff --git a/packages/typespec-azurite-emitter/generated-defs/Azurite.ts b/packages/typespec-azurite-emitter/generated-defs/Azurite.ts new file mode 100644 index 0000000000..2be42a747a --- /dev/null +++ b/packages/typespec-azurite-emitter/generated-defs/Azurite.ts @@ -0,0 +1,19 @@ +import type { + DecoratorContext, + DecoratorValidatorCallbacks, + ModelProperty, +} from "@typespec/compiler"; + +/** + * Marks a model property as optional for Azurite without editing the shared service TypeSpec. + * + * @param target The model property to make optional. + */ +export type MakeOptionalDecorator = ( + context: DecoratorContext, + target: ModelProperty, +) => DecoratorValidatorCallbacks | void; + +export type AzuriteDecorators = { + makeOptional: MakeOptionalDecorator; +}; diff --git a/packages/typespec-azurite-emitter/generated-defs/Azurite.ts-test.ts b/packages/typespec-azurite-emitter/generated-defs/Azurite.ts-test.ts new file mode 100644 index 0000000000..66b83c7bd8 --- /dev/null +++ b/packages/typespec-azurite-emitter/generated-defs/Azurite.ts-test.ts @@ -0,0 +1,10 @@ +// An error in the imports would mean that the decorator is not exported or +// doesn't have the right name. + +import { $decorators } from "@azure-tools/typespec-azurite-emitter"; +import type { AzuriteDecorators } from "./Azurite.js"; + +/** + * An error here would mean that the exported decorator is not using the same signature. Make sure to have export const $decName: DecNameDecorator = (...) => ... + */ +const _decs: AzuriteDecorators = $decorators["Azurite"]; diff --git a/packages/typespec-azurite-emitter/lib/decorators.tsp b/packages/typespec-azurite-emitter/lib/decorators.tsp new file mode 100644 index 0000000000..67ee0d1895 --- /dev/null +++ b/packages/typespec-azurite-emitter/lib/decorators.tsp @@ -0,0 +1,12 @@ +import "../dist/src/index.js"; + +using Reflection; + +namespace Azurite; + +/** + * Marks a model property as optional for Azurite without editing the shared service TypeSpec. + * + * @param target The model property to make optional. + */ +extern dec makeOptional(target: ModelProperty); diff --git a/packages/typespec-azurite-emitter/lib/main.tsp b/packages/typespec-azurite-emitter/lib/main.tsp new file mode 100644 index 0000000000..29ee772dd0 --- /dev/null +++ b/packages/typespec-azurite-emitter/lib/main.tsp @@ -0,0 +1 @@ +import "./decorators.tsp"; diff --git a/packages/typespec-azurite-emitter/package.json b/packages/typespec-azurite-emitter/package.json new file mode 100644 index 0000000000..26ee06a54e --- /dev/null +++ b/packages/typespec-azurite-emitter/package.json @@ -0,0 +1,75 @@ +{ + "name": "@azure-tools/typespec-azurite-emitter", + "version": "0.1.0", + "description": "TypeSpec emitter generating Azurite server artifacts (models, route metadata, serialization helpers, handler interfaces).", + "type": "module", + "license": "MIT", + "author": "Microsoft Corporation", + "keywords": [ + "typespec", + "emitter", + "azurite" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/Azure/typespec-azure.git" + }, + "bugs": { + "url": "https://github.com/Azure/typespec-azure/issues" + }, + "engines": { + "node": ">=22.0.0" + }, + "main": "dist/src/index.js", + "types": "dist/src/index.d.ts", + "tspMain": "lib/main.tsp", + "files": [ + "lib/**", + "dist/**", + "!dist/test/**" + ], + "exports": { + ".": { + "typespec": "./lib/main.tsp", + "import": "./dist/src/index.js" + } + }, + "scripts": { + "build": "pnpm gen-extern-signature && alloy build", + "watch": "alloy build --watch", + "clean": "rimraf ./dist ./temp", + "gen-extern-signature": "tspd --enable-experimental gen-extern-signature .", + "lint": "oxlint . --deny-warnings", + "lint:fix": "oxlint . --fix", + "test": "vitest run", + "test:watch": "vitest -w", + "test:ui": "vitest --ui", + "test:ci": "vitest run --coverage --reporter=junit --reporter=default" + }, + "dependencies": { + "@alloy-js/core": "catalog:", + "@alloy-js/typescript": "catalog:", + "@typespec/emitter-framework": "workspace:^" + }, + "peerDependencies": { + "@azure-tools/typespec-client-generator-core": "workspace:^", + "@typespec/compiler": "workspace:^", + "@typespec/http": "workspace:^", + "@typespec/xml": "workspace:^" + }, + "devDependencies": { + "@alloy-js/cli": "catalog:", + "@alloy-js/rollup-plugin": "catalog:", + "@azure-tools/typespec-client-generator-core": "workspace:^", + "@types/node": "catalog:", + "@typespec/compiler": "workspace:^", + "@typespec/http": "workspace:^", + "@typespec/xml": "workspace:^", + "@typespec/tspd": "workspace:^", + "@vitest/coverage-v8": "catalog:", + "@vitest/ui": "catalog:", + "rimraf": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" + } +} diff --git a/packages/typespec-azurite-emitter/src/build-model.ts b/packages/typespec-azurite-emitter/src/build-model.ts new file mode 100644 index 0000000000..3e68e6062f --- /dev/null +++ b/packages/typespec-azurite-emitter/src/build-model.ts @@ -0,0 +1,795 @@ +import * as ts from "@alloy-js/typescript"; +import { + $doc, + type DecoratorContext, + type Model, + type ModelProperty, + type Type, +} from "@typespec/compiler"; +import { + getAllHttpServices, + type HttpOperation, + type HttpOperationParameter, + type HttpOperationResponse, + type HttpPayloadBody, +} from "@typespec/http"; +import "@typespec/http/experimental/typekit"; +import { isAttribute, isUnwrapped } from "@typespec/xml"; +import type { AzuriteEmitterContext } from "./context.js"; +import { createDiagnostic } from "./lib.js"; +import type { + ServerLiteralQueryParameter, + ServerModel, + ServerModelProperty, + ServerNumericConstraints, + ServerOperation, + ServerOperationParameter, + ServerParameterLocation, + ServerRequestBody, + ServerResponse, + ServerResponseHeader, + ServerSkippedOperation, + ServerTypeRef, +} from "./model.js"; +import { getName, hasNameOverride } from "./utils.js"; + +const tsNamePolicy = ts.createTSNamePolicy(); + +function getTypeName(name: string): string { + return tsNamePolicy.getName(name, "type"); +} + +interface TypeMetadata { + readonly runtime: ServerTypeRef; + readonly declaration: Type; +} + +type BuiltOperationParameter = ServerOperationParameter & { readonly declarationType: Type }; +type BuiltRequestBody = ServerRequestBody & { readonly declarationType: Type }; +type BuiltResponseHeader = ServerResponseHeader & { readonly declarationType: Type }; +type BuiltResponse = Omit & { + readonly headers: BuiltResponseHeader[]; + readonly body?: BuiltRequestBody; +}; + +interface OperationNamePlan { + readonly runtimeName: string; + readonly typeName: string; +} + +interface OperationNameCandidate { + readonly op: HttpOperation; + readonly baseRuntimeName: string; + readonly explicitRuntimeName: boolean; +} + +/** + * Builds the intermediate {@link ServerModel} for the (single) HTTP service found in the + * compiled program. This is the "transform" phase: it walks `@typespec/http` metadata and + * TypeSpec model types and produces a simplified, render-ready representation. + * + * Operations the transform phase can't represent are skipped with a reason in + * `skippedOperations`. + */ +export function buildServerModel( + context: AzuriteEmitterContext, + service = getAllHttpServices(context.program)[0][0], +): ServerModel { + const operations: ServerOperation[] = []; + const skippedOperations: ServerSkippedOperation[] = []; + const operationNames = planOperationNames(context, service?.operations ?? []); + for (const op of service?.operations ?? []) { + try { + const names = operationNames.get(op); + if (names === undefined) continue; + const built = buildOperation(context, names.runtimeName, names.typeName, op); + operations.push(built); + } catch (error) { + skippedOperations.push({ + name: getName(context, op.operation, getTypeName(op.operation.name)), + reason: error instanceof Error ? error.message : String(error), + target: op.operation, + }); + } + } + + return { + serviceName: service ? getName(context, service.namespace, service.namespace.name) : "Service", + operations, + models: [...context.modelRegistry.values()], + skippedOperations, + }; +} + +function buildOperation( + context: AzuriteEmitterContext, + name: string, + typeName: string, + op: HttpOperation, +): ServerOperation { + const parameters = op.parameters.parameters.map((p) => buildParameter(context, p)); + + const requestBody = op.parameters.body + ? buildRequestBody(context, op.parameters.body) + : undefined; + + const responses = op.responses.map((r) => buildResponse(context, r)); + const route = splitRoutePath(op.path); + const doc = getDocHelper(context, op.operation); + const operation: ServerOperation = { + name, + typeName, + verb: op.verb, + rawPath: op.path, + path: route.path, + literalQueryParameters: route.literalQueryParameters, + parameters: parameters.map(stripParameter), + parametersModel: createOperationParametersModel( + context, + typeName, + parameters, + requestBody, + doc, + ), + requestBody: requestBody && stripRequestBody(requestBody), + responses: responses.map(stripResponse), + responseUnion: createOperationResponseUnion(context, typeName, responses), + doc, + interfaceName: op.operation.interface?.name, + }; + + return operation; +} + +function planOperationNames( + context: AzuriteEmitterContext, + operations: readonly HttpOperation[], +): Map { + const candidates = operations.map((op) => ({ + op, + baseRuntimeName: getName(context, op.operation, getTypeName(op.operation.name)), + explicitRuntimeName: hasNameOverride(context, op.operation), + })); + const runtimeNames = resolveRuntimeOperationNames(context, candidates); + const typeNames = resolveOperationTypeNames(context, runtimeNames); + return new Map( + [...runtimeNames.entries()] + .filter(([op]) => typeNames.has(op)) + .map(([op, runtimeName]) => [op, { runtimeName, typeName: typeNames.get(op)! }]), + ); +} + +function resolveRuntimeOperationNames( + context: AzuriteEmitterContext, + candidates: readonly OperationNameCandidate[], +): Map { + const resolved = new Map(); + for (const group of groupBy(candidates, (candidate) => candidate.baseRuntimeName).values()) { + if (group.length === 1) { + resolved.set(group[0].op, group[0].baseRuntimeName); + continue; + } + if (group.some((candidate) => candidate.explicitRuntimeName)) { + reportNameCollision( + context, + group, + group[0].baseRuntimeName, + "duplicate explicit or effective runtime operation names are not qualified automatically", + ); + continue; + } + const qualified = qualifyOperationNameGroup(context, group, group[0].baseRuntimeName); + for (const [op, name] of qualified) resolved.set(op, name); + } + return resolved; +} + +function resolveOperationTypeNames( + context: AzuriteEmitterContext, + runtimeNames: ReadonlyMap, +): Map { + const planned = new Map(); + for (const group of groupBy([...runtimeNames.entries()], ([, name]) => + getTypeName(name), + ).values()) { + if (group.length === 1) { + planned.set(group[0][0], getTypeName(group[0][1])); + continue; + } + const candidates = group.map(([op, runtimeName]) => ({ + op, + baseRuntimeName: getTypeName(runtimeName), + explicitRuntimeName: false, + })); + const qualified = qualifyOperationNameGroup(context, candidates, getTypeName(group[0][1])); + for (const [op, name] of qualified) planned.set(op, name); + } + return planned; +} + +function qualifyOperationNameGroup( + context: AzuriteEmitterContext, + group: readonly OperationNameCandidate[], + collidingName: string, +): Map { + const qualified = new Map(); + for (const candidate of group) { + const interfaceName = candidate.op.operation.interface?.name; + if (!interfaceName) { + reportNameCollision( + context, + [candidate], + collidingName, + "operation has no interface name available for deterministic qualification", + ); + continue; + } + qualified.set(candidate.op, `${getTypeName(interfaceName)}${candidate.baseRuntimeName}`); + } + for (const duplicateGroup of groupBy([...qualified.entries()], ([, name]) => name).values()) { + if (duplicateGroup.length <= 1) continue; + reportNameCollision( + context, + duplicateGroup.map(([op]) => ({ + op, + baseRuntimeName: duplicateGroup[0][1], + explicitRuntimeName: false, + })), + duplicateGroup[0][1], + "interface-qualified operation names still collide", + ); + for (const [op] of duplicateGroup) qualified.delete(op); + } + return qualified; +} + +function reportNameCollision( + context: AzuriteEmitterContext, + group: readonly OperationNameCandidate[], + name: string, + reason: string, +): void { + for (const candidate of group) { + context.diagnostics.push( + createDiagnostic({ + code: "operation-name-collision", + format: { name, reason }, + target: candidate.op.operation, + }), + ); + } +} + +function groupBy(items: Iterable, keySelector: (item: T) => TKey): Map { + const groups = new Map(); + for (const item of items) { + const key = keySelector(item); + const group = groups.get(key); + if (group) { + group.push(item); + } else { + groups.set(key, [item]); + } + } + return groups; +} + +function splitRoutePath(path: string): { + path: string; + literalQueryParameters: ServerLiteralQueryParameter[]; +} { + const queryStart = path.indexOf("?"); + if (queryStart === -1) { + return { path, literalQueryParameters: [] }; + } + + const routePath = path.slice(0, queryStart); + const query = path.slice(queryStart + 1); + const literalQueryParameters = query + .split("&") + .filter(Boolean) + .map((part) => { + const [rawName, rawValue = ""] = part.split("="); + return { + name: decodeURIComponent(rawName), + value: decodeURIComponent(rawValue), + }; + }); + + return { path: routePath, literalQueryParameters }; +} + +function buildParameter( + context: AzuriteEmitterContext, + param: HttpOperationParameter, +): BuiltOperationParameter { + const location: ServerParameterLocation = + param.type === "cookie" ? "header" : (param.type as ServerParameterLocation); + const type = isHeaderCollection(param.name) + ? getHeaderCollectionTypeMetadata(context) + : getTypeMetadata(context, param.param.type, param.param); + return { + name: getName(context, param.param, param.param.name), + wireName: param.name, + location, + type: type.runtime, + declarationType: type.declaration, + optional: param.param.optional, + }; +} + +function buildRequestBody(context: AzuriteEmitterContext, body: HttpPayloadBody): BuiltRequestBody { + const type = getTypeMetadata(context, body.type, body.property ?? body.type); + return { + type: type.runtime, + declarationType: type.declaration, + contentTypes: body.contentTypes, + parameterPath: "body", + }; +} + +function buildResponse( + context: AzuriteEmitterContext, + response: HttpOperationResponse, +): BuiltResponse { + const content = response.responses[0]; + const headers: BuiltResponseHeader[] = []; + for (const [headerWireName, prop] of Object.entries(content?.headers ?? {})) { + const type = isHeaderCollection(headerWireName) + ? getHeaderCollectionTypeMetadata(context) + : getTypeMetadata(context, prop.type, prop); + headers.push({ + name: getName(context, prop, prop.name), + wireName: headerWireName, + type: type.runtime, + declarationType: type.declaration, + optional: prop.optional, + }); + } + return { + statusCode: typeof response.statusCodes === "number" ? response.statusCodes : "*", + headers, + body: + content?.body && response.statusCodes !== "*" + ? buildRequestBody(context, content.body) + : undefined, + }; +} + +function isHeaderCollection(wireName: string): boolean { + return wireName.toLowerCase() === "x-ms-meta"; +} + +function getHeaderCollectionTypeMetadata(context: AzuriteEmitterContext): TypeMetadata { + const tk = context.tk; + const value = tk.union.create([tk.builtin.string, tk.array.create(tk.builtin.string)]); + return { + runtime: { + kind: "record", + element: { + kind: "union", + variants: [{ kind: "string" }, { kind: "array", element: { kind: "string" } }], + }, + }, + declaration: tk.record.create(value), + }; +} + +function stripParameter(parameter: BuiltOperationParameter): ServerOperationParameter { + const { declarationType, ...serverParameter } = parameter; + return serverParameter; +} + +function stripRequestBody(body: BuiltRequestBody): ServerRequestBody { + const { declarationType, ...serverBody } = body; + return serverBody; +} + +function stripResponse(response: BuiltResponse): ServerResponse { + return { + ...response, + headers: response.headers.map(stripResponseHeader), + body: response.body && stripRequestBody(response.body), + }; +} + +function stripResponseHeader(header: BuiltResponseHeader): ServerResponseHeader { + const { declarationType, ...serverHeader } = header; + return serverHeader; +} + +function getTypeMetadata( + context: AzuriteEmitterContext, + type: Type, + constraintTarget: Type = type, +): TypeMetadata { + const tk = context.tk; + switch (type.kind) { + case "Scalar": + const scalarName = type.name || ""; + if (tk.scalar.extendsNumeric(type)) + return { + runtime: numericTypeRef(context, constraintTarget), + declaration: type, + }; + if (tk.scalar.extendsString(type)) return { runtime: { kind: "string" }, declaration: type }; + if ( + tk.scalar.extendsUtcDateTime(type) || + tk.scalar.extendsOffsetDateTime(type) || + tk.scalar.extendsPlainDate(type) || + tk.scalar.extendsPlainTime(type) + ) { + return { runtime: { kind: "datetime" }, declaration: tk.builtin.string }; + } + if (tk.scalar.extendsBoolean(type)) + return { runtime: { kind: "boolean" }, declaration: type }; + throw new Error(`unsupported scalar ${scalarName}`); + case "Boolean": + return { runtime: { kind: "literal", value: type.value }, declaration: type }; + case "String": + return { runtime: { kind: "literal", value: type.value }, declaration: type }; + case "Number": { + let value: number | null | undefined; + try { + value = type.numericValue.asNumber(); + } catch { + value = undefined; + } + if (value === undefined || value === null || !Number.isFinite(value)) { + throw new Error("numeric literal cannot be represented as a JavaScript number"); + } + return { + runtime: { kind: "literal", value }, + declaration: type, + }; + } + case "Enum": { + // TypeSpec string enums are represented on the wire as strings; generated TypeScript + // declarations keep them as string because Azurite handlers do not need enum objects. + return { runtime: { kind: "string" }, declaration: tk.builtin.string }; + } + case "Model": { + if (tk.array.is(type)) { + const element = getTypeMetadata(context, tk.array.getElementType(type)); + return { + runtime: { kind: "array", element: element.runtime }, + declaration: tk.array.create(element.declaration), + }; + } + if (tk.record.is(type)) { + const element = getTypeMetadata(context, tk.record.getElementType(type)); + return { + runtime: { kind: "record", element: element.runtime }, + declaration: tk.record.create(element.declaration), + }; + } + const runtime = registerModel(context, type); + return { + runtime, + declaration: + runtime.kind === "model" + ? context.modelRegistry.get(runtime.name)!.declarationModel + : type, + }; + } + case "Union": { + const variants = [...type.variants.values()].map( + (variant) => getTypeMetadata(context, variant.type).runtime, + ); + return { runtime: { kind: "union", variants }, declaration: tk.intrinsic.any }; + } + default: + throw new Error(`unsupported TypeSpec type kind ${type.kind}`); + } +} + +function registerModel(context: AzuriteEmitterContext, model: Model): ServerTypeRef { + const tk = context.tk; + const name = resolveModelName(context, model); + if (!context.modelRegistry.has(name)) { + const declarationModel = createDeclarationModel(context, name, model); + // Insert a placeholder first to guard against infinite recursion on cyclic models. + context.modelRegistry.set(name, { name, wireName: name, properties: [], declarationModel }); + const properties = [...tk.model.getProperties(model).values()].map((prop) => + buildModelProperty(context, prop, declarationModel), + ); + context.modelRegistry.set(name, { + name, + wireName: context.tk.type.getEncodedName(model, "application/xml"), + properties, + declarationModel, + doc: getDocHelper(context, model), + }); + } + return { kind: "model", name }; +} + +/** + * Resolves the generated TS type name for `model`. Named models use their TypeSpec name + * directly; anonymous models (`model.name === ""`) get a stable, unique `AnonymousModelN` name + * keyed by *object identity* so that two structurally-different anonymous models never clobber + * each other under the same cache key. + */ +function resolveModelName(context: AzuriteEmitterContext, model: Model): string { + if (model.name) return getName(context, model, getTypeName(model.name)); + const existingNamedModel = findStructurallyEquivalentNamedModel(context, model); + if (existingNamedModel) return existingNamedModel; + // Anonymous TypeSpec models all have an empty name, so identity is the only stable cache key. + const existing = context.anonymousModelNamesByType.get(model); + if (existing) return existing; + let candidate = anonymousModelName(context.nextAnonymousModelId++); + while (context.modelRegistry.has(candidate)) { + candidate = anonymousModelName(context.nextAnonymousModelId++); + } + context.anonymousModelNamesByType.set(model, candidate); + return candidate; +} + +function anonymousModelName(id: number): string { + return id === 1 ? "AnonymousModel" : `AnonymousModel${id}`; +} + +function findStructurallyEquivalentNamedModel( + context: AzuriteEmitterContext, + model: Model, +): string | undefined { + const tk = context.tk; + const anonymousProperties = [...tk.model.getProperties(model).values()]; + for (const candidate of context.modelRegistry.values()) { + if (candidate.name.startsWith("AnonymousModel")) continue; + if (candidate.properties.length !== anonymousProperties.length) continue; + const candidateProperties = new Map(candidate.properties.map((prop) => [prop.name, prop])); + let matches = true; + for (const prop of anonymousProperties) { + const name = getName(context, prop, prop.name); + const candidateProp = candidateProperties.get(name); + if ( + candidateProp === undefined || + candidateProp.optional !== prop.optional || + candidateProp.wireName !== context.tk.type.getEncodedName(prop, "application/xml") + ) { + matches = false; + break; + } + const typeRef = getTypeMetadata(context, prop.type).runtime; + if (JSON.stringify(candidateProp.type) !== JSON.stringify(typeRef)) { + matches = false; + break; + } + } + if (matches) return candidate.name; + } + return undefined; +} + +function buildModelProperty( + context: AzuriteEmitterContext, + prop: ModelProperty, + declarationModel: Model, +): ServerModelProperty { + const type = getTypeMetadata(context, prop.type, prop); + createDeclarationProperty(context, prop, declarationModel, type); + return { + name: getName(context, prop, prop.name), + wireName: context.tk.type.getEncodedName(prop, "application/xml"), + type: type.runtime, + optional: prop.optional, + xmlAttribute: isAttribute(context.program, prop), + xmlUnwrapped: isUnwrapped(context.program, prop), + doc: getDocHelper(context, prop), + }; +} + +function createDeclarationModel( + context: AzuriteEmitterContext, + name: string, + sourceModel: Model, +): Model { + const tk = context.tk; + const model = tk.model.create({ + name, + properties: {}, + expression: false, + }); + applyDoc(context, model, getDocHelper(context, sourceModel)); + return model; +} + +function createDeclarationProperty( + context: AzuriteEmitterContext, + source: ModelProperty, + declarationModel: Model, + type: TypeMetadata, +): ModelProperty { + const tk = context.tk; + const property = tk.modelProperty.create({ + name: getName(context, source, source.name), + type: type.declaration, + optional: source.optional, + defaultValue: source.defaultValue, + }); + property.model = declarationModel; + declarationModel.properties.set(property.name, property); + applyDoc(context, property, getDocHelper(context, source)); + return property; +} + +function createOperationParametersModel( + context: AzuriteEmitterContext, + operationName: string, + parameters: readonly BuiltOperationParameter[], + requestBody: BuiltRequestBody | undefined, + doc: string | undefined, +): Model { + const tk = context.tk; + const model = tk.model.create({ + name: `${operationName}Parameters`, + properties: {}, + expression: false, + }); + applyDoc(context, model, doc); + + for (const parameter of parameters) { + const property = tk.modelProperty.create({ + name: parameter.name, + type: parameter.declarationType, + optional: parameter.optional, + }); + property.model = model; + model.properties.set(property.name, property); + } + + if (requestBody !== undefined) { + const property = tk.modelProperty.create({ + name: "body", + type: requestBody.declarationType, + optional: false, + }); + property.model = model; + model.properties.set(property.name, property); + } + + return model; +} + +function createOperationResponseUnion( + context: AzuriteEmitterContext, + operationName: string, + responses: readonly BuiltResponse[], +) { + const tk = context.tk; + return tk.union.create({ + name: `${operationName}Response`, + expression: false, + variants: responses.map((response, index) => + tk.unionVariant.create({ + name: `response${index}`, + type: createOperationResponseModel(context, response), + }), + ), + }); +} + +function createOperationResponseModel( + context: AzuriteEmitterContext, + response: BuiltResponse, +): Model { + const tk = context.tk; + const model = tk.model.create({ + properties: {}, + expression: true, + }); + + const statusCodeProperty = tk.modelProperty.create({ + name: "statusCode", + type: + response.statusCode === "*" + ? tk.builtin.float64 + : tk.literal.createNumeric(response.statusCode), + optional: false, + }); + statusCodeProperty.model = model; + model.properties.set(statusCodeProperty.name, statusCodeProperty); + + if (response.headers.length > 0) { + const headersProperty = tk.modelProperty.create({ + name: "headers", + type: createResponseHeadersModel(context, response.headers), + optional: false, + }); + headersProperty.model = model; + model.properties.set(headersProperty.name, headersProperty); + } + + if (response.body !== undefined) { + const bodyProperty = tk.modelProperty.create({ + name: "body", + type: response.body.declarationType, + optional: false, + }); + bodyProperty.model = model; + model.properties.set(bodyProperty.name, bodyProperty); + } + + return model; +} + +function createResponseHeadersModel( + context: AzuriteEmitterContext, + headers: readonly BuiltResponseHeader[], +): Model { + const tk = context.tk; + const model = tk.model.create({ + properties: {}, + expression: true, + }); + + for (const header of headers) { + const property = tk.modelProperty.create({ + name: header.name, + type: header.declarationType, + optional: header.optional, + }); + property.model = model; + model.properties.set(property.name, property); + } + + return model; +} + +function applyDoc(context: AzuriteEmitterContext, target: Type, doc: string | undefined): void { + if (!doc) return; + // Typekit can read docs but does not expose a public setter; use the public @doc + // decorator so emitter-framework typed declarations can infer copied docs. + $doc({ program: context.program } as DecoratorContext, target, doc); +} + +function getDocHelper(context: AzuriteEmitterContext, target: Type): string | undefined { + return context.tk.type.getDoc(target); +} + +function numericTypeRef(context: AzuriteEmitterContext, target: Type): ServerTypeRef { + const constraints = getNumericConstraints(context, target); + return constraints === undefined ? { kind: "number" } : { kind: "number", constraints }; +} + +function getNumericConstraints( + context: AzuriteEmitterContext, + target: Type, +): ServerNumericConstraints | undefined { + const tk = context.tk; + const targets = getNumericConstraintTargets(target); + const constraints: ServerNumericConstraints = { + min: maxDefined(targets.map((candidate) => tk.type.minValue(candidate))), + max: minDefined(targets.map((candidate) => tk.type.maxValue(candidate))), + minExclusive: maxDefined(targets.map((candidate) => tk.type.minValueExclusive(candidate))), + maxExclusive: minDefined(targets.map((candidate) => tk.type.maxValueExclusive(candidate))), + }; + return Object.values(constraints).some((value) => value !== undefined) ? constraints : undefined; +} + +function getNumericConstraintTargets(target: Type): Type[] { + const targets: Type[] = []; + for (let current: Type | undefined = target; current !== undefined;) { + targets.push(current); + if (current.kind === "ModelProperty") { + current = current.sourceProperty; + } else { + current = undefined; + } + } + if (target.kind === "ModelProperty") { + targets.push(target.type); + } + return targets; +} + +function maxDefined(values: readonly (number | undefined)[]): number | undefined { + const defined = values.filter((value) => value !== undefined); + return defined.length === 0 ? undefined : Math.max(...defined); +} + +function minDefined(values: readonly (number | undefined)[]): number | undefined { + const defined = values.filter((value) => value !== undefined); + return defined.length === 0 ? undefined : Math.min(...defined); +} diff --git a/packages/typespec-azurite-emitter/src/context.ts b/packages/typespec-azurite-emitter/src/context.ts new file mode 100644 index 0000000000..0d510931d2 --- /dev/null +++ b/packages/typespec-azurite-emitter/src/context.ts @@ -0,0 +1,32 @@ +import { createTCGCContext, type TCGCContext } from "@azure-tools/typespec-client-generator-core"; +import type { Diagnostic, Model, Program } from "@typespec/compiler"; +import { $ } from "@typespec/compiler/typekit"; +import type { ServerDataModel } from "./model.js"; +import type { NormalizedAzuritePilotEmitterOptions } from "./options.js"; + +export interface AzuriteEmitterContext { + readonly program: Program; + readonly tk: ReturnType; + readonly tcgcContext: TCGCContext; + readonly options: NormalizedAzuritePilotEmitterOptions; + readonly modelRegistry: Map; + readonly anonymousModelNamesByType: WeakMap; + readonly diagnostics: Diagnostic[]; + nextAnonymousModelId: number; +} + +export function createAzuriteEmitterContext( + program: Program, + options: NormalizedAzuritePilotEmitterOptions, +): AzuriteEmitterContext { + return { + program, + tk: $(program), + tcgcContext: createTCGCContext(program, "@azure-tools/typespec-azurite-emitter"), + options, + modelRegistry: new Map(), + anonymousModelNamesByType: new WeakMap(), + diagnostics: [], + nextAnonymousModelId: 1, + }; +} diff --git a/packages/typespec-azurite-emitter/src/decorators.ts b/packages/typespec-azurite-emitter/src/decorators.ts new file mode 100644 index 0000000000..fb2c1a9f54 --- /dev/null +++ b/packages/typespec-azurite-emitter/src/decorators.ts @@ -0,0 +1,13 @@ +import type { MakeOptionalDecorator } from "../generated-defs/Azurite.js"; + +/** Implements `@makeOptional` for Azurite overlays. */ +export const $makeOptional: MakeOptionalDecorator = (context, target) => { + target.optional = true; +}; + +/** @internal */ +export const $decorators = { + Azurite: { + makeOptional: $makeOptional, + }, +}; diff --git a/packages/typespec-azurite-emitter/src/emitter.tsx b/packages/typespec-azurite-emitter/src/emitter.tsx new file mode 100644 index 0000000000..0425790c4a --- /dev/null +++ b/packages/typespec-azurite-emitter/src/emitter.tsx @@ -0,0 +1,56 @@ +import { NoTarget, resolvePath, type EmitContext } from "@typespec/compiler"; +import { writeOutput } from "@typespec/emitter-framework"; +import { getAllHttpServices } from "@typespec/http"; +import { buildServerModel } from "./build-model.js"; +import { createAzuriteEmitterContext } from "./context.js"; +import { reportDiagnostic } from "./lib.js"; +import type { AzuritePilotEmitterOptions } from "./options.js"; +import { normalizeOptions } from "./options.js"; +import { AzuriteEmitterOutput } from "./render/index.js"; + +/** + * Emitter entry point: builds the intermediate server model (transform phase) and renders + * it into the three generated artifacts (render phase). + */ +export async function $onEmit(context: EmitContext): Promise { + const options = normalizeOptions(context.options); + const azuriteContext = createAzuriteEmitterContext(context.program, options); + const { program } = azuriteContext; + + const [services, httpDiagnostics] = getAllHttpServices(program); + if (httpDiagnostics.length > 0) { + program.reportDiagnostics(httpDiagnostics); + return; + } + if (services.length === 0 || services[0].operations.length === 0) { + reportDiagnostic(program, { code: "no-service-found", target: NoTarget }); + return; + } + if (services.length > 1) { + reportDiagnostic(program, { code: "multiple-services", target: NoTarget }); + return; + } + + const serverModel = buildServerModel(azuriteContext, services[0]); + if (azuriteContext.diagnostics.length > 0) { + program.reportDiagnostics(azuriteContext.diagnostics); + return; + } + if (serverModel.skippedOperations.length > 0) { + for (const skipped of serverModel.skippedOperations) { + reportDiagnostic(program, { + code: "skipped-operation", + format: { name: skipped.name, reason: skipped.reason }, + target: skipped.target, + }); + } + return; + } + + const baseDir = resolvePath(context.emitterOutputDir, options.outputDir); + await writeOutput( + program, + , + baseDir, + ); +} diff --git a/packages/typespec-azurite-emitter/src/index.ts b/packages/typespec-azurite-emitter/src/index.ts new file mode 100644 index 0000000000..d7f58d0b64 --- /dev/null +++ b/packages/typespec-azurite-emitter/src/index.ts @@ -0,0 +1,18 @@ +export { buildServerModel } from "./build-model.js"; +export { $decorators } from "./decorators.js"; +export { $onEmit } from "./emitter.js"; +export { $lib } from "./lib.js"; +export type { + ServerDataModel, + ServerModel, + ServerModelProperty, + ServerOperation, + ServerOperationParameter, + ServerParameterLocation, + ServerRequestBody, + ServerResponse, + ServerResponseHeader, + ServerTypeRef, +} from "./model.js"; +export type { AzuritePilotEmitterOptions } from "./options.js"; +export { renderHandlers, renderMetadata, renderModels, renderOperations } from "./render/index.js"; diff --git a/packages/typespec-azurite-emitter/src/lib.ts b/packages/typespec-azurite-emitter/src/lib.ts new file mode 100644 index 0000000000..b7a281f04f --- /dev/null +++ b/packages/typespec-azurite-emitter/src/lib.ts @@ -0,0 +1,45 @@ +import { createTypeSpecLibrary, paramMessage } from "@typespec/compiler"; +import { azuritePilotEmitterOptionsSchema } from "./options.js"; + +export const $lib = createTypeSpecLibrary({ + name: "@azure-tools/typespec-azurite-emitter", + diagnostics: { + "no-service-found": { + severity: "error", + messages: { + default: + "No HTTP service was found in the compiled program. The Azurite emitter requires exactly one HTTP service.", + }, + }, + "multiple-services": { + severity: "error", + messages: { + default: + "Multiple HTTP services were found in the compiled program. The Azurite emitter requires exactly one HTTP service.", + }, + }, + "unsupported-type": { + severity: "error", + messages: { + default: paramMessage`Unsupported TypeSpec wire type: ${"message"}`, + }, + }, + "skipped-operation": { + severity: "error", + messages: { + default: paramMessage`Operation ${"name"} could not be emitted: ${"reason"}`, + }, + }, + "operation-name-collision": { + severity: "error", + messages: { + default: paramMessage`Operation name collision for ${"name"}: ${"reason"}`, + }, + }, + }, + emitter: { + options: azuritePilotEmitterOptionsSchema, + }, +}); + +export const { reportDiagnostic, createDiagnostic } = $lib; diff --git a/packages/typespec-azurite-emitter/src/model.ts b/packages/typespec-azurite-emitter/src/model.ts new file mode 100644 index 0000000000..c7dfe1b180 --- /dev/null +++ b/packages/typespec-azurite-emitter/src/model.ts @@ -0,0 +1,109 @@ +import type { DiagnosticTarget, Model, Union } from "@typespec/compiler"; + +export interface ServerNumericConstraints { + readonly min?: number; + readonly max?: number; + readonly minExclusive?: number; + readonly maxExclusive?: number; +} + +export type ServerTypeRef = + | { kind: "string" } + | { kind: "number"; constraints?: ServerNumericConstraints } + | { kind: "boolean" } + | { kind: "datetime" } + | { kind: "unknown" } + | { kind: "model"; name: string } + | { kind: "array"; element: ServerTypeRef } + | { kind: "record"; element: ServerTypeRef } + | { kind: "union"; variants: readonly ServerTypeRef[] } + | { kind: "literal"; value: string | number | boolean }; + +export interface ServerModelProperty { + readonly name: string; + readonly wireName: string; + readonly type: ServerTypeRef; + readonly optional: boolean; + readonly xmlAttribute: boolean; + readonly xmlUnwrapped: boolean; + readonly doc?: string; +} + +/** A named data model (request/response body shape) referenced by one or more operations. */ +export interface ServerDataModel { + readonly name: string; + readonly wireName: string; + readonly properties: ServerModelProperty[]; + readonly declarationModel: Model; + readonly doc?: string; +} + +export type ServerParameterLocation = "path" | "query" | "header"; + +export interface ServerOperationParameter { + readonly name: string; + /** The wire name (e.g. the query-string key or header name), which may differ from `name`. */ + readonly wireName: string; + readonly location: ServerParameterLocation; + readonly type: ServerTypeRef; + readonly optional: boolean; +} + +export interface ServerRequestBody { + readonly type: ServerTypeRef; + readonly contentTypes: readonly string[]; + readonly parameterPath: string | readonly string[]; +} + +export interface ServerResponseHeader { + readonly name: string; + readonly wireName: string; + readonly type: ServerTypeRef; + readonly optional: boolean; +} + +export interface ServerResponse { + /** Numeric status code, or `"*"` for a default/catch-all response. */ + readonly statusCode: number | "*"; + readonly headers: ServerResponseHeader[]; + readonly body?: ServerRequestBody; +} + +export interface ServerOperation { + /** Runtime operation name used in generated metadata and dispatcher lookup. */ + readonly name: string; + /** TypeScript declaration stem for operation-specific generated types. */ + readonly typeName: string; + readonly verb: "get" | "put" | "post" | "patch" | "delete" | "head"; + /** The full HTTP path as reported by TypeSpec, including literal query constraints. */ + readonly rawPath: string; + /** The route path without literal query constraints. */ + readonly path: string; + readonly literalQueryParameters: readonly ServerLiteralQueryParameter[]; + readonly parameters: ServerOperationParameter[]; + readonly parametersModel: Model; + readonly requestBody?: ServerRequestBody; + readonly responses: ServerResponse[]; + readonly responseUnion: Union; + readonly doc?: string; + /** TypeSpec interface declaring the operation, when available. */ + readonly interfaceName?: string; +} + +export interface ServerLiteralQueryParameter { + readonly name: string; + readonly value: string; +} + +export interface ServerModel { + readonly serviceName: string; + readonly operations: ServerOperation[]; + readonly models: ServerDataModel[]; + readonly skippedOperations: readonly ServerSkippedOperation[]; +} + +export interface ServerSkippedOperation { + readonly name: string; + readonly reason: string; + readonly target: DiagnosticTarget; +} diff --git a/packages/typespec-azurite-emitter/src/options.ts b/packages/typespec-azurite-emitter/src/options.ts new file mode 100644 index 0000000000..0344d8791e --- /dev/null +++ b/packages/typespec-azurite-emitter/src/options.ts @@ -0,0 +1,37 @@ +import type { JSONSchemaType } from "@typespec/compiler"; + +export interface AzuritePilotEmitterOptions { + /** + * Relative path (within the emitter output dir) where generated artifacts are written. + */ + outputDir?: string; + + /** + * Module specifier used by generated artifacts to import the handwritten Azurite serialization + * runtime. + */ + runtimeImport?: string; +} + +export interface NormalizedAzuritePilotEmitterOptions { + outputDir: string; + runtimeImport: string; +} + +export const azuritePilotEmitterOptionsSchema: JSONSchemaType = { + type: "object", + additionalProperties: false, + properties: { + outputDir: { type: "string", nullable: true }, + runtimeImport: { type: "string", nullable: true }, + }, +}; + +export function normalizeOptions( + rawOptions: AzuritePilotEmitterOptions | undefined, +): NormalizedAzuritePilotEmitterOptions { + return { + outputDir: rawOptions?.outputDir?.trim() || ".", + runtimeImport: rawOptions?.runtimeImport?.trim() || "../runtime/serializationRuntime", + }; +} diff --git a/packages/typespec-azurite-emitter/src/render/file-header.ts b/packages/typespec-azurite-emitter/src/render/file-header.ts new file mode 100644 index 0000000000..b44c18cca3 --- /dev/null +++ b/packages/typespec-azurite-emitter/src/render/file-header.ts @@ -0,0 +1,3 @@ +export const GENERATED_FILE_HEADER = `// This file was automatically generated by @azure-tools/typespec-azurite-emitter. +// Do not edit this file manually; re-run \`tsp compile\` to regenerate it. +`; diff --git a/packages/typespec-azurite-emitter/src/render/generated-source-file.tsx b/packages/typespec-azurite-emitter/src/render/generated-source-file.tsx new file mode 100644 index 0000000000..812d8f8e93 --- /dev/null +++ b/packages/typespec-azurite-emitter/src/render/generated-source-file.tsx @@ -0,0 +1,11 @@ +import type { Children } from "@alloy-js/core"; +import * as ts from "@alloy-js/typescript"; +import { GENERATED_FILE_HEADER } from "./file-header.js"; + +export function GeneratedSourceFile(props: { path: string; children?: Children }) { + return ( + + {props.children} + + ); +} diff --git a/packages/typespec-azurite-emitter/src/render/index.tsx b/packages/typespec-azurite-emitter/src/render/index.tsx new file mode 100644 index 0000000000..24cd052032 --- /dev/null +++ b/packages/typespec-azurite-emitter/src/render/index.tsx @@ -0,0 +1,6 @@ +export { AzuriteEmitterOutput } from "./output.js"; +export { renderHandlers } from "./render-handlers.js"; +export { renderMetadata } from "./render-metadata.js"; +export { renderModels } from "./render-models.js"; +export { renderOperations } from "./render-operations.js"; +export { renderSerialization } from "./render-serialization.js"; diff --git a/packages/typespec-azurite-emitter/src/render/output.tsx b/packages/typespec-azurite-emitter/src/render/output.tsx new file mode 100644 index 0000000000..43a8b637aa --- /dev/null +++ b/packages/typespec-azurite-emitter/src/render/output.tsx @@ -0,0 +1,61 @@ +import { Output as CoreOutput, SourceDirectory, type Children } from "@alloy-js/core"; +import { Output as EmitterFrameworkOutput } from "@typespec/emitter-framework"; +import type { AzuriteEmitterContext } from "../context.js"; +import type { ServerModel } from "../model.js"; +import { renderHandlers } from "./render-handlers.js"; +import { renderMetadata } from "./render-metadata.js"; +import { renderModels } from "./render-models.js"; +import { renderOperations } from "./render-operations.js"; +import { renderSerialization } from "./render-serialization.js"; +import { createRuntimeSymbols, type RuntimeSymbols } from "./runtime-symbols.js"; + +export function AzuriteEmitterOutput(props: { + serverModel: ServerModel; + context: AzuriteEmitterContext; +}) { + const runtimeSymbols = createRuntimeSymbols(props.context.options.runtimeImport); + return ( + + + + ); +} + +function AzuriteOutput(props: { + children: Children; + context: AzuriteEmitterContext; + runtimeSymbols?: RuntimeSymbols; +}) { + return ( + + {props.children} + + ); +} + +function AzuriteOutputFiles(props: { + serverModel: ServerModel; + runtimeImport: string; + runtimeSymbols: RuntimeSymbols; +}) { + return ( + <> + {renderModels(props.serverModel, props.runtimeSymbols)} + {renderOperations(props.serverModel, props.runtimeSymbols)} + {renderMetadata(props.serverModel, props.runtimeImport, props.runtimeSymbols)} + {renderHandlers(props.serverModel)} + {renderSerialization(props.serverModel, props.runtimeSymbols)} + + ); +} + +export function AzuriteTestOutput(props: { children: Children }) { + return {props.children}; +} diff --git a/packages/typespec-azurite-emitter/src/render/refkeys.ts b/packages/typespec-azurite-emitter/src/render/refkeys.ts new file mode 100644 index 0000000000..5973a96ee6 --- /dev/null +++ b/packages/typespec-azurite-emitter/src/render/refkeys.ts @@ -0,0 +1,21 @@ +import { refkey } from "@alloy-js/core"; +import type { ServerOperation } from "../model.js"; + +// Declaration identities used by Alloy to resolve references and synthesize imports; these are not runtime values. +export const contextRefkey = refkey("context"); + +export function operationParametersRefkey(operation: ServerOperation) { + return refkey(operation, "parameters"); +} + +export function operationResponseRefkey(operation: ServerOperation) { + return refkey(operation, "response"); +} + +export function operationMetadataRefkey(operation: ServerOperation) { + return refkey(operation, "metadata"); +} + +export function modelXmlMetadataRefkey(model: { readonly name: string }) { + return refkey(model, "xml-metadata"); +} diff --git a/packages/typespec-azurite-emitter/src/render/render-handlers.tsx b/packages/typespec-azurite-emitter/src/render/render-handlers.tsx new file mode 100644 index 0000000000..4666b7da5a --- /dev/null +++ b/packages/typespec-azurite-emitter/src/render/render-handlers.tsx @@ -0,0 +1,51 @@ +import { code, For } from "@alloy-js/core"; +import * as ts from "@alloy-js/typescript"; +import * as ef from "@typespec/emitter-framework/typescript"; +import type { ServerModel } from "../model.js"; +import { GeneratedSourceFile } from "./generated-source-file.js"; +import { contextRefkey, operationParametersRefkey, operationResponseRefkey } from "./refkeys.js"; + +export function renderHandlers(serverModel: ServerModel) { + return ( + + + + {code`;`} + + + + + ); +} + +export function ServiceHandlerInterface(props: { serverModel: ServerModel }) { + return ( + + + {(op) => { + const methodName = op.name[0].toLowerCase() + op.name.slice(1); + return ( + , + }, + { name: "context", type: }, + ]} + returnType={ + <> + Promise< + + > + + } + /> + ); + }} + + + ); +} diff --git a/packages/typespec-azurite-emitter/src/render/render-metadata.tsx b/packages/typespec-azurite-emitter/src/render/render-metadata.tsx new file mode 100644 index 0000000000..bea28f2229 --- /dev/null +++ b/packages/typespec-azurite-emitter/src/render/render-metadata.tsx @@ -0,0 +1,266 @@ +import { code } from "@alloy-js/core"; +import * as ts from "@alloy-js/typescript"; +import type { + ServerDataModel, + ServerModel, + ServerNumericConstraints, + ServerOperation, + ServerResponse, + ServerResponseHeader, + ServerTypeRef, +} from "../model.js"; +import { GeneratedSourceFile } from "./generated-source-file.js"; +import type { RuntimeSymbols } from "./runtime-symbols.js"; + +export type OperationTypeDescriptor = + | "string" + | "boolean" + | "datetime" + | "unknown" + | "number" + | readonly ["number", NumericConstraintDescriptor] + | readonly ["model", string] + | readonly ["literal", string | number | boolean] + | readonly ["array", OperationTypeDescriptor] + | readonly ["record", OperationTypeDescriptor] + | readonly ["union", readonly OperationTypeDescriptor[]]; + +export interface NumericConstraintDescriptor { + min?: number; + max?: number; + minExclusive?: number; + maxExclusive?: number; +} + +export type OperationParameterDescriptor = readonly [ + name: string, + wireName: string, + location: "path" | "query" | "header", + type: OperationTypeDescriptor, + required?: true, + collectionPrefix?: string, +]; + +export type OperationResponseHeaderDescriptor = readonly [ + name: string, + wireName: string, + type: OperationTypeDescriptor, + collectionPrefix?: string, +]; + +export type OperationResponseDescriptor = readonly [ + statusCode: number | "*", + headers?: readonly OperationResponseHeaderDescriptor[], + bodyType?: OperationTypeDescriptor, +]; + +export type OperationRequestBodyDescriptor = readonly [ + type: OperationTypeDescriptor, + contentTypes: readonly string[], + parameterPath?: string | readonly string[], +]; + +export type OperationDescriptor = readonly [ + name: string, + verb: string, + rawPath: string, + parameters: readonly OperationParameterDescriptor[], + requestBody: OperationRequestBodyDescriptor | undefined, + responses: readonly OperationResponseDescriptor[], + interfaceName?: string, +]; + +export type XmlPropertyDescriptor = readonly [ + name: string, + wireName: string, + type: OperationTypeDescriptor, + mode?: "attribute" | "unwrapped", + itemName?: string, + required?: true, +]; + +export type XmlModelDescriptor = readonly [ + wireName: string, + properties: readonly XmlPropertyDescriptor[], +]; + +export type NamedXmlModelDescriptor = readonly [name: string, descriptor: XmlModelDescriptor]; + +/** Renders the service-level metadata aggregator without restating descriptor literals. */ +export function renderMetadata( + serverModel: ServerModel, + runtimeImport: string, + runtimeSymbols: RuntimeSymbols, +) { + const modelMetadataNames = serverModel.models.map(modelXmlMetadataConstName); + const operationMetadataNames = serverModel.operations.map(operationMetadataConstName); + return ( + + {code` + import { ${modelMetadataNames.join(", ")} } from "./models"; + import { ${operationMetadataNames.join(", ")} } from "./operations"; + + export type { OperationMetadata, ServiceMetadata } from ${JSON.stringify(runtimeImport)}; + `} + + {code`export const serviceMetadata: `} + + {code` = `} + + {code`({ operations: [${operationMetadataNames.join(", ")}], xmlModels: [${modelMetadataNames.join(", ")}] }); + + export const operations: readonly `} + + {code`[] = serviceMetadata.operations;`} + + ); +} + +export function operationDescriptorValue(op: ServerOperation): OperationDescriptor { + return withoutTrailingUndefined([ + op.name, + op.verb, + op.rawPath, + op.parameters.map(parameterDescriptorValue), + op.requestBody === undefined ? undefined : requestBodyDescriptorValue(op.requestBody), + op.responses.map(responseDescriptorValue), + op.interfaceName, + ]); +} + +export function operationMetadataConstName(op: ServerOperation): string { + return `${op.typeName}Metadata`; +} + +function requestBodyDescriptorValue(requestBody: NonNullable) { + return withoutTrailingUndefined([ + operationTypeDescriptorValue(requestBody.type), + requestBody.contentTypes, + isDefaultBodyPath(requestBody.parameterPath) ? undefined : requestBody.parameterPath, + ]) as unknown as OperationRequestBodyDescriptor; +} + +function parameterDescriptorValue(parameter: ServerOperation["parameters"][number]) { + return withoutTrailingUndefined([ + parameter.name, + parameter.wireName, + parameter.location, + operationTypeDescriptorValue(parameter.type), + parameter.optional ? undefined : true, + getHeaderCollectionPrefix(parameter.wireName), + ]) as unknown as OperationParameterDescriptor; +} + +function responseDescriptorValue(response: ServerResponse): OperationResponseDescriptor { + return withoutTrailingUndefined([ + response.statusCode, + response.headers.length === 0 ? undefined : response.headers.map(responseHeaderDescriptorValue), + response.body === undefined ? undefined : operationTypeDescriptorValue(response.body.type), + ]); +} + +function responseHeaderDescriptorValue(header: ServerResponseHeader) { + return withoutTrailingUndefined([ + header.name, + header.wireName, + operationTypeDescriptorValue(header.type), + getHeaderCollectionPrefix(header.wireName), + ]) as unknown as OperationResponseHeaderDescriptor; +} + +export function operationTypeDescriptorValue(type: ServerTypeRef): OperationTypeDescriptor { + switch (type.kind) { + case "array": + return ["array", operationTypeDescriptorValue(type.element)]; + case "literal": + return ["literal", type.value]; + case "model": + return ["model", type.name]; + case "record": + return ["record", operationTypeDescriptorValue(type.element)]; + case "union": + return ["union", type.variants.map(operationTypeDescriptorValue)]; + case "number": + return type.constraints === undefined + ? "number" + : ["number", numericConstraintDescriptorValue(type.constraints)]; + case "boolean": + case "datetime": + case "string": + case "unknown": + return type.kind; + } +} + +function numericConstraintDescriptorValue( + constraints: ServerNumericConstraints, +): NumericConstraintDescriptor { + return withoutUndefinedProperties({ + min: constraints.min, + max: constraints.max, + minExclusive: constraints.minExclusive, + maxExclusive: constraints.maxExclusive, + }); +} + +export function xmlModelDescriptorValue( + model: ServerDataModel, + models: readonly ServerDataModel[], +): XmlModelDescriptor { + return [model.wireName, model.properties.map((prop) => xmlPropertyMetadataValue(prop, models))]; +} + +export function modelXmlMetadataConstName(model: ServerDataModel): string { + return `${model.name}XmlMetadata`; +} + +export function xmlPropertyMetadataValue( + prop: ServerDataModel["properties"][number], + models: readonly ServerDataModel[], +) { + const itemName = getArrayItemName(prop.type, prop.wireName, models); + return withoutTrailingUndefined([ + prop.name, + prop.wireName, + operationTypeDescriptorValue(prop.type), + prop.xmlAttribute ? "attribute" : prop.xmlUnwrapped ? "unwrapped" : undefined, + itemName, + prop.optional ? undefined : true, + ]) as unknown as XmlPropertyDescriptor; +} + +function getHeaderCollectionPrefix(wireName: string): string | undefined { + return wireName.toLowerCase() === "x-ms-meta" ? "x-ms-meta-" : undefined; +} + +function isDefaultBodyPath(path: string | readonly string[]): boolean { + return path === "body"; +} + +function withoutTrailingUndefined(items: T): T { + const trimmed = [...items]; + while (trimmed.length > 0 && trimmed[trimmed.length - 1] === undefined) { + trimmed.pop(); + } + return trimmed as unknown as T; +} + +function withoutUndefinedProperties>(value: T): T { + return Object.fromEntries( + Object.entries(value).filter(([, propertyValue]) => propertyValue !== undefined), + ) as T; +} + +function getArrayItemName( + type: ServerTypeRef, + fallback: string, + models: readonly ServerDataModel[], +): string | undefined { + if (type.kind !== "array") return undefined; + const element = type.element; + if (element.kind === "model") { + const model = models.find((candidate) => candidate.name === element.name); + return model?.wireName ?? element.name; + } + return fallback; +} diff --git a/packages/typespec-azurite-emitter/src/render/render-models.tsx b/packages/typespec-azurite-emitter/src/render/render-models.tsx new file mode 100644 index 0000000000..47e93d698d --- /dev/null +++ b/packages/typespec-azurite-emitter/src/render/render-models.tsx @@ -0,0 +1,43 @@ +import { For } from "@alloy-js/core"; +import * as ts from "@alloy-js/typescript"; +import * as ef from "@typespec/emitter-framework/typescript"; +import type { ServerModel } from "../model.js"; +import { GeneratedSourceFile } from "./generated-source-file.js"; +import { modelXmlMetadataRefkey } from "./refkeys.js"; +import { modelXmlMetadataConstName, xmlModelDescriptorValue } from "./render-metadata.js"; +import type { RuntimeSymbols } from "./runtime-symbols.js"; + +/** + * Renders the `models.ts` artifact: one TypeScript interface per named data model + * referenced by the service's operations (request/response bodies). + */ +export function renderModels(serverModel: ServerModel, runtimeSymbols: RuntimeSymbols) { + return ( + + + {(model) => ( + <> + + + + ( + {JSON.stringify(model.name)},{" "} + + ) + + } + /> + + )} + + + ); +} diff --git a/packages/typespec-azurite-emitter/src/render/render-operations.tsx b/packages/typespec-azurite-emitter/src/render/render-operations.tsx new file mode 100644 index 0000000000..cf0e078845 --- /dev/null +++ b/packages/typespec-azurite-emitter/src/render/render-operations.tsx @@ -0,0 +1,56 @@ +import { For } from "@alloy-js/core"; +import * as ts from "@alloy-js/typescript"; +import * as ef from "@typespec/emitter-framework/typescript"; +import type { ServerModel } from "../model.js"; +import { GeneratedSourceFile } from "./generated-source-file.js"; +import { + operationMetadataRefkey, + operationParametersRefkey, + operationResponseRefkey, +} from "./refkeys.js"; +import { operationDescriptorValue, operationMetadataConstName } from "./render-metadata.js"; +import type { RuntimeSymbols } from "./runtime-symbols.js"; + +/** + * Renders the `operations.ts` artifact: per-operation request/response TypeScript declarations + * with colocated compact HTTP operation descriptors. + */ +export function renderOperations(serverModel: ServerModel, runtimeSymbols: RuntimeSymbols) { + return ( + + + {(op) => ( + <> + + + + + + ( + ) + + } + /> + + + )} + + + ); +} diff --git a/packages/typespec-azurite-emitter/src/render/render-serialization.tsx b/packages/typespec-azurite-emitter/src/render/render-serialization.tsx new file mode 100644 index 0000000000..89d88c3a2b --- /dev/null +++ b/packages/typespec-azurite-emitter/src/render/render-serialization.tsx @@ -0,0 +1,28 @@ +import { code } from "@alloy-js/core"; +import * as ts from "@alloy-js/typescript"; +import type { ServerModel } from "../model.js"; +import { GeneratedSourceFile } from "./generated-source-file.js"; +import type { RuntimeSymbols } from "./runtime-symbols.js"; + +/** + * Renders the service-specific serialization binding. Stable request/response/XML conversion + * lives in Azurite's handwritten runtime; generated output supplies a consolidated service + * metadata manifest in `metadata.ts`. + */ +export function renderSerialization(_serverModel: ServerModel, runtimeSymbols: RuntimeSymbols) { + return ( + + {code` + import { serviceMetadata } from "./metadata"; + + const runtime = `} + + {code`(serviceMetadata); + + export const deserializeRequest = runtime.deserializeRequest; + export const serializeResponse = runtime.serializeResponse; + export const hasGeneratedSerialization = runtime.hasGeneratedSerialization; + `} + + ); +} diff --git a/packages/typespec-azurite-emitter/src/render/runtime-symbols.ts b/packages/typespec-azurite-emitter/src/render/runtime-symbols.ts new file mode 100644 index 0000000000..06435c500f --- /dev/null +++ b/packages/typespec-azurite-emitter/src/render/runtime-symbols.ts @@ -0,0 +1,24 @@ +import * as ts from "@alloy-js/typescript"; + +const runtimeExports = [ + "createSerializationRuntime", + "defineOperation", + "defineServiceMetadata", + "defineXmlModel", + "OperationMetadata", + "ServiceMetadata", +] as const; + +export type RuntimeSymbols = ReturnType; + +export function createRuntimeSymbols(runtimeImport: string) { + return ts.createPackage({ + name: runtimeImport, + version: "0.0.0", + descriptor: { + ".": { + named: [...runtimeExports], + }, + }, + }); +} diff --git a/packages/typespec-azurite-emitter/src/utils.ts b/packages/typespec-azurite-emitter/src/utils.ts new file mode 100644 index 0000000000..a5889e0dc0 --- /dev/null +++ b/packages/typespec-azurite-emitter/src/utils.ts @@ -0,0 +1,15 @@ +import { getClientNameOverride } from "@azure-tools/typespec-client-generator-core"; +import type { Type } from "@typespec/compiler"; +import type { AzuriteEmitterContext } from "./context.js"; + +export function getName( + context: AzuriteEmitterContext, + target: Type, + fallbackName: string, +): string { + return getClientNameOverride(context.tcgcContext, target) ?? fallbackName; +} + +export function hasNameOverride(context: AzuriteEmitterContext, target: Type): boolean { + return getClientNameOverride(context.tcgcContext, target) !== undefined; +} diff --git a/packages/typespec-azurite-emitter/test/azurite-compat.test.ts b/packages/typespec-azurite-emitter/test/azurite-compat.test.ts new file mode 100644 index 0000000000..dd6e5b6d64 --- /dev/null +++ b/packages/typespec-azurite-emitter/test/azurite-compat.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "vitest"; +import { EmitterTester, loadQueuePilotFixture } from "./tester.js"; + +describe("structural fit against Azure/Azurite's real generated artifacts", () => { + it("handler methods take a typed parameters object AND a trailing context argument, like IQueueHandler.ts", async () => { + const { outputs } = await EmitterTester.compile(loadQueuePilotFixture()); + const handlersFile = findOutput(outputs, "handlers.ts"); + + // Mirrors e.g.: + // create(options: Models.QueueCreateOptionalParams, context: Context): Promise; + // from https://github.com/Azure/Azurite/blob/main/src/queue/generated/handlers/IQueueHandler.ts + expect(handlersFile).toMatch( + /queue_Create\(\s*params: QueueCreateParameters,\s*context: Context,\s*\): Promise;/, + ); + // Every operation follows the same (params, context) => Promise shape. + const methodSignatures = [ + ...handlersFile.matchAll( + /^\s*\w+\(\s*params: \w+,\s*context: Context,\s*\): Promise<\w+>;/gm, + ), + ]; + expect(methodSignatures).toHaveLength(5); + }); + + it("declares a trailing Context type, mirroring Azurite's generated per-request Context object", async () => { + const { outputs } = await EmitterTester.compile(loadQueuePilotFixture()); + const handlersFile = findOutput(outputs, "handlers.ts"); + + expect(handlersFile).toContain("export interface Context {"); + }); + + it("route metadata carries the HTTP method + path template a dispatcher needs to match a request, like dispatch.middleware.ts's spec.httpMethod/spec.path", async () => { + const { outputs } = await EmitterTester.compile(loadQueuePilotFixture()); + const operationsFile = findOutput(outputs, "operations.ts"); + + expect(operationsFile).toContain(`"Queue_Create"`); + expect(operationsFile).toContain(`"put"`); + expect(operationsFile).toContain(`"/{queueName}"`); + + expect(operationsFile).toContain(`"ListMessages"`); + expect(operationsFile).toContain(`"get"`); + expect(operationsFile).toContain(`"/{queueName}/messages"`); + }); + + it("route metadata marks each parameter's wire name, location, and whether it is required - the exact fields dispatch.middleware.ts's isRequestAgainstOperation reads off spec.queryParameters/headerParameters", async () => { + const { outputs } = await EmitterTester.compile(loadQueuePilotFixture()); + const operationsFile = findOutput(outputs, "operations.ts"); + + expect(operationsFile).toMatch(/\["numOfMessages", "numOfMessages", "query", "number"\]/); + expect(operationsFile).toMatch( + /"visibilityTimeout"[\s\S]*"query"[\s\S]*"number"[\s\S]*min: 0[\s\S]*max: 2147483647/, + ); + expect(operationsFile).toMatch(/\["queueName", "queueName", "path", "string", true\]/); + }); + + it("response metadata is keyed by status code with header wire names, like specifications.ts's per-status headersMapper (e.g. QueueCreateOperationSpec's 201 response)", async () => { + const { outputs } = await EmitterTester.compile(loadQueuePilotFixture()); + const operationsFile = findOutput(outputs, "operations.ts"); + + expect(operationsFile).toMatch(/\[201, \[\["requestId", "x-ms-request-id", "string"\]\]\]/); + expect(operationsFile).toMatch( + /\[\s*200,[\s\S]*"approximateMessagesCount",[\s\S]*"x-ms-approximate-messages-count",[\s\S]*"number"[\s\S]*\["model", "QueueProperties"\]/, + ); + }); + + it("request body content type is captured per operation, like specifications.ts's contentType field", async () => { + const { outputs } = await EmitterTester.compile(loadQueuePilotFixture()); + const operationsFile = findOutput(outputs, "operations.ts"); + + expect(operationsFile).toContain(`[["model", "QueueMetadata"], ["application/json"]]`); + }); +}); + +function findOutput(outputs: Record, suffix: string): string { + const key = Object.keys(outputs).find((k) => k.endsWith(suffix)); + if (!key) { + throw new Error( + `No output file ending with ${suffix} found in ${Object.keys(outputs).join(", ")}`, + ); + } + return outputs[key]; +} diff --git a/packages/typespec-azurite-emitter/test/build-model.test.ts b/packages/typespec-azurite-emitter/test/build-model.test.ts new file mode 100644 index 0000000000..cebbebe146 --- /dev/null +++ b/packages/typespec-azurite-emitter/test/build-model.test.ts @@ -0,0 +1,676 @@ +import { $ } from "@typespec/compiler/typekit"; +import { getAllHttpServices } from "@typespec/http"; +import { describe, expect, it } from "vitest"; +import { buildServerModel } from "../src/build-model.js"; +import { operationDescriptorValue } from "../src/render/render-metadata.js"; +import { + ApiTester, + createTestAzuriteContext, + loadQueuePilotFixture, + readFixture, +} from "./tester.js"; + +describe("buildServerModel", () => { + it("builds one operation per HTTP operation with the right verb and path", async () => { + const { program } = await ApiTester.compile(loadQueuePilotFixture()); + const serverModel = buildServerModel(createTestAzuriteContext(program)); + + const names = serverModel.operations.map((op) => op.name).sort(); + expect(names).toEqual([ + "DeleteQueue", + "GetQueueProperties", + "ListMessages", + "Queue_Create", + "SetAccessPolicy", + ]); + + const createQueue = serverModel.operations.find((op) => op.name === "Queue_Create")!; + expect(createQueue.typeName).toBe("QueueCreate"); + expect(createQueue.verb).toBe("put"); + expect(createQueue.path).toBe("/{queueName}"); + expect(createQueue.rawPath).toBe("/{queueName}"); + + const listMessages = serverModel.operations.find((op) => op.name === "ListMessages")!; + expect(listMessages.verb).toBe("get"); + expect(listMessages.path).toBe("/{queueName}/messages"); + + const setAccessPolicy = serverModel.operations.find((op) => op.name === "SetAccessPolicy")!; + expect(setAccessPolicy.rawPath).toBe("/{queueName}?comp=acl"); + expect(setAccessPolicy.path).toBe("/{queueName}"); + expect(setAccessPolicy.literalQueryParameters).toEqual([{ name: "comp", value: "acl" }]); + }); + + it("captures path and query parameters with their wire names and optionality", async () => { + const { program } = await ApiTester.compile(loadQueuePilotFixture()); + const serverModel = buildServerModel(createTestAzuriteContext(program)); + + const listMessages = serverModel.operations.find((op) => op.name === "ListMessages")!; + const byName = Object.fromEntries(listMessages.parameters.map((p) => [p.name, p])); + + expect(byName.queueName).toMatchObject({ location: "path", optional: false }); + expect(byName.numOfMessages).toMatchObject({ + location: "query", + wireName: "numOfMessages", + optional: true, + }); + expect(byName.visibilityTimeout).toMatchObject({ + location: "query", + optional: true, + type: { kind: "number", constraints: { min: 0, max: 2147483647 } }, + }); + }); + + it("captures the request body type and content types", async () => { + const { program } = await ApiTester.compile(loadQueuePilotFixture()); + const serverModel = buildServerModel(createTestAzuriteContext(program)); + + const createQueue = serverModel.operations.find((op) => op.name === "Queue_Create")!; + expect(createQueue.requestBody).toMatchObject({ + type: { kind: "model", name: "QueueMetadata" }, + contentTypes: ["application/json"], + }); + }); + + it("captures response status codes, headers and bodies", async () => { + const { program } = await ApiTester.compile(loadQueuePilotFixture()); + const serverModel = buildServerModel(createTestAzuriteContext(program)); + + const createQueue = serverModel.operations.find((op) => op.name === "Queue_Create")!; + expect(createQueue.responses).toHaveLength(1); + expect(createQueue.responses[0].statusCode).toBe(201); + expect(createQueue.responses[0].headers).toEqual([ + { name: "requestId", wireName: "x-ms-request-id", type: { kind: "string" }, optional: false }, + ]); + + const getQueueProperties = serverModel.operations.find( + (op) => op.name === "GetQueueProperties", + )!; + const [response] = getQueueProperties.responses; + expect(response.statusCode).toBe(200); + expect(response.headers[0]).toMatchObject({ + wireName: "x-ms-approximate-messages-count", + type: { kind: "number" }, + }); + expect(response.body).toMatchObject({ type: { kind: "model", name: "QueueProperties" } }); + }); + + it("types x-ms-meta header collections as string-or-string-array records", async () => { + const { program } = await ApiTester.compile({ + "main.tsp": ` + import "@typespec/http"; + using Http; + + @service + namespace HeaderCollectionDemo; + + @route("/queues") + @put + op create(@header("x-ms-meta") metadata?: string): { + @statusCode statusCode: 201; + @header("x-ms-meta") metadata?: string; + }; + `, + }); + const serverModel = buildServerModel(createTestAzuriteContext(program)); + + const create = serverModel.operations[0]; + const descriptor = operationDescriptorValue(create); + expect(create.parameters[0].type).toEqual({ + kind: "record", + element: { + kind: "union", + variants: [{ kind: "string" }, { kind: "array", element: { kind: "string" } }], + }, + }); + expect(create.responses[0].headers[0].type).toEqual(create.parameters[0].type); + expect(descriptor[3][0][5]).toBe("x-ms-meta-"); + expect(descriptor[5][0][1]?.[0][3]).toBe("x-ms-meta-"); + }); + + it("registers transitively referenced models with their properties", async () => { + const { program } = await ApiTester.compile(loadQueuePilotFixture()); + const serverModel = buildServerModel(createTestAzuriteContext(program)); + + const modelNames = serverModel.models.map((m) => m.name).sort(); + expect(modelNames).toEqual([ + "AccessPolicy", + "QueueMessage", + "QueueMessageList", + "QueueMetadata", + "QueueProperties", + "SignedIdentifier", + "SignedIdentifiers", + ]); + + const queueMetadata = serverModel.models.find((m) => m.name === "QueueMetadata")!; + const props = Object.fromEntries(queueMetadata.properties.map((p) => [p.name, p])); + expect(props.description).toMatchObject({ optional: true, type: { kind: "string" } }); + expect(props.publicAccess).toMatchObject({ optional: true, type: { kind: "boolean" } }); + }); + + it("does not emit anonymous declarations for the Queue fixture", async () => { + const { program } = await ApiTester.compile(loadQueuePilotFixture()); + const serverModel = buildServerModel(createTestAzuriteContext(program)); + + expect(serverModel.models.some((model) => model.name.startsWith("AnonymousModel"))).toBe(false); + }); + + it("resolves array-of-model types for nested list bodies", async () => { + const { program } = await ApiTester.compile(loadQueuePilotFixture()); + const serverModel = buildServerModel(createTestAzuriteContext(program)); + + const list = serverModel.models.find((m) => m.name === "QueueMessageList")!; + expect(list.properties[0]).toMatchObject({ + name: "messages", + type: { kind: "array", element: { kind: "model", name: "QueueMessage" } }, + }); + }); + + it("applies azurite.tsp overlay changes for Queue swagger customizations", async () => { + const { program } = await ApiTester.compile(loadQueuePilotFixture()); + const serverModel = buildServerModel(createTestAzuriteContext(program)); + + const accessPolicy = serverModel.models.find((m) => m.name === "AccessPolicy")!; + const accessPolicyProps = Object.fromEntries(accessPolicy.properties.map((p) => [p.name, p])); + expect(accessPolicyProps.start).toMatchObject({ optional: true }); + expect(accessPolicyProps.expiry).toMatchObject({ optional: true }); + expect(accessPolicyProps.permission).toMatchObject({ optional: true }); + + const [services] = getAllHttpServices(program); + const listMessages = services[0].operations.find((op) => op.operation.name === "listMessages")!; + const visibilityTimeout = listMessages.parameters.parameters.find( + (p) => p.param.name === "visibilityTimeout", + )!; + expect($(program).type.maxValue(visibilityTimeout.param)).toBe(2147483647); + }); + + it("preserves the shared visibility timeout max when no Azurite overlay overrides it", async () => { + const { program } = await ApiTester.compile({ + "main.tsp": ` + import "./base.tsp"; + `, + "base.tsp": readFixture("base.tsp"), + }); + + const serverModel = buildServerModel(createTestAzuriteContext(program)); + const listMessages = serverModel.operations.find((op) => op.name === "ListMessages")!; + const visibilityTimeout = listMessages.parameters.find( + (parameter) => parameter.name === "visibilityTimeout", + )!; + expect(visibilityTimeout.type).toMatchObject({ + kind: "number", + constraints: { min: 0, max: 604800 }, + }); + }); + + it("uses TCGC @clientName operation overrides when present", async () => { + const { program } = await ApiTester.compile(loadQueuePilotFixture()); + const serverModel = buildServerModel(createTestAzuriteContext(program)); + + expect(serverModel.operations.some((op) => op.name === "Queue_Create")).toBe(true); + expect(serverModel.operations.some((op) => op.name === "CreateQueue")).toBe(false); + }); + + it("uses TCGC @clientName overrides for named model, property, parameter, and header shapes", async () => { + const { program } = await ApiTester.compile({ + "main.tsp": ` + import "@typespec/http"; + import "@azure-tools/typespec-client-generator-core"; + using Http; + using Azure.ClientGenerator.Core; + + @service + namespace NamingDemo; + + @clientName("GeneratedItem") + model Item { + @clientName("clientValue") + value: string; + } + + @route("/items/{itemId}") + @get + op getItem( + @clientName("clientItemId") + @path + itemId: string, + ): { + @statusCode statusCode: 200; + + @clientName("clientRequestId") + @header("x-ms-request-id") + requestId: string; + + @body body: Item; + }; + `, + }); + const serverModel = buildServerModel(createTestAzuriteContext(program)); + + const item = serverModel.models.find((m) => m.name === "GeneratedItem")!; + expect(item).toBeDefined(); + expect(item.properties[0].name).toBe("clientValue"); + + const getItem = serverModel.operations.find((op) => op.name === "GetItem")!; + expect(getItem.parameters[0].name).toBe("clientItemId"); + expect(getItem.responses[0].headers[0].name).toBe("clientRequestId"); + expect(getItem.responses[0].body?.type).toEqual({ kind: "model", name: "GeneratedItem" }); + }); + + it("expands a Record dictionary property to a record type ref instead of an empty named model", async () => { + const { program } = await ApiTester.compile({ + "main.tsp": ` + import "@typespec/http"; + using Http; + + @service + namespace RecordDemo; + + model Item { + tags: Record; + } + + @route("/items") + @get + op getItem(): Item; + `, + }); + const serverModel = buildServerModel(createTestAzuriteContext(program)); + + const item = serverModel.models.find((m) => m.name === "Item")!; + const tags = item.properties.find((p) => p.name === "tags")!; + expect(tags.type).toMatchObject({ kind: "record", element: { kind: "string" } }); + }); + + it("gives distinct anonymous models distinct names instead of collapsing them by shared empty name", async () => { + // Two operations whose inline error-response bodies have genuinely different shapes. Keying + // the anonymous-model cache by name (empty string for both) would incorrectly coalesce them + // into one shared registry entry; keying by TypeSpec object identity keeps them distinct. + const { program } = await ApiTester.compile({ + "main.tsp": ` + import "@typespec/http"; + using Http; + + @service + namespace AnonymousDemo; + + @route("/a") + @get + op getA(): { @statusCode statusCode: 200; code: string }; + + @route("/b") + @get + op getB(): { @statusCode statusCode: 200; message: string }; + `, + }); + const serverModel = buildServerModel(createTestAzuriteContext(program)); + + const anonymousModels = serverModel.models.filter((m) => m.name.startsWith("AnonymousModel")); + expect(anonymousModels.length).toBeGreaterThanOrEqual(2); + expect(new Set(anonymousModels.map((m) => m.name)).size).toBe(anonymousModels.length); + }); + + it("reuses the same anonymous model identity each time it is referenced", async () => { + const { program } = await ApiTester.compile({ + "main.tsp": ` + import "@typespec/http"; + using Http; + + @service + namespace AnonymousIdentityDemo; + + alias InlineBody = { + value: string; + }; + + @route("/items") + @post + op create(@body body: InlineBody): { + @statusCode statusCode: 200; + @body body: InlineBody; + }; + `, + }); + const serverModel = buildServerModel(createTestAzuriteContext(program)); + + const anonymousModels = serverModel.models.filter((m) => m.name.startsWith("AnonymousModel")); + expect(anonymousModels).toHaveLength(1); + + const create = serverModel.operations.find((op) => op.name === "Create")!; + expect(create.requestBody?.type).toEqual({ kind: "model", name: anonymousModels[0].name }); + expect(create.responses[0].body?.type).toEqual({ + kind: "model", + name: anonymousModels[0].name, + }); + }); + + it("keeps anonymous model state isolated per emitter context", async () => { + const { program } = await ApiTester.compile({ + "main.tsp": ` + import "@typespec/http"; + using Http; + + @service + namespace AnonymousContextDemo; + + @route("/items") + @get + op getItem(): { + @statusCode statusCode: 200; + @body body: { + value: string; + }; + }; + `, + }); + + const first = buildServerModel(createTestAzuriteContext(program)); + const second = buildServerModel(createTestAzuriteContext(program)); + + expect(first.models.map((model) => model.name)).toEqual(["AnonymousModel"]); + expect(second.models.map((model) => model.name)).toEqual(["AnonymousModel"]); + expect(second.operations[0].responses[0].body?.type).toEqual( + first.operations[0].responses[0].body?.type, + ); + }); + + it("terminates when anonymous models participate in reference cycles", async () => { + const { program } = await ApiTester.compile({ + "main.tsp": ` + import "@typespec/http"; + using Http; + + @service + namespace AnonymousCycleDemo; + + model Node { + child?: { + parent?: Node; + value: string; + }; + } + + @route("/nodes") + @get + op getNode(): Node; + `, + }); + const serverModel = buildServerModel(createTestAzuriteContext(program)); + + const node = serverModel.models.find((model) => model.name === "Node")!; + const child = node.properties.find((prop) => prop.name === "child")!; + expect(child.type).toMatchObject({ kind: "model", name: "AnonymousModel" }); + + const anonymous = serverModel.models.find((model) => model.name === "AnonymousModel")!; + expect(anonymous.properties.find((prop) => prop.name === "parent")?.type).toEqual({ + kind: "model", + name: "Node", + }); + }); + + it("does not register default error bodies as handler response models", async () => { + const { program } = await ApiTester.compile({ + "main.tsp": ` + import "@typespec/http"; + using Http; + + @service + namespace ErrorDemo; + + @error + model StorageError { + @header("x-ms-error-code") + errorCode?: string; + code?: string; + message?: string; + } + + @route("/items") + @get + op getItem(): { + @statusCode statusCode: 200; + @body body: string; + } | StorageError; + `, + }); + const serverModel = buildServerModel(createTestAzuriteContext(program)); + + const getItem = serverModel.operations.find((op) => op.name === "GetItem")!; + const defaultResponse = getItem.responses.find((response) => response.statusCode === "*")!; + expect(defaultResponse.body).toBeUndefined(); + expect(serverModel.models.some((model) => model.name.startsWith("AnonymousModel"))).toBe(false); + }); + + it("reuses an already-registered named model for an equivalent anonymous model expression", async () => { + const { program } = await ApiTester.compile({ + "main.tsp": ` + import "@typespec/http"; + using Http; + + @service + namespace AnonymousReuseDemo; + + model Item { + value: string; + } + + @route("/items") + @post + op createItem(@body body: Item): { + @statusCode statusCode: 200; + @body body: { + value: string; + }; + }; + `, + }); + const serverModel = buildServerModel(createTestAzuriteContext(program)); + + const createItem = serverModel.operations.find((op) => op.name === "CreateItem")!; + expect(createItem.responses[0].body?.type).toEqual({ kind: "model", name: "Item" }); + expect(serverModel.models.map((model) => model.name)).toEqual(["Item"]); + }); + + it("qualifies all colliding inferred runtime operation names across interfaces", async () => { + const { program } = await ApiTester.compile({ + "main.tsp": ` + import "@typespec/http"; + using Http; + + @service + namespace CollisionDemo; + + @route("/a") + interface A { + @get get_url(): string; + } + + @route("/b") + interface XML_queue { + @get get_url(): string; + } + `, + }); + const context = createTestAzuriteContext(program); + const serverModel = buildServerModel(context); + + const names = serverModel.operations.map((op) => op.name); + const typeNames = serverModel.operations.map((op) => op.typeName); + expect(context.diagnostics).toEqual([]); + expect(names).toHaveLength(2); + expect(names).toContain("AGetUrl"); + expect(names).toContain("XmlQueueGetUrl"); + expect(typeNames).toEqual(names); + + // Each operation's generated metadata should also carry which interface declared it, so a + // consumer (e.g. a dispatcher needing to classify operations by resource) doesn't have to + // guess resource identity from the operation name/path alone. + const interfaceNames = serverModel.operations.map((op) => op.interfaceName).sort(); + expect(interfaceNames).toEqual(["A", "XML_queue"]); + }); + + it("diagnoses duplicate explicit runtime operation names instead of qualifying them", async () => { + const { program } = await ApiTester.compile({ + "main.tsp": ` + import "@typespec/http"; + import "@azure-tools/typespec-client-generator-core"; + using Http; + using Azure.ClientGenerator.Core; + + @service + namespace ExplicitCollisionDemo; + + @route("/a") + interface A { + @clientName("Same") + @get + getA(): string; + } + + @route("/b") + interface B { + @clientName("Same") + @get + getB(): string; + } + `, + }); + const context = createTestAzuriteContext(program); + const serverModel = buildServerModel(context); + + expect(serverModel.operations).toEqual([]); + expect(context.diagnostics.map((diagnostic) => diagnostic.code)).toEqual([ + "@azure-tools/typespec-azurite-emitter/operation-name-collision", + "@azure-tools/typespec-azurite-emitter/operation-name-collision", + ]); + }); + + it("qualifies TypeScript type names when distinct runtime names normalize to the same identifier", async () => { + const { program } = await ApiTester.compile({ + "main.tsp": ` + import "@typespec/http"; + import "@azure-tools/typespec-client-generator-core"; + using Http; + using Azure.ClientGenerator.Core; + + @service + namespace TypeNameCollisionDemo; + + @route("/a") + interface A { + @clientName("get_url") + @get + getA(): string; + } + + @route("/b") + interface B { + @clientName("getUrl") + @get + getB(): string; + } + `, + }); + const context = createTestAzuriteContext(program); + const serverModel = buildServerModel(context); + + expect(context.diagnostics).toEqual([]); + expect(serverModel.operations.map((op) => op.name)).toEqual(["get_url", "getUrl"]); + expect(serverModel.operations.map((op) => op.typeName)).toEqual(["AGetUrl", "BGetUrl"]); + }); + + it("diagnoses same-interface operation name collisions that remain after qualification", async () => { + const { program } = await ApiTester.compile({ + "main.tsp": ` + import "@typespec/http"; + using Http; + + @service + namespace SameInterfaceCollisionDemo; + + @route("/items") + interface Items { + @route("/a") + @get + get_url(): string; + + @route("/b") + @get + getUrl(): string; + } + `, + }); + const context = createTestAzuriteContext(program); + const serverModel = buildServerModel(context); + + expect(serverModel.operations).toEqual([]); + expect(context.diagnostics.map((diagnostic) => diagnostic.code)).toContain( + "@azure-tools/typespec-azurite-emitter/operation-name-collision", + ); + }); + + it("diagnoses namespace operation name collisions that cannot be interface-qualified", async () => { + const { program } = await ApiTester.compile({ + "main.tsp": ` + import "@typespec/http"; + using Http; + + @service + namespace NoInterfaceCollisionDemo { + @route("/a") + @get + op get_url(): string; + + @route("/b") + @get + op getUrl(): string; + } + `, + }); + const context = createTestAzuriteContext(program); + const serverModel = buildServerModel(context); + + expect(serverModel.operations).toEqual([]); + expect(context.diagnostics.map((diagnostic) => diagnostic.code)).toContain( + "@azure-tools/typespec-azurite-emitter/operation-name-collision", + ); + }); + + it("applies @makeOptional from an azurite.tsp-style overlay to relax a required base-spec property", async () => { + const { program } = await ApiTester.compile({ + "base.tsp": ` + import "@typespec/http"; + using Http; + + @service + namespace RequiredDemo; + + model Options { + visibilityTimeout: int32; + } + + @route("/options") + @put + op setOptions(@body options: Options): void; + `, + "azurite.tsp": ` + import "./base.tsp"; + import "@azure-tools/typespec-azurite-emitter"; + + using RequiredDemo; + using Azurite; + + @@makeOptional(RequiredDemo.Options.visibilityTimeout); + `, + "main.tsp": ` + import "./base.tsp"; + import "./azurite.tsp"; + `, + }); + const serverModel = buildServerModel(createTestAzuriteContext(program)); + + const options = serverModel.models.find((m) => m.name === "Options")!; + const visibilityTimeout = options.properties.find((p) => p.name === "visibilityTimeout")!; + expect(visibilityTimeout.optional).toBe(true); + }); +}); diff --git a/packages/typespec-azurite-emitter/test/e2e.test.ts b/packages/typespec-azurite-emitter/test/e2e.test.ts new file mode 100644 index 0000000000..b1add235e6 --- /dev/null +++ b/packages/typespec-azurite-emitter/test/e2e.test.ts @@ -0,0 +1,384 @@ +import { describe, expect, it } from "vitest"; +import { ApiTester, EmitterTester, loadQueuePilotFixture } from "./tester.js"; + +describe("end-to-end emit", () => { + it("compiles the queue-pilot fixture (base + azurite overlay) without diagnostics and emits all artifacts", async () => { + const [{ outputs }, diagnostics] = + await EmitterTester.compileAndDiagnose(loadQueuePilotFixture()); + + expect(diagnostics).toEqual([]); + + const fileNames = Object.keys(outputs) + .map((path) => path.split("/").pop()) + .sort(); + expect(fileNames).toEqual([ + "handlers.ts", + "metadata.ts", + "models.ts", + "operations.ts", + "serialization.ts", + ]); + }); + + it("generates models.ts with all referenced data models", async () => { + const { outputs } = await EmitterTester.compile(loadQueuePilotFixture()); + const modelsFile = findOutput(outputs, "models.ts"); + + expect(modelsFile).toContain("export interface AccessPolicy {"); + expect(modelsFile).toContain("export interface QueueMetadata {"); + expect(modelsFile).toContain("export interface QueueProperties {"); + expect(modelsFile).toContain("export interface QueueMessage {"); + expect(modelsFile).toContain("export interface QueueMessageList {"); + expect(modelsFile).toContain("messages: Array;"); + expect(modelsFile).toContain("start?: string;"); + expect(modelsFile).toContain("permission?: string;"); + }); + + it("generates metadata.ts with route metadata for all fixture operations", async () => { + const { outputs } = await EmitterTester.compile(loadQueuePilotFixture()); + const metadataFile = findOutput(outputs, "metadata.ts"); + const operationsFile = findOutput(outputs, "operations.ts"); + + expect(metadataFile).toContain(`defineServiceMetadata({`); + expect(operationsFile).toContain(`export const QueueCreateMetadata = defineOperation([`); + expect(operationsFile).toContain(`"Queue_Create"`); + expect(operationsFile).toContain(`"put"`); + expect(operationsFile).toContain(`"ListMessages"`); + expect(operationsFile).toContain(`"get"`); + expect(operationsFile).toContain(`"SetAccessPolicy"`); + expect(operationsFile).toContain(`"query"`); + expect(operationsFile).toContain(`"path"`); + expect(metadataFile).toContain(`from "../runtime/serializationRuntime";`); + expect(metadataFile).toContain(`QueueCreateMetadata`); + expect(metadataFile).toContain(`QueueMetadataXmlMetadata`); + expect(metadataFile).toMatch( + /export const operations: readonly OperationMetadata\[\] = serviceMetadata\.operations;?/, + ); + expect(operationsFile).not.toContain(`defineOperations`); + expect(metadataFile).not.toContain(`"Queue_Create"`); + }); + + it("generates XML model metadata with requiredness for required TypeSpec properties", async () => { + const { outputs } = await EmitterTester.compile(loadQueuePilotFixture()); + const modelsFile = findOutput(outputs, "models.ts"); + + expect(modelsFile).toMatch( + /SignedIdentifierXmlMetadata[\s\S]*\["id", "id", "string", undefined, undefined, true\]/, + ); + expect(modelsFile).toMatch( + /SignedIdentifierXmlMetadata[\s\S]*"accessPolicy"[\s\S]*"accessPolicy"[\s\S]*\["model", "AccessPolicy"\][\s\S]*undefined,[\s\S]*undefined,[\s\S]*true/, + ); + expect(modelsFile).toMatch( + /AccessPolicyXmlMetadata[\s\S]*\["start", "start", "datetime"\][\s\S]*\["expiry", "expiry", "datetime"\][\s\S]*\["permission", "permission", "string"\]/, + ); + expect(modelsFile).not.toMatch( + /AccessPolicyXmlMetadata[\s\S]*\["start", "start", "datetime", undefined, undefined, true\]/, + ); + }); + + it("generates sparse numeric constraints for operation parameters and XML model properties", async () => { + const { outputs } = await EmitterTester.compile({ + "main.tsp": ` + import "@typespec/http"; + using Http; + + @service + namespace NumericConstraintDemo; + + model NumericBody { + @minValue(1) + days: int32; + + @maxValueExclusive(60) + optionalLimit?: int32; + + unconstrained?: int32; + } + + @route("/items/{id}") + @put + op putItem( + @path + @minValueExclusive(0) + id: int32, + + @query + @minValue(0) + timeout?: int32 = 30, + + @header("x-count") + @maxValue(10) + count: int32, + + @body body: NumericBody, + ): { + @statusCode statusCode: 200; + + @header("x-retry-after") + @maxValue(120) + retryAfter: int32; + + @body body: NumericBody; + }; + `, + }); + const operationsFile = findOutput(outputs, "operations.ts"); + const modelsFile = findOutput(outputs, "models.ts"); + + expect(operationsFile).toMatch(/"id"[\s\S]*"path"[\s\S]*"number"[\s\S]*minExclusive: 0/); + expect(operationsFile).toMatch(/"timeout"[\s\S]*"query"[\s\S]*"number"[\s\S]*min: 0/); + expect(operationsFile).toMatch(/"count"[\s\S]*"header"[\s\S]*"number"[\s\S]*max: 10/); + expect(operationsFile).toMatch( + /"retryAfter"[\s\S]*"x-retry-after"[\s\S]*"number"[\s\S]*max: 120/, + ); + expect(modelsFile).toMatch(/"days"[\s\S]*"number"[\s\S]*min: 1[\s\S]*true/); + expect(modelsFile).toMatch(/"optionalLimit"[\s\S]*"number"[\s\S]*maxExclusive: 60/); + expect(modelsFile).toContain(`["unconstrained", "unconstrained", "number"]`); + }); + + it("imports referenced model types into operations.ts so the file compiles standalone", async () => { + const { outputs } = await EmitterTester.compile(loadQueuePilotFixture()); + const operationsFile = findOutput(outputs, "operations.ts"); + + expect(operationsFile).toMatch( + /import type \{[^}]*QueueMetadata[^}]*\} from "\.\/models\.js";/, + ); + }); + + it("generates handlers.ts with a method per operation", async () => { + const { outputs } = await EmitterTester.compile(loadQueuePilotFixture()); + const handlersFile = findOutput(outputs, "handlers.ts"); + + expect(handlersFile).toContain("export interface IServiceHandler {"); + expect(handlersFile).toMatch( + /queue_Create\(\s*params: QueueCreateParameters,\s*context: Context,\s*\): Promise;/, + ); + expect(handlersFile).toMatch( + /getQueueProperties\(\s*params: GetQueuePropertiesParameters,\s*context: Context,\s*\): Promise;/, + ); + expect(handlersFile).toMatch( + /listMessages\(\s*params: ListMessagesParameters,\s*context: Context,\s*\): Promise;/, + ); + expect(handlersFile).toMatch( + /setAccessPolicy\(\s*params: SetAccessPolicyParameters,\s*context: Context,\s*\): Promise;/, + ); + }); + + it("generates thin serialization.ts binding to the Azurite runtime", async () => { + const { outputs } = await EmitterTester.compile(loadQueuePilotFixture()); + const serializationFile = findOutput(outputs, "serialization.ts"); + + expect(serializationFile).not.toContain(`@azure/ms-rest-js`); + expect(serializationFile).toContain(`createSerializationRuntime`); + expect(serializationFile).toContain(`import { serviceMetadata } from "./metadata";`); + expect(serializationFile).toContain(`from "../runtime/serializationRuntime";`); + expect(serializationFile).toContain( + `const runtime = createSerializationRuntime(serviceMetadata);`, + ); + expect(serializationFile).toContain( + `export const deserializeRequest = runtime.deserializeRequest;`, + ); + expect(serializationFile).toContain( + `export const serializeResponse = runtime.serializeResponse;`, + ); + expect(serializationFile).toContain( + `export const hasGeneratedSerialization = runtime.hasGeneratedSerialization;`, + ); + expect(serializationFile).not.toContain(`defineServiceMetadata`); + expect(serializationFile).not.toContain(`xmlModels`); + expect(serializationFile).not.toContain( + `function deserializeDeleteQueueRequest(req: IRequest)`, + ); + expect(serializationFile).not.toContain(`function serializeDeleteQueueResponse`); + expect(serializationFile).not.toContain(`function deserializeMetadataRequest`); + expect(serializationFile).not.toContain(`function serializeMetadataResponse`); + expect(serializationFile).not.toContain(`function deserializePathParameter`); + expect(serializationFile).not.toContain(`function serializeXmlModel`); + expect(serializationFile).not.toContain(`function deserializeXmlModel`); + expect(serializationFile).not.toContain(`function setHeaderCollection`); + expect(serializationFile).not.toContain(`switch (name)`); + expect(serializationFile).not.toContain(`Context`); + expect(serializationFile).not.toContain(`IHandlerParameters`); + }); + + it("honors the configurable runtime import path in generated artifacts", async () => { + const customEmitterTester = ApiTester.emit("@azure-tools/typespec-azurite-emitter", { + runtimeImport: "../custom/runtime.js", + }); + const { outputs } = await customEmitterTester.compile(loadQueuePilotFixture()); + + expect(findOutput(outputs, "metadata.ts")).toContain(`from "../custom/runtime.js";`); + expect(findOutput(outputs, "models.ts")).toContain(`from "../custom/runtime.js";`); + expect(findOutput(outputs, "operations.ts")).toContain(`from "../custom/runtime.js";`); + expect(findOutput(outputs, "serialization.ts")).toContain(`from "../custom/runtime.js";`); + }); +}); + +describe("emit diagnostics", () => { + it("documents compact union descriptor policy", async () => { + const [{ outputs }, diagnostics] = await EmitterTester.compileAndDiagnose({ + "main.tsp": ` + import "@typespec/http"; + using Http; + + @service + namespace UnsupportedUnionDemo; + + @route("/items") + @get + op get(@query value: string | int32): { + @statusCode statusCode: 204; + }; + `, + }); + + expect(diagnostics).toEqual([]); + expect(findOutput(outputs, "operations.ts")).toContain( + `["value", "value", "query", ["union", ["string", "number"]], true]`, + ); + }); + + it("reports unsupported scalar wire types", async () => { + const [, diagnostics] = await EmitterTester.compileAndDiagnose({ + "main.tsp": ` + import "@typespec/http"; + using Http; + + @service + namespace UnsupportedScalarDemo; + + scalar unsupported; + + @route("/items") + @get + op get(@query value: unsupported): { + @statusCode statusCode: 204; + }; + `, + }); + + expect(diagnostics.map((diagnostic) => diagnostic.code)).toContain( + "@azure-tools/typespec-azurite-emitter/skipped-operation", + ); + expect(diagnostics[0]?.message).toContain("unsupported scalar unsupported"); + }); + + it("reports unrepresentable numeric literals instead of substituting zero", async () => { + const [, diagnostics] = await EmitterTester.compileAndDiagnose({ + "main.tsp": ` + import "@typespec/http"; + using Http; + + @service + namespace NumericLiteralDemo; + + @route("/items") + @get + op get(): { + @statusCode statusCode: 200; + @header("x-huge") value: 1e10000; + }; + `, + }); + + expect(diagnostics.map((diagnostic) => diagnostic.code)).toContain( + "@azure-tools/typespec-azurite-emitter/skipped-operation", + ); + expect(diagnostics[0]?.message).toContain("cannot be represented as a JavaScript number"); + }); + + it("reports multiple services", async () => { + const [, diagnostics] = await EmitterTester.compileAndDiagnose({ + "main.tsp": ` + import "@typespec/http"; + using Http; + + @service namespace First { + @route("/first") @get op get(): { @statusCode statusCode: 204; }; + } + + @service namespace Second { + @route("/second") @get op get(): { @statusCode statusCode: 204; }; + } + `, + }); + + expect(diagnostics.map((diagnostic) => diagnostic.code)).toContain( + "@azure-tools/typespec-azurite-emitter/multiple-services", + ); + }); + + it("prevents output when operation names collide without deterministic qualification", async () => { + const [{ outputs }, diagnostics] = await EmitterTester.compileAndDiagnose({ + "main.tsp": ` + import "@typespec/http"; + using Http; + + @service + namespace OperationCollisionDemo { + @route("/a") @get op get_url(): string; + @route("/b") @get op getUrl(): string; + } + `, + }); + + expect(diagnostics.map((diagnostic) => diagnostic.code)).toContain( + "@azure-tools/typespec-azurite-emitter/operation-name-collision", + ); + expect(outputs).toEqual({}); + }); + + it("reports HTTP diagnostics before rendering", async () => { + const [{ outputs }, diagnostics] = await EmitterTester.compileAndDiagnose({ + "main.tsp": ` + import "@typespec/http"; + using Http; + + @service + namespace HttpDiagnosticDemo { + @route("/same") @get op first(): { @statusCode statusCode: 204; }; + @route("/same") @get op second(): { @statusCode statusCode: 204; }; + } + `, + }); + + expect(diagnostics.some((diagnostic) => diagnostic.code.includes("duplicate"))).toBe(true); + expect(outputs).toEqual({}); + }); + + it("documents enum-as-string wire policy", async () => { + const [{ outputs }, diagnostics] = await EmitterTester.compileAndDiagnose({ + "main.tsp": ` + import "@typespec/http"; + using Http; + + enum Mode { + fast, + slow, + } + + @service + namespace EnumDemo { + @route("/items") @get op get(@query mode: Mode): { + @statusCode statusCode: 204; + }; + } + `, + }); + + expect(diagnostics).toEqual([]); + expect(findOutput(outputs, "operations.ts")).toContain( + `["mode", "mode", "query", "string", true]`, + ); + }); +}); + +function findOutput(outputs: Record, suffix: string): string { + const key = Object.keys(outputs).find((k) => k.endsWith(suffix)); + if (!key) { + throw new Error( + `No output file ending with ${suffix} found in ${Object.keys(outputs).join(", ")}`, + ); + } + return outputs[key]; +} diff --git a/packages/typespec-azurite-emitter/test/fixtures/queue-pilot/azurite.tsp b/packages/typespec-azurite-emitter/test/fixtures/queue-pilot/azurite.tsp new file mode 100644 index 0000000000..0844f40008 --- /dev/null +++ b/packages/typespec-azurite-emitter/test/fixtures/queue-pilot/azurite.tsp @@ -0,0 +1,15 @@ +import "./base.tsp"; +import "@azure-tools/typespec-azurite-emitter"; +import "@azure-tools/typespec-client-generator-core"; + +using QueuePilot; +using Azurite; +using Azure.ClientGenerator.Core; + +@@clientName(QueuePilot.createQueue, "Queue_Create"); + +@@maxValue(QueuePilot.VisibilityTimeoutParameter.visibilityTimeout, 2147483647); + +@@makeOptional(QueuePilot.AccessPolicy.start); +@@makeOptional(QueuePilot.AccessPolicy.expiry); +@@makeOptional(QueuePilot.AccessPolicy.permission); diff --git a/packages/typespec-azurite-emitter/test/fixtures/queue-pilot/base.tsp b/packages/typespec-azurite-emitter/test/fixtures/queue-pilot/base.tsp new file mode 100644 index 0000000000..e6730583cd --- /dev/null +++ b/packages/typespec-azurite-emitter/test/fixtures/queue-pilot/base.tsp @@ -0,0 +1,95 @@ +import "@typespec/http"; + +using Http; + +@service(#{ title: "Queue Pilot Service" }) +namespace QueuePilot { + alias VisibilityTimeoutParameter = { + @minValue(0) + @maxValue(604800) + @query + visibilityTimeout?: int32; + }; + + model AccessPolicy { + start: utcDateTime; + expiry: utcDateTime; + permission: string; + } + + model SignedIdentifier { + id: string; + accessPolicy: AccessPolicy; + } + + model SignedIdentifiers { + items: SignedIdentifier[]; + } + + model QueueMetadata { + /** Arbitrary emulator-defined metadata values for the queue. */ + description?: string; + + /** Whether the queue allows anonymous access. */ + publicAccess?: boolean; + } + + model QueueProperties { + /** Name of the queue. */ + name: string; + + /** Arbitrary emulator-defined metadata values for the queue. */ + description?: string; + } + + model QueueMessage { + /** Opaque message id assigned by the service. */ + messageId: string; + + /** Message body text. */ + messageText: string; + } + + model QueueMessageList { + messages: QueueMessage[]; + } + + @route("/{queueName}") + @put + op createQueue(@path queueName: string, @body metadata: QueueMetadata): { + @statusCode statusCode: 201; + @header("x-ms-request-id") requestId: string; + }; + + @route("/{queueName}") + @get + op getQueueProperties(@path queueName: string): { + @statusCode statusCode: 200; + @header("x-ms-approximate-messages-count") approximateMessagesCount: int32; + @body properties: QueueProperties; + }; + + @route("/{queueName}") + @delete + op deleteQueue(@path queueName: string): { + @statusCode statusCode: 204; + @header("x-ms-request-id") requestId: string; + }; + + @route("/{queueName}/messages") + @get + op listMessages( + @path queueName: string, + @query numOfMessages?: int32, + ...VisibilityTimeoutParameter, + ): { + @statusCode statusCode: 200; + @body messages: QueueMessageList; + }; + + @route("/{queueName}?comp=acl") + @put + op setAccessPolicy(@path queueName: string, @body queueAcl: SignedIdentifiers): { + @statusCode statusCode: 204; + }; +} diff --git a/packages/typespec-azurite-emitter/test/render.test.tsx b/packages/typespec-azurite-emitter/test/render.test.tsx new file mode 100644 index 0000000000..19869e74e7 --- /dev/null +++ b/packages/typespec-azurite-emitter/test/render.test.tsx @@ -0,0 +1,432 @@ +import type { Children } from "@alloy-js/core"; +import { code } from "@alloy-js/core"; +import { d } from "@alloy-js/core/testing"; +import * as ts from "@alloy-js/typescript"; +import type { Program } from "@typespec/compiler"; +import { Output } from "@typespec/emitter-framework"; +import * as ef from "@typespec/emitter-framework/typescript"; +import { beforeAll, describe, expect, it } from "vitest"; +import { buildServerModel } from "../src/build-model.js"; +import type { ServerModel } from "../src/model.js"; +import { GeneratedSourceFile } from "../src/render/generated-source-file.js"; +import { + contextRefkey, + modelXmlMetadataRefkey, + operationMetadataRefkey, + operationParametersRefkey, + operationResponseRefkey, +} from "../src/render/refkeys.js"; +import { ServiceHandlerInterface } from "../src/render/render-handlers.js"; +import { operationDescriptorValue, renderMetadata } from "../src/render/render-metadata.js"; +import { renderModels } from "../src/render/render-models.js"; +import { renderOperations } from "../src/render/render-operations.js"; +import { renderSerialization } from "../src/render/render-serialization.js"; +import { createRuntimeSymbols } from "../src/render/runtime-symbols.js"; +import { ApiTester, createTestAzuriteContext } from "./tester.js"; + +let testProgram: Program; +let sampleServerModel: ServerModel; +const runtimeSymbols = createRuntimeSymbols("../runtime/serializationRuntime"); + +beforeAll(async () => { + const { program } = await ApiTester.compile({ + "main.tsp": ` + import "@typespec/http"; + using Http; + + @service + namespace RenderTest; + + @doc("Queue metadata.") + model QueueMetadata { + @doc("A description.") + description?: string; + publicAccess?: boolean; + tags?: string[]; + metadata?: Record; + } + + model QueueMessage { + messageId: string; + metadata: QueueMetadata; + tags?: string[]; + } + + @route("/{queueName}") + interface Queue { + @doc("Creates a queue.") + @put + op CreateQueue(@path queueName: string, @body body: string): { + @statusCode statusCode: 201; + @header("x-ms-request-id") requestId: string; + }; + + @delete + op DeleteQueue(@path queueName: string): { + @statusCode statusCode: 204; + @header("x-ms-request-id") requestId: string; + }; + + @get + @route("/metadata") + op GetMetadata(@path queueName: string): { + @statusCode statusCode: 200; + @body body: QueueMetadata; + }; + + @get + @route("/message") + op GetMessage(@path queueName: string): { + @statusCode statusCode: 200; + @body body: QueueMessage; + }; + } + `, + }); + testProgram = program; + const builtModel = buildServerModel(createTestAzuriteContext(program)); + sampleServerModel = { + ...builtModel, + models: builtModel.models.filter((model) => + ["QueueMetadata", "QueueMessage"].includes(model.name), + ), + operations: builtModel.operations.filter((operation) => + ["CreateQueue", "DeleteQueue"].includes(operation.name), + ), + }; +}); + +function Wrapper(props: { children: Children }) { + return ( + + {props.children} + + ); +} + +function SourceFile(props: { children: Children }) { + return ( + + {props.children} + + ); +} + +describe("renderModels", () => { + it("renders a generated source file header before synthesized imports", () => { + expect( + + + + + (metadata) + + } + /> + + , + ).toRenderTo({ + "test.ts": d` + // This file was automatically generated by @azure-tools/typespec-azurite-emitter. + // Do not edit this file manually; re-run \`tsp compile\` to regenerate it. + + + import { createSerializationRuntime } from "../runtime/serializationRuntime"; + + const runtime = createSerializationRuntime(metadata) + `, + }); + }); + + it("renders TypeScript interfaces for server data models", () => { + expect({renderModels(sampleServerModel, runtimeSymbols)}).toRenderTo(` + // This file was automatically generated by @azure-tools/typespec-azurite-emitter. + // Do not edit this file manually; re-run \`tsp compile\` to regenerate it. + + + import { defineXmlModel } from "../runtime/serializationRuntime"; + + /** + * Queue metadata. + */ + export interface QueueMetadata { + /** + * A description. + */ + description?: string; + publicAccess?: boolean; + tags?: Array; + metadata?: Record; + } + export const QueueMetadataXmlMetadata = defineXmlModel("QueueMetadata", [ + "QueueMetadata", + [ + ["description", "description", "string"], + ["publicAccess", "publicAccess", "boolean"], + ["tags", "tags", ["array", "string"], undefined, "tags"], + ["metadata", "metadata", ["record", "string"]] + ] + ]) + export interface QueueMessage { + messageId: string; + metadata: QueueMetadata; + tags?: Array; + } + export const QueueMessageXmlMetadata = defineXmlModel("QueueMessage", [ + "QueueMessage", + [ + ["messageId", "messageId", "string", undefined, undefined, true], + [ + "metadata", + "metadata", + ["model", "QueueMetadata"], + undefined, + undefined, + true + ], + ["tags", "tags", ["array", "string"], undefined, "tags"] + ] + ]) + `); + }); +}); + +describe("renderOperations", () => { + it("renders operation declarations only", () => { + expect({renderOperations(sampleServerModel, runtimeSymbols)}).toRenderTo(` + // This file was automatically generated by @azure-tools/typespec-azurite-emitter. + // Do not edit this file manually; re-run \`tsp compile\` to regenerate it. + + + import { defineOperation } from "../runtime/serializationRuntime"; + + /** + * Creates a queue. + */ + export interface CreateQueueParameters { + queueName: string; + body: string; + } + export type CreateQueueResponse = { + statusCode: 201; + headers: { + requestId: string; + }; + }; + export const CreateQueueMetadata = defineOperation([ + "CreateQueue", + "put", + "/{queueName}", + [["queueName", "queueName", "path", "string", true]], + ["string", ["text/plain"]], + [[201, [["requestId", "x-ms-request-id", "string"]]]], + "Queue" + ]) + + export interface DeleteQueueParameters { + queueName: string; + } + export type DeleteQueueResponse = { + statusCode: 204; + headers: { + requestId: string; + }; + }; + export const DeleteQueueMetadata = defineOperation([ + "DeleteQueue", + "delete", + "/{queueName}", + [["queueName", "queueName", "path", "string", true]], + undefined, + [[204, [["requestId", "x-ms-request-id", "string"]]]], + "Queue" + ]) + + `); + }); + + it("renders compact operation descriptor values with Alloy recursive value expressions", () => { + expect( + + + , + ).toRenderTo(` + [ + "CreateQueue", + "put", + "/{queueName}", + [["queueName", "queueName", "path", "string", true]], + ["string", ["text/plain"]], + [[201, [["requestId", "x-ms-request-id", "string"]]]], + "Queue" + ] + `); + }); +}); + +describe("ServiceHandlerInterface", () => { + it("renders handler methods with automatic cross-file imports from refkeys", () => { + expect( + + + + + {code`{ statusCode: 201 }`} + + + + {code`{ statusCode: 204 }`} + + + + + + {code`;`} + + + + + , + ).toRenderTo({ + "operations.ts": d` + export interface CreateQueueParameters { + + }export type CreateQueueResponse = { statusCode: 201 };export interface DeleteQueueParameters { + + }export type DeleteQueueResponse = { statusCode: 204 }; + `, + "handlers.ts": d` + import type { + CreateQueueParameters, + CreateQueueResponse, + DeleteQueueParameters, + DeleteQueueResponse, + } from "./operations.js"; + + export interface Context { + readonly contextId: string; + } + export interface IServiceHandler { + /** + * Creates a queue. + * + * @param {CreateQueueParameters} params + * @param {Context} context + */ + createQueue( + params: CreateQueueParameters, + context: Context, + ): Promise; + deleteQueue( + params: DeleteQueueParameters, + context: Context, + ): Promise; + } + `, + }); + }); +}); + +describe("renderMetadata", () => { + it("renders one consolidated service metadata manifest", () => { + expect( + + + + + + + + + + {renderMetadata(sampleServerModel, "../runtime/serializationRuntime", runtimeSymbols)} + , + ).toRenderTo({ + "metadata.ts": d` + // This file was automatically generated by @azure-tools/typespec-azurite-emitter. + // Do not edit this file manually; re-run \`tsp compile\` to regenerate it. + + + import { + defineServiceMetadata, + type OperationMetadata, + type ServiceMetadata, + } from "../runtime/serializationRuntime"; + + import { QueueMetadataXmlMetadata, QueueMessageXmlMetadata } from "./models"; + import { CreateQueueMetadata, DeleteQueueMetadata } from "./operations"; + + export type { OperationMetadata, ServiceMetadata } from "../runtime/serializationRuntime"; + export const serviceMetadata: ServiceMetadata = defineServiceMetadata({ operations: [CreateQueueMetadata, DeleteQueueMetadata], xmlModels: [QueueMetadataXmlMetadata, QueueMessageXmlMetadata] }); + + export const operations: readonly OperationMetadata[] = serviceMetadata.operations; + `, + "models.ts": d` + export const QueueMetadataXmlMetadataexport const QueueMessageXmlMetadata + `, + "operations.ts": d` + export const CreateQueueMetadataexport const DeleteQueueMetadata + `, + }); + }); +}); + +describe("renderSerialization components", () => { + it("renders thin serialization binding to the configured runtime", () => { + expect({renderSerialization(sampleServerModel, runtimeSymbols)}).toRenderTo(` + // This file was automatically generated by @azure-tools/typespec-azurite-emitter. + // Do not edit this file manually; re-run \`tsp compile\` to regenerate it. + + + import { createSerializationRuntime } from "../runtime/serializationRuntime"; + + import { serviceMetadata } from "./metadata"; + + const runtime = createSerializationRuntime(serviceMetadata); + + export const deserializeRequest = runtime.deserializeRequest; + export const serializeResponse = runtime.serializeResponse; + export const hasGeneratedSerialization = runtime.hasGeneratedSerialization; + `); + }); +}); diff --git a/packages/typespec-azurite-emitter/test/tester.ts b/packages/typespec-azurite-emitter/test/tester.ts new file mode 100644 index 0000000000..cbed73959c --- /dev/null +++ b/packages/typespec-azurite-emitter/test/tester.ts @@ -0,0 +1,34 @@ +import { resolvePath, type Program } from "@typespec/compiler"; +import { createTester } from "@typespec/compiler/testing"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { createAzuriteEmitterContext } from "../src/context.js"; +import { normalizeOptions } from "../src/options.js"; + +export const ApiTester = createTester(resolvePath(import.meta.dirname, ".."), { + libraries: [ + "@typespec/http", + "@azure-tools/typespec-client-generator-core", + "@azure-tools/typespec-azurite-emitter", + ], +}); + +export const EmitterTester = ApiTester.emit("@azure-tools/typespec-azurite-emitter", {}); + +const FIXTURE_DIR = join(import.meta.dirname, "fixtures", "queue-pilot"); + +export function readFixture(name: string): string { + return readFileSync(join(FIXTURE_DIR, name), "utf-8"); +} + +export function loadQueuePilotFixture(): Record { + return { + "main.tsp": `import "./base.tsp";\nimport "./azurite.tsp";\n`, + "base.tsp": readFixture("base.tsp"), + "azurite.tsp": readFixture("azurite.tsp"), + }; +} + +export function createTestAzuriteContext(program: Program) { + return createAzuriteEmitterContext(program, normalizeOptions({})); +} diff --git a/packages/typespec-azurite-emitter/test/vitest.setup.ts b/packages/typespec-azurite-emitter/test/vitest.setup.ts new file mode 100644 index 0000000000..96cd441912 --- /dev/null +++ b/packages/typespec-azurite-emitter/test/vitest.setup.ts @@ -0,0 +1 @@ +import "@alloy-js/core/testing"; diff --git a/packages/typespec-azurite-emitter/tsconfig.build.json b/packages/typespec-azurite-emitter/tsconfig.build.json new file mode 100644 index 0000000000..a5131ded97 --- /dev/null +++ b/packages/typespec-azurite-emitter/tsconfig.build.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "include": ["src/**/*.ts", "src/**/*.tsx", "test/**/*.ts", "generated-defs/**/*.ts"], + "exclude": ["**/*.test.*", "**/*-test.ts"], + "references": [ + { "path": "../../core/packages/compiler/tsconfig.build.json" }, + { "path": "../../core/packages/http/tsconfig.build.json" } + ] +} diff --git a/packages/typespec-azurite-emitter/tsconfig.json b/packages/typespec-azurite-emitter/tsconfig.json new file mode 100644 index 0000000000..46a076fc4c --- /dev/null +++ b/packages/typespec-azurite-emitter/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "../../core/tsconfig.base.json", + "composite": true, + "references": [ + { "path": "../../core/packages/compiler/tsconfig.json" }, + { "path": "../../core/packages/http/tsconfig.json" } + ], + "compilerOptions": { + "outDir": "dist", + "rootDir": ".", + "tsBuildInfoFile": "temp/tsconfig.tsbuildinfo", + "jsx": "preserve", + "jsxImportSource": "@alloy-js/core", + "verbatimModuleSyntax": true + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "generated-defs/**/*.ts"], + "exclude": ["test", "vitest.config.ts"] +} diff --git a/packages/typespec-azurite-emitter/vitest.config.ts b/packages/typespec-azurite-emitter/vitest.config.ts new file mode 100644 index 0000000000..26029ff6f9 --- /dev/null +++ b/packages/typespec-azurite-emitter/vitest.config.ts @@ -0,0 +1,23 @@ +import alloyPlugin from "@alloy-js/rollup-plugin"; +import { defineConfig, mergeConfig } from "vitest/config"; +import { defaultTypeSpecVitestConfig } from "../../core/vitest.config.js"; + +export default mergeConfig( + defaultTypeSpecVitestConfig, + defineConfig({ + esbuild: { + jsx: "preserve", + jsxImportSource: "@alloy-js/core", + }, + oxc: false, + plugins: [alloyPlugin()], + resolve: { + conditions: ["development"], + dedupe: ["@alloy-js/core"], + }, + test: { + setupFiles: ["./test/vitest.setup.ts"], + testTimeout: 30000, + }, + }), +); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 380f54a5e6..baf4aacc13 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3718,6 +3718,58 @@ importers: specifier: 'catalog:' version: 5.0.0(@types/node@26.5.0)(@vitest/coverage-v8@5.0.0)(@vitest/ui@5.0.0)(happy-dom@20.11.6)(jsdom@25.0.1)(vite@8.2.2(@types/node@26.5.0)(esbuild@0.28.2)(tsx@4.23.13)(yaml@2.9.0)) + packages/typespec-azurite-emitter: + dependencies: + '@alloy-js/core': + specifier: 'catalog:' + version: 0.25.0 + '@alloy-js/typescript': + specifier: 'catalog:' + version: 0.25.0 + '@typespec/emitter-framework': + specifier: workspace:^ + version: link:../../core/packages/emitter-framework + devDependencies: + '@alloy-js/cli': + specifier: 'catalog:' + version: 0.25.0 + '@alloy-js/rollup-plugin': + specifier: 'catalog:' + version: 0.2.0(@babel/core@8.0.1)(@types/babel__core@7.20.5) + '@azure-tools/typespec-client-generator-core': + specifier: workspace:^ + version: link:../typespec-client-generator-core + '@types/node': + specifier: 'catalog:' + version: 26.5.0 + '@typespec/compiler': + specifier: workspace:^ + version: link:../../core/packages/compiler + '@typespec/http': + specifier: workspace:^ + version: link:../../core/packages/http + '@typespec/tspd': + specifier: workspace:^ + version: link:../../core/packages/tspd + '@typespec/xml': + specifier: workspace:^ + version: link:../../core/packages/xml + '@vitest/coverage-v8': + specifier: 'catalog:' + version: 5.0.0(vitest@5.0.0) + '@vitest/ui': + specifier: 'catalog:' + version: 5.0.0(vitest@5.0.0) + rimraf: + specifier: 'catalog:' + version: 6.1.3 + typescript: + specifier: 'catalog:' + version: 6.0.3 + vitest: + specifier: 'catalog:' + version: 5.0.0(@types/node@26.5.0)(@vitest/coverage-v8@5.0.0)(@vitest/ui@5.0.0)(happy-dom@20.11.6)(jsdom@25.0.1)(vite@8.2.2(@types/node@26.5.0)(esbuild@0.28.2)(tsx@4.23.13)(yaml@2.9.0)) + packages/typespec-client-generator-core: dependencies: '@typespec/versioning': @@ -7108,15 +7160,6 @@ packages: rollup: optional: true - '@rushstack/node-core-library@5.24.0': - resolution: {integrity: sha512-g/Z47ZwARn/VkTnHcyJslrR26erRf1G5JbqPlfGZGBCrOcrEt8fTQXXDVhUDDNl/g/v3TXI1dNiAAT5FEks8BA==} - engines: {node: '>=20.9.0'} - peerDependencies: - '@types/node': '*' - peerDependenciesMeta: - '@types/node': - optional: true - '@rushstack/node-core-library@5.24.1': resolution: {integrity: sha512-ZlOrzv92MwnsCXA45qWfDj4L/kTasKghXezu+M2WmdtkbnXyPnZSCovfeBZx1Yc5qm+LkElbLw6IeSSWZDhZUg==} engines: {node: '>=20.9.0'} @@ -7137,15 +7180,6 @@ packages: '@rushstack/rig-package@0.7.3': resolution: {integrity: sha512-aAA518n6wxxjCfnTAOjQnm7ngNE0FVHxHAw2pxKlIhxrMn0XQjGcXKF0oKWpjBgJOmsaJpVob/v+zr3zxgPWuA==} - '@rushstack/terminal@0.24.3': - resolution: {integrity: sha512-KxphDhPGC4xDrKg8O4yrWCEpPMi87aJc1iycXqMVh1dLgV0M34hiH9ZTgWjBRmcrT/OIHoOeWf48npcQFzgp+g==} - engines: {node: '>=20.9.0'} - peerDependencies: - '@types/node': '*' - peerDependenciesMeta: - '@types/node': - optional: true - '@rushstack/terminal@0.24.4': resolution: {integrity: sha512-3fRBWK0IMY293lBx5ycgit1DTMUi+nhOjALHlrIad9hQsqzM9Ak+XdBI1gJ/tZPxW+LraeAc4SsmMdcOflBmAQ==} engines: {node: '>=20.9.0'} @@ -7155,10 +7189,6 @@ packages: '@types/node': optional: true - '@rushstack/ts-command-line@5.3.13': - resolution: {integrity: sha512-+jNbxhh8CkrZYxMk9X3GRYM2+Myr1xCCj+eHbJ9Vp+PCdNHS0TUl5QQFT6UbM/aTFRCzs5jiBTKYzBRpe5uYbQ==} - engines: {node: '>=20.9.0'} - '@rushstack/ts-command-line@5.3.14': resolution: {integrity: sha512-lT2JKZk2dukBMp4GFOh4RaDfVzpZehGgQOGpzpSliUn317NgEmOOCXyd7/d0eU46HHsbRxizP83GAm39s0lAlg==} engines: {node: '>=20.9.0'} @@ -9486,6 +9516,10 @@ packages: resolution: {integrity: sha512-6EuL879VkRA+1Cz578mKMiKvjPNEuk6+r1JaFzoSWejZmtf7xWbIyw1e3KkxlkzTIt9Taw6JBhEppG7utc1P+w==} hasBin: true + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + js-yaml@4.3.2: resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==} hasBin: true @@ -12528,7 +12562,7 @@ snapshots: '@autorest/codemodel@4.20.1': dependencies: '@azure-tools/codegen': 2.10.1 - js-yaml: 4.3.2 + js-yaml: 4.1.1 '@azu/format-text@1.0.2': {} @@ -12555,7 +12589,7 @@ snapshots: '@azure-tools/codegen@2.10.1': dependencies: '@azure-tools/async-io': 3.0.254 - js-yaml: 4.3.2 + js-yaml: 4.1.1 semver: 7.8.5 '@azure-tools/tasks@3.0.255': {} @@ -15640,19 +15674,6 @@ snapshots: estree-walker: 2.0.2 picomatch: 4.0.7 - '@rushstack/node-core-library@5.24.0(@types/node@26.5.0)': - dependencies: - ajv: 8.20.0 - ajv-draft-04: 1.0.0(ajv@8.20.0) - ajv-formats: 3.0.1(ajv@8.20.0) - fs-extra: 11.3.6 - import-lazy: 4.0.0 - jju: 1.4.0 - resolve: 1.22.12 - semver: 7.7.4 - optionalDependencies: - '@types/node': 26.5.0 - '@rushstack/node-core-library@5.24.1(@types/node@26.5.0)': dependencies: ajv: 8.20.0 @@ -15675,14 +15696,6 @@ snapshots: jju: 1.4.0 resolve: 1.22.12 - '@rushstack/terminal@0.24.3(@types/node@26.5.0)': - dependencies: - '@rushstack/node-core-library': 5.24.0(@types/node@26.5.0) - '@rushstack/problem-matcher': 0.2.1(@types/node@26.5.0) - supports-color: 8.1.1 - optionalDependencies: - '@types/node': 26.5.0 - '@rushstack/terminal@0.24.4(@types/node@26.5.0)': dependencies: '@rushstack/node-core-library': 5.24.1(@types/node@26.5.0) @@ -15691,15 +15704,6 @@ snapshots: optionalDependencies: '@types/node': 26.5.0 - '@rushstack/ts-command-line@5.3.13(@types/node@26.5.0)': - dependencies: - '@rushstack/terminal': 0.24.3(@types/node@26.5.0) - '@types/argparse': 1.0.38 - argparse: 1.0.10 - string-argv: 0.3.2 - transitivePeerDependencies: - - '@types/node' - '@rushstack/ts-command-line@5.3.14(@types/node@26.5.0)': dependencies: '@rushstack/terminal': 0.24.4(@types/node@26.5.0) @@ -18454,6 +18458,10 @@ snapshots: argparse: 1.0.10 esprima: 4.0.1 + js-yaml@4.1.1: + dependencies: + argparse: 2.0.1 + js-yaml@4.3.2: dependencies: argparse: 2.0.1 diff --git a/tsconfig.ws.json b/tsconfig.ws.json index 929e87ac28..e93d8add14 100644 --- a/tsconfig.ws.json +++ b/tsconfig.ws.json @@ -9,6 +9,7 @@ { "path": "packages/typespec-azure-rulesets/tsconfig.build.json" }, { "path": "packages/azure-http-specs/tsconfig.build.json" }, { "path": "packages/typespec-metadata/tsconfig.build.json" }, + { "path": "packages/typespec-azurite-emitter/tsconfig.build.json" }, { "path": "packages/typespec-azure-examples/tsconfig.build.json" }, { "path": "packages/samples/tsconfig.build.json" }, { "path": "packages/typespec-python/tsconfig.json" },