From 2b12ab692adbf35b661c6da6b7dd1dd5aebb8a7c Mon Sep 17 00:00:00 2001 From: Brian Terlson Date: Sat, 25 Oct 2025 11:04:31 -0700 Subject: [PATCH 1/3] Add mutator framework and canonicalization --- packages/http-canonicalization/README.md | 161 ++++ packages/http-canonicalization/package.json | 45 ++ packages/http-canonicalization/src/codecs.ts | 344 +++++++++ .../src/http-canonicalization-classes.ts | 28 + .../src/http-canonicalization.test.ts | 99 +++ .../src/http-canonicalization.ts | 39 + .../http-canonicalization/src/intrinsic.ts | 61 ++ packages/http-canonicalization/src/literal.ts | 62 ++ .../src/model-property.test.ts | 33 + .../src/model-property.ts | 163 ++++ packages/http-canonicalization/src/model.ts | 137 ++++ .../src/operation.test.ts | 129 ++++ .../http-canonicalization/src/operation.ts | 731 ++++++++++++++++++ packages/http-canonicalization/src/options.ts | 45 ++ .../http-canonicalization/src/scalar.test.ts | 122 +++ packages/http-canonicalization/src/scalar.ts | 86 +++ .../src/union-variant.ts | 82 ++ .../http-canonicalization/src/union.test.ts | 116 +++ packages/http-canonicalization/src/union.ts | 678 ++++++++++++++++ .../http-canonicalization/test/test-host.ts | 8 + packages/http-canonicalization/tsconfig.json | 19 + .../http-canonicalization/vitest.config.ts | 4 + packages/mutator-framework/package.json | 42 + packages/mutator-framework/readme.md | 339 ++++++++ packages/mutator-framework/src/index.ts | 4 + .../src/mutation-node/enum-member.test.ts | 26 + .../src/mutation-node/enum-member.ts | 8 + .../src/mutation-node/enum.test.ts | 26 + .../src/mutation-node/enum.ts | 33 + .../src/mutation-node/factory.ts | 95 +++ .../src/mutation-node/index.ts | 16 + .../src/mutation-node/interface.ts | 33 + .../src/mutation-node/intrinsic.ts | 8 + .../src/mutation-node/literal.ts | 10 + .../src/mutation-node/model-property.test.ts | 136 ++++ .../src/mutation-node/model-property.ts | 53 ++ .../src/mutation-node/model.test.ts | 151 ++++ .../src/mutation-node/model.ts | 89 +++ .../src/mutation-node/mutation-edge.ts | 43 ++ .../src/mutation-node/mutation-node.test.ts | 94 +++ .../src/mutation-node/mutation-node.ts | 110 +++ .../src/mutation-node/mutation-subgraph.ts | 59 ++ .../src/mutation-node/operation.ts | 49 ++ .../src/mutation-node/scalar.test.ts | 44 ++ .../src/mutation-node/scalar.ts | 32 + .../src/mutation-node/tuple.test.ts | 30 + .../src/mutation-node/tuple.ts | 35 + .../src/mutation-node/union-variant.test.ts | 28 + .../src/mutation-node/union-variant.ts | 26 + .../src/mutation-node/union.test.ts | 26 + .../src/mutation-node/union.ts | 33 + .../mutator-framework/src/mutation/index.ts | 12 + .../src/mutation/interface.ts | 38 + .../src/mutation/intrinsic.ts | 23 + .../mutator-framework/src/mutation/literal.ts | 29 + .../src/mutation/model-property.ts | 35 + .../mutator-framework/src/mutation/model.ts | 58 ++ .../src/mutation/mutation-engine.test.ts | 202 +++++ .../src/mutation/mutation-engine.ts | 288 +++++++ .../src/mutation/mutation.ts | 90 +++ .../src/mutation/operation.ts | 40 + .../mutator-framework/src/mutation/scalar.ts | 36 + .../src/mutation/simple-mutation-engine.ts | 21 + .../src/mutation/union-variant.ts | 30 + .../mutator-framework/src/mutation/union.ts | 39 + packages/mutator-framework/test/test-host.ts | 6 + packages/mutator-framework/test/utils.ts | 9 + packages/mutator-framework/tsconfig.json | 19 + packages/mutator-framework/vitest.config.ts | 4 + pnpm-lock.yaml | 50 +- 70 files changed, 5891 insertions(+), 8 deletions(-) create mode 100644 packages/http-canonicalization/README.md create mode 100644 packages/http-canonicalization/package.json create mode 100644 packages/http-canonicalization/src/codecs.ts create mode 100644 packages/http-canonicalization/src/http-canonicalization-classes.ts create mode 100644 packages/http-canonicalization/src/http-canonicalization.test.ts create mode 100644 packages/http-canonicalization/src/http-canonicalization.ts create mode 100644 packages/http-canonicalization/src/intrinsic.ts create mode 100644 packages/http-canonicalization/src/literal.ts create mode 100644 packages/http-canonicalization/src/model-property.test.ts create mode 100644 packages/http-canonicalization/src/model-property.ts create mode 100644 packages/http-canonicalization/src/model.ts create mode 100644 packages/http-canonicalization/src/operation.test.ts create mode 100644 packages/http-canonicalization/src/operation.ts create mode 100644 packages/http-canonicalization/src/options.ts create mode 100644 packages/http-canonicalization/src/scalar.test.ts create mode 100644 packages/http-canonicalization/src/scalar.ts create mode 100644 packages/http-canonicalization/src/union-variant.ts create mode 100644 packages/http-canonicalization/src/union.test.ts create mode 100644 packages/http-canonicalization/src/union.ts create mode 100644 packages/http-canonicalization/test/test-host.ts create mode 100644 packages/http-canonicalization/tsconfig.json create mode 100644 packages/http-canonicalization/vitest.config.ts create mode 100644 packages/mutator-framework/package.json create mode 100644 packages/mutator-framework/readme.md create mode 100644 packages/mutator-framework/src/index.ts create mode 100644 packages/mutator-framework/src/mutation-node/enum-member.test.ts create mode 100644 packages/mutator-framework/src/mutation-node/enum-member.ts create mode 100644 packages/mutator-framework/src/mutation-node/enum.test.ts create mode 100644 packages/mutator-framework/src/mutation-node/enum.ts create mode 100644 packages/mutator-framework/src/mutation-node/factory.ts create mode 100644 packages/mutator-framework/src/mutation-node/index.ts create mode 100644 packages/mutator-framework/src/mutation-node/interface.ts create mode 100644 packages/mutator-framework/src/mutation-node/intrinsic.ts create mode 100644 packages/mutator-framework/src/mutation-node/literal.ts create mode 100644 packages/mutator-framework/src/mutation-node/model-property.test.ts create mode 100644 packages/mutator-framework/src/mutation-node/model-property.ts create mode 100644 packages/mutator-framework/src/mutation-node/model.test.ts create mode 100644 packages/mutator-framework/src/mutation-node/model.ts create mode 100644 packages/mutator-framework/src/mutation-node/mutation-edge.ts create mode 100644 packages/mutator-framework/src/mutation-node/mutation-node.test.ts create mode 100644 packages/mutator-framework/src/mutation-node/mutation-node.ts create mode 100644 packages/mutator-framework/src/mutation-node/mutation-subgraph.ts create mode 100644 packages/mutator-framework/src/mutation-node/operation.ts create mode 100644 packages/mutator-framework/src/mutation-node/scalar.test.ts create mode 100644 packages/mutator-framework/src/mutation-node/scalar.ts create mode 100644 packages/mutator-framework/src/mutation-node/tuple.test.ts create mode 100644 packages/mutator-framework/src/mutation-node/tuple.ts create mode 100644 packages/mutator-framework/src/mutation-node/union-variant.test.ts create mode 100644 packages/mutator-framework/src/mutation-node/union-variant.ts create mode 100644 packages/mutator-framework/src/mutation-node/union.test.ts create mode 100644 packages/mutator-framework/src/mutation-node/union.ts create mode 100644 packages/mutator-framework/src/mutation/index.ts create mode 100644 packages/mutator-framework/src/mutation/interface.ts create mode 100644 packages/mutator-framework/src/mutation/intrinsic.ts create mode 100644 packages/mutator-framework/src/mutation/literal.ts create mode 100644 packages/mutator-framework/src/mutation/model-property.ts create mode 100644 packages/mutator-framework/src/mutation/model.ts create mode 100644 packages/mutator-framework/src/mutation/mutation-engine.test.ts create mode 100644 packages/mutator-framework/src/mutation/mutation-engine.ts create mode 100644 packages/mutator-framework/src/mutation/mutation.ts create mode 100644 packages/mutator-framework/src/mutation/operation.ts create mode 100644 packages/mutator-framework/src/mutation/scalar.ts create mode 100644 packages/mutator-framework/src/mutation/simple-mutation-engine.ts create mode 100644 packages/mutator-framework/src/mutation/union-variant.ts create mode 100644 packages/mutator-framework/src/mutation/union.ts create mode 100644 packages/mutator-framework/test/test-host.ts create mode 100644 packages/mutator-framework/test/utils.ts create mode 100644 packages/mutator-framework/tsconfig.json create mode 100644 packages/mutator-framework/vitest.config.ts diff --git a/packages/http-canonicalization/README.md b/packages/http-canonicalization/README.md new file mode 100644 index 00000000000..b57be863712 --- /dev/null +++ b/packages/http-canonicalization/README.md @@ -0,0 +1,161 @@ +# @typespec/http-canonicalization + +** WARNING: THIS PACKAGE IS EXPERIMENTAL AND WILL CHANGE ** + +Utilities for emitters and tooling that need to understand type shapes in the HTTP protocol. The canonicalizer produces a mutated type graph specifying shapes in the language and on the wire, as well as groups together relevant HTTP metadata from various compiler APIs in a convenient package. + +## Why you might use it + +- Get HTTP request and response shapes. +- Apply visibility transforms. +- Understand how types are serialized to HTTP. +- Get tests to determine how to discriminate non-discriminated unions. + +## Installation + +```bash +pnpm add @typespec/http-canonicalization +``` + +Peer dependencies `@typespec/compiler` and `@typespec/http` must be installed. + +## Quick start + +TypeSpec service definition: + +```typespec +import "@typespec/http"; + +model Foo { + @visibility(Lifecycle.Read) + @encode(DateTimeKnownEncoding.rfc7231) + createdAt: utcDateTime; + + @visibility(Lifecycle.Create) + name: string; +} + +@route("/foo") +@post +op createFoo(@body foo: Foo): Foo; +``` + +Emitter-side usage: + +```ts +import { $ } from "@typespec/compiler/typekit"; +import { HttpCanonicalizer } from "@typespec/http-canonicalization"; + +const tk = $(program); +const canonicalizer = new HttpCanonicalizer(tk); +const http = canonicalizer.canonicalize(op); +const body = http.requestParameters.body!.type; +// body.type.languageType.name === "FooCreate" +// body.type.visibleProperties => only "name" + +const response = http.responses[0]; +// response.type.languageType.properties has both "name" and "createdAt" +// response.responses[0].headers?.etag captures header metadata +``` + +## What the result tells you + +- **Models and properties**: `languageType` reflects the shape visible to generated code, while `wireType` describes the payload sent over the network. Properties removed for the selected visibility remain in `properties` but carry an `intrinsic.never` language type so you can detect deletions. +- **Scalars**: Each scalar includes a `codec` (for example `rfc7231` for date headers) and may change its wire type (e.g., `int32` -> `float64`) to match HTTP expectations. +- **Operations**: Requests expose grouped `headers`, `query`, `path`, and `body` parameters. Responses list status codes, per-content-type payloads, and derived body information (single, multipart, or file payloads) along with metadata such as content type properties and filenames. +- **Unions**: Variant tests contain ordered type guards (primitive checks before object checks) and literal discriminants. If variants cannot be distinguished (for example, two object models with identical shapes), the canonicalizer throws so you can surface a clear error. + +## API reference + +### OperationHttpCanonicalization + +- Canonicalizes operations by deriving HTTP-specific request and response shapes and tracks the language and wire projections for each operation. +- `requestParameters`: Canonicalized request parameters grouped by location. +- `requestHeaders`: Canonicalized header parameters for the request. +- `queryParameters`: Canonicalized query parameters for the request. +- `pathParameters`: Canonicalized path parameters for the request. +- `responses`: Canonicalized responses produced by the operation. +- `path`: Concrete path for the HTTP operation. +- `uriTemplate`: URI template used for path and query expansion. +- `parameterVisibility`: Visibility applied when canonicalizing request parameters. +- `returnTypeVisibility`: Visibility applied when canonicalizing response payloads. +- `method`: HTTP method verb for the operation. +- `name`: Name assigned to the canonicalized operation. +- `languageType`: Mutated language type for this operation. +- `wireType`: Mutated wire type for this operation. + +### ModelHttpCanonicalization + +- Canonicalizes models for HTTP and supplies language and wire variants along with visibility-aware metadata. +- `isDeclaration`: Indicates if the canonicalization wraps a named TypeSpec declaration. +- `codec`: Codec chosen to transform language and wire types for this model. +- `languageType`: Possibly mutated language type for the model. +- `wireType`: Possibly mutated wire type for the model. +- `visibleProperties`: Canonical properties visible under the current visibility options. + +### ModelPropertyHttpCanonicalization + +- Canonicalizes model properties, tracking HTTP metadata, visibility, and codecs while adjusting types per location. +- `isDeclaration`: Indicates if this property corresponds to a named declaration. +- `isVisible`: Whether the property is visible with the current visibility options. +- `codec`: Codec used to transform the property's type between language and wire views. +- `isQueryParameter`: True when the property is a query parameter. +- `queryParameterName`: Query parameter name when applicable. +- `isHeader`: True when the property is an HTTP header. +- `headerName`: Header name when the property is a header. +- `isPathParameter`: True when the property is a path parameter. +- `pathParameterName`: Path parameter name when applicable. +- `explode`: Whether structured values should use explode semantics. +- `languageType`: Possibly mutated language type for the property. +- `wireType`: Possibly mutated wire type for the property. + +### ScalarHttpCanonicalization + +- Canonicalizes scalar types by applying encoding-specific mutations driven by codecs. +- `options`: Canonicalization options in effect for the scalar. +- `codec`: Codec responsible for transforming the scalar into language and wire types. +- `isDeclaration`: Indicates whether the scalar is a named TypeSpec declaration. +- `languageType`: Possibly mutated language type for the scalar. +- `wireType`: Possibly mutated wire type for the scalar. + +### UnionHttpCanonicalization + +- Canonicalizes union types, tracking discriminators, envelope structures, and runtime variant tests for both language and wire projections. +- `options`: Canonicalization options guiding union transformation. +- `isDeclaration`: Indicates if the union corresponds to a named declaration. +- `isDiscriminated`: True when `@discriminator` is present on the union. +- `envelopeKind`: Envelope structure used for discriminated unions. +- `discriminatorProperty`: Canonicalized discriminator property for envelope unions. +- `variantDescriptors`: Descriptors describing each canonicalized variant. +- `languageVariantTests`: Runtime tests used to select a variant for language types. +- `wireVariantTests`: Runtime tests used to select a variant for wire types. +- `discriminatorPropertyName`: Name of the discriminator property when present. +- `envelopePropertyName`: Name of the envelope property when present. +- `visibleVariants`: Variants that remain visible under the current visibility rules. +- `languageType`: Potentially mutated language type for this union. +- `wireType`: Potentially mutated wire type for this union. + +### UnionVariantHttpCanonicalization + +- Canonicalizes individual union variants for HTTP, removing hidden variants and exposing mutated representations. +- `options`: Canonicalization options. +- `isDeclaration`: Indicates if the variant corresponds to a named declaration. +- `isVisible`: Whether the variant is visible under the current visibility options. +- `languageType`: Possibly mutated language type for this variant. +- `wireType`: Possibly mutated wire type for this variant. + +### IntrinsicHttpCanonicalization + +- Canonicalizes intrinsic types for HTTP, producing language and wire projections directed by the active options. +- `options`: Canonicalization options. +- `isDeclaration`: Indicates if this intrinsic represents a named declaration. +- `languageType`: Possibly mutated language type for this intrinsic. +- `wireType`: Possibly mutated wire type for this intrinsic. + +### LiteralHttpCanonicalization + +- Canonicalizes literal types for HTTP, yielding language and wire variants for string, number, and boolean literals. +- `options`: Canonicalization options. +- `isDeclaration`: Indicates if the literal is a named declaration (always false for literals). +- `languageType`: Possibly mutated language type for this literal. +- `wireType`: Possibly mutated wire type for this literal. diff --git a/packages/http-canonicalization/package.json b/packages/http-canonicalization/package.json new file mode 100644 index 00000000000..daf16ca43a9 --- /dev/null +++ b/packages/http-canonicalization/package.json @@ -0,0 +1,45 @@ +{ + "name": "@typespec/http-canonicalization", + "version": "0.12.0", + "type": "module", + "main": "dist/src/index.js", + "repository": { + "type": "git", + "url": "git+https://github.com/microsoft/typespec.git" + }, + "scripts": { + "build": "tsc -p .", + "clean": "rimraf ./dist", + "format": "prettier . --write", + "watch": "tsc -p . --watch", + "test": "vitest run", + "test:ui": "vitest --ui", + "test:watch": "vitest -w", + "test:ci": "vitest run --coverage --reporter=junit --reporter=default", + "prepack": "tsx ../../eng/tsp-core/scripts/strip-dev-import-exports.ts", + "postpack": "tsx ../../eng/tsp-core/scripts/strip-dev-import-exports.ts --restore", + "lint": "eslint . --max-warnings=0", + "lint:fix": "eslint . --fix" + }, + "exports": { + ".": { + "import": "./dist/src/index.js" + } + }, + "keywords": [], + "author": "", + "license": "MIT", + "description": "", + "dependencies": { + "@typespec/mutator-framework": "workspace:^" + }, + "peerDependencies": { + "@typespec/compiler": "workspace:^", + "@typespec/http": "workspace:^" + }, + "devDependencies": { + "@types/node": "~24.3.0", + "concurrently": "^9.1.2", + "prettier": "~3.6.2" + } +} diff --git a/packages/http-canonicalization/src/codecs.ts b/packages/http-canonicalization/src/codecs.ts new file mode 100644 index 00000000000..3e5f559be3f --- /dev/null +++ b/packages/http-canonicalization/src/codecs.ts @@ -0,0 +1,344 @@ +import { getEncode, type MemberType, type Program, type Type } from "@typespec/compiler"; +import type { Typekit } from "@typespec/compiler/typekit"; +import { isHeader } from "@typespec/http"; +import type { HttpCanonicalization } from "./http-canonicalization-classes.js"; + +export interface CodecEncodeResult { + codec: Codec; + encodedType: Type; +} +export class CodecRegistry { + $: Typekit; + #codecs: (typeof Codec)[]; + constructor($: Typekit) { + this.#codecs = []; + this.$ = $; + } + + addCodec(codec: typeof Codec) { + this.#codecs.push(codec); + } + + detect(type: HttpCanonicalization): Codec { + for (const codec of this.#codecs) { + const codecInstance = codec.detect(this.$, type); + if (codecInstance) { + return codecInstance; + } + } + + throw new Error("No codec found"); + } +} + +export abstract class Codec { + abstract id: string; + canonicalization: HttpCanonicalization; + $: Typekit; + + constructor($: Typekit, canonicalization: HttpCanonicalization) { + this.canonicalization = canonicalization; + this.$ = $; + } + + static detect($: Typekit, canonicalization: HttpCanonicalization): Codec | undefined { + return undefined; + } + + abstract encode(): { languageType: Type; wireType: Type }; + + static getMetadata< + TTypeSource extends Type, + TMemberSource extends MemberType, + TFilteredMemberSource extends TMemberSource, + TArgs extends [], + TReturn, + >( + $: Typekit, + typeSource: TTypeSource | undefined, + memberSource: TMemberSource[], + isApplicableMember: (member: TMemberSource) => member is TFilteredMemberSource, + getter: ( + program: Program, + type: TTypeSource | TFilteredMemberSource, + ...args: TArgs + ) => TReturn, + ...args: TArgs + ): TReturn | undefined { + for (const member of memberSource) { + if (isApplicableMember(member)) { + const memberInfo = getter($.program, member, ...args); + if (memberInfo) { + return memberInfo; + } + } + } + + if (typeSource) { + return getter($.program, typeSource, ...args); + } + + return undefined; + } +} + +export class IdentityCodec extends Codec { + readonly id = "identity"; + static detect($: Typekit, canonicalization: HttpCanonicalization) { + return new IdentityCodec($, canonicalization); + } + + encode() { + return { + wireType: this.canonicalization.sourceType, + languageType: this.canonicalization.sourceType, + }; + } +} + +export class UnixTimestamp64Codec extends Codec { + readonly id = "unix-timestamp-64"; + static detect($: Typekit, canonicalization: HttpCanonicalization) { + const type = canonicalization.sourceType; + + if (!$.scalar.is(type) || !$.type.isAssignableTo(type, $.builtin.utcDateTime)) { + return; + } + + const encodingInfo = this.getMetadata( + $, + type, + canonicalization.referenceTypes, + $.modelProperty.is, + getEncode, + ); + + if (!encodingInfo) { + return; + } + + if (encodingInfo.encoding === "unix-timestamp" && encodingInfo.type === $.builtin.int64) { + return new UnixTimestamp64Codec($, canonicalization); + } + } + + encode() { + return { + languageType: this.$.builtin.int64, + wireType: this.$.builtin.float64, + }; + } +} + +export class UnixTimestamp32Codec extends Codec { + readonly id = "unix-timestamp-32"; + static detect($: Typekit, canonicalization: HttpCanonicalization) { + const type = canonicalization.sourceType; + + if (!$.scalar.is(type) || !$.type.isAssignableTo(type, $.builtin.utcDateTime)) { + return; + } + + const encodingInfo = this.getMetadata( + $, + type, + canonicalization.referenceTypes, + $.modelProperty.is, + getEncode, + ); + + if (!encodingInfo) { + return; + } + + if (encodingInfo.encoding === "unix-timestamp" && encodingInfo.type === $.builtin.int32) { + return new UnixTimestamp32Codec($, canonicalization); + } + } + + encode() { + return { + languageType: this.$.builtin.int32, + wireType: this.$.builtin.float64, + }; + } +} + +export class Rfc3339Codec extends Codec { + readonly id = "rfc3339"; + + static detect($: Typekit, canonicalization: HttpCanonicalization) { + const type = canonicalization.sourceType; + + if (!$.scalar.is(type) || !$.type.isAssignableTo(type, $.builtin.utcDateTime)) { + return; + } + + return new Rfc3339Codec($, canonicalization); + } + + encode() { + return { + languageType: this.canonicalization.sourceType, + wireType: this.$.builtin.string, + }; + } +} + +export class Rfc7231Codec extends Codec { + readonly id = "rfc7231"; + + static detect($: Typekit, canonicalization: HttpCanonicalization) { + const type = canonicalization.sourceType; + + if (!$.scalar.is(type) || !$.type.isAssignableTo(type, $.builtin.utcDateTime)) { + return; + } + + const encodingInfo = this.getMetadata( + $, + type, + canonicalization.referenceTypes, + $.modelProperty.is, + getEncode, + ); + + if (!encodingInfo) { + if ( + this.getMetadata( + $, + undefined, + canonicalization.referenceTypes, + $.modelProperty.is, + isHeader, + ) + ) { + return new Rfc7231Codec($, canonicalization); + } + return; + } + + if (encodingInfo.encoding === "rfc7231") { + return new Rfc7231Codec($, canonicalization); + } + } + + encode() { + return { + languageType: this.canonicalization.sourceType, + wireType: this.$.builtin.string, + }; + } +} + +export class Base64Codec extends Codec { + readonly id = "base64"; + + static detect($: Typekit, canonicalization: HttpCanonicalization) { + const type = canonicalization.sourceType; + + if (!$.type.isAssignableTo(type, $.builtin.bytes)) { + return; + } + + return new Base64Codec($, canonicalization); + } + + encode() { + return { + languageType: this.canonicalization.sourceType, + wireType: this.$.builtin.string, + }; + } +} + +export class CoerceToFloat64Codec extends Codec { + readonly id = "coerce-to-float64"; + + static detect($: Typekit, canonicalization: HttpCanonicalization) { + const type = canonicalization.sourceType; + + if (!$.type.isAssignableTo(type, $.builtin.numeric)) { + return; + } + + return new CoerceToFloat64Codec($, canonicalization); + } + + encode() { + return { + languageType: this.canonicalization.sourceType, + wireType: this.$.builtin.float64, + }; + } +} + +export class NumericToStringCodec extends Codec { + readonly id = "numeric-to-string"; + + static detect($: Typekit, canonicalization: HttpCanonicalization) { + const type = canonicalization.sourceType; + + if (!$.type.isAssignableTo(type, $.builtin.numeric)) { + return; + } + + return new NumericToStringCodec($, canonicalization); + } + + encode() { + return { + languageType: this.canonicalization.sourceType, + wireType: this.$.builtin.string, + }; + } +} + +export class ArrayJoinCodec extends Codec { + readonly id = "array-join"; + static detect($: Typekit, canonicalization: HttpCanonicalization) { + const type = canonicalization.sourceType; + + if (!$.array.is(type)) { + return; + } + + if ( + canonicalization.options.location === "query" || + canonicalization.options.location === "header" || + canonicalization.options.location === "path" + ) { + return new ArrayJoinCodec($, canonicalization); + } + } + + encode() { + return { + languageType: this.canonicalization.sourceType, + wireType: this.$.builtin.string, + }; + } +} + +const jsonEncoderRegistryCache = new WeakMap(); + +export const getJsonEncoderRegistry = ($: Typekit) => { + if (jsonEncoderRegistryCache.has($.program)) { + return jsonEncoderRegistryCache.get($.program)!; + } + + const registry = new CodecRegistry($); + registry.addCodec(Rfc7231Codec); + registry.addCodec(Rfc3339Codec); + registry.addCodec(UnixTimestamp32Codec); + registry.addCodec(UnixTimestamp64Codec); + registry.addCodec(Base64Codec); + registry.addCodec(CoerceToFloat64Codec); + registry.addCodec(NumericToStringCodec); + registry.addCodec(ArrayJoinCodec); + registry.addCodec(IdentityCodec); + + jsonEncoderRegistryCache.set($.program, registry); + + return registry; +}; diff --git a/packages/http-canonicalization/src/http-canonicalization-classes.ts b/packages/http-canonicalization/src/http-canonicalization-classes.ts new file mode 100644 index 00000000000..7de9ca5bf62 --- /dev/null +++ b/packages/http-canonicalization/src/http-canonicalization-classes.ts @@ -0,0 +1,28 @@ +import type { InstancesFor } from "@typespec/mutator-framework"; +import { IntrinsicHttpCanonicalization } from "./intrinsic.js"; +import { LiteralHttpCanonicalization } from "./literal.js"; +import { ModelPropertyHttpCanonicalization } from "./model-property.js"; +import { ModelHttpCanonicalization } from "./model.js"; +import { OperationHttpCanonicalization } from "./operation.js"; +import { ScalarHttpCanonicalization } from "./scalar.js"; +import { UnionVariantHttpCanonicalization } from "./union-variant.js"; +import { UnionHttpCanonicalization } from "./union.js"; + +export const CANONICALIZATION_CLASSES = { + Operation: OperationHttpCanonicalization, + Model: ModelHttpCanonicalization, + ModelProperty: ModelPropertyHttpCanonicalization, + Scalar: ScalarHttpCanonicalization, + Union: UnionHttpCanonicalization, + Intrinsic: IntrinsicHttpCanonicalization, + UnionVariant: UnionVariantHttpCanonicalization, + String: LiteralHttpCanonicalization, + Number: LiteralHttpCanonicalization, + Boolean: LiteralHttpCanonicalization, +} as const; + +export type HttpCanonicalizationMutations = InstancesFor; + +export type HttpCanonicalization = InstanceType< + (typeof CANONICALIZATION_CLASSES)[keyof typeof CANONICALIZATION_CLASSES] +>; diff --git a/packages/http-canonicalization/src/http-canonicalization.test.ts b/packages/http-canonicalization/src/http-canonicalization.test.ts new file mode 100644 index 00000000000..22f14ee769c --- /dev/null +++ b/packages/http-canonicalization/src/http-canonicalization.test.ts @@ -0,0 +1,99 @@ +import { t } from "@typespec/compiler/testing"; +import { $ } from "@typespec/compiler/typekit"; +import { Visibility } from "@typespec/http"; +import { expect, it } from "vitest"; +import { Tester } from "../test/test-host.js"; +import { HttpCanonicalizer } from "./http-canonicalization.js"; +import type { ModelPropertyHttpCanonicalization } from "./model-property.js"; +import type { ModelHttpCanonicalization } from "./model.js"; +import { HttpCanonicalizationOptions } from "./options.js"; + +it("canonicalizes models for read visibility", async () => { + const runner = await Tester.createInstance(); + const { Foo, program } = await runner.compile(t.code` + model ${t.model("Foo")} { + @visibility(Lifecycle.Read) + @encode(DateTimeKnownEncoding.rfc7231) + createdAt: utcDateTime; + + @visibility(Lifecycle.Create) + name: string; + } + `); + + const tk = $(program); + + const canonicalizer = new HttpCanonicalizer(tk); + + const read = canonicalizer.canonicalize( + Foo, + new HttpCanonicalizationOptions({ visibility: Visibility.Read }), + ); + expect(read.sourceType).toBe(Foo); + + // validate mutation node + expect(read.properties.size).toBe(2); + const deletedProperty = read.properties.get("name")! as ModelPropertyHttpCanonicalization; + expect(deletedProperty.languageType).toBe(tk.intrinsic.never); + + // validate language type + expect(read.languageType.name).toBe("Foo"); + expect(read.languageType.properties.size).toBe(1); + expect( + read.languageType.properties.get("createdAt")!.type === tk.builtin.utcDateTime, + ).toBeTruthy(); + + // validate wire type + expect(read.wireType.name).toBe("Foo"); + expect(read.wireType.properties.size).toBe(1); + expect(read.wireType.properties.get("createdAt")!.type === tk.builtin.string).toBeTruthy(); +}); + +it("canonicalizes models for write visibility", async () => { + const runner = await Tester.createInstance(); + const { Foo, program } = await runner.compile(t.code` + model ${t.model("Foo")} { + @visibility(Lifecycle.Read) + createdAt: utcDateTime; + + @visibility(Lifecycle.Create) + name: string; + } + `); + + const tk = $(program); + + const canonicalizer = new HttpCanonicalizer(tk); + const write = canonicalizer.canonicalize(Foo, { + visibility: Visibility.Create, + }) as ModelHttpCanonicalization; + + expect(write.sourceType).toBe(Foo); + expect(write.languageType.name).toBe("FooCreate"); + expect(write.languageType.properties.size).toBe(1); + expect(write.languageType.name).toBe("FooCreate"); + expect(write.languageType.properties.size).toBe(1); +}); + +it("returns the same canonicalization for the same type", async () => { + const runner = await Tester.createInstance(); + const { Foo, program } = await runner.compile(t.code` + model ${t.model("Foo")} { + @visibility(Lifecycle.Read) createdAt: utcDateTime; + name: string; + } + `); + + const tk = $(program); + + const canonicalizer = new HttpCanonicalizer(tk); + + const read1 = canonicalizer.canonicalize(Foo, { + visibility: Visibility.Read, + }); + const read2 = canonicalizer.canonicalize(Foo, { + visibility: Visibility.Read, + }); + + expect(read1 === read2).toBe(true); +}); diff --git a/packages/http-canonicalization/src/http-canonicalization.ts b/packages/http-canonicalization/src/http-canonicalization.ts new file mode 100644 index 00000000000..6e221dc4404 --- /dev/null +++ b/packages/http-canonicalization/src/http-canonicalization.ts @@ -0,0 +1,39 @@ +import type { Type } from "@typespec/compiler"; +import type { Typekit } from "@typespec/compiler/typekit"; +import { MutationEngine, MutationSubgraph } from "@typespec/mutator-framework"; +import { + CANONICALIZATION_CLASSES, + type HttpCanonicalizationMutations, +} from "./http-canonicalization-classes.js"; +import { HttpCanonicalizationOptions, type HttpCanonicalizationOptionsInit } from "./options.js"; + +export interface LanguageMapper { + getLanguageType(specType: Type): Type; +} + +export const TSLanguageMapper: LanguageMapper = { + getLanguageType(specType: Type): Type { + // TypeScript emitter handles all the built-in types. + return specType; + }, +}; + +export class HttpCanonicalizer extends MutationEngine { + constructor($: Typekit) { + super($, CANONICALIZATION_CLASSES); + this.registerSubgraph("language"); + this.registerSubgraph("wire"); + } + + getLanguageSubgraph(options: HttpCanonicalizationOptions): MutationSubgraph { + return this.getMutationSubgraph(options, "language"); + } + + getWireSubgraph(options: HttpCanonicalizationOptions): MutationSubgraph { + return this.getMutationSubgraph(options, "wire"); + } + + canonicalize(type: T, options?: HttpCanonicalizationOptionsInit) { + return this.mutate(type, new HttpCanonicalizationOptions(options)); + } +} diff --git a/packages/http-canonicalization/src/intrinsic.ts b/packages/http-canonicalization/src/intrinsic.ts new file mode 100644 index 00000000000..7f7e488c0bc --- /dev/null +++ b/packages/http-canonicalization/src/intrinsic.ts @@ -0,0 +1,61 @@ +import type { IntrinsicType, MemberType } from "@typespec/compiler"; +import { IntrinsicMutation } from "@typespec/mutator-framework"; +import type { HttpCanonicalizationMutations } from "./http-canonicalization-classes.js"; +import { HttpCanonicalizer } from "./http-canonicalization.js"; +import { HttpCanonicalizationOptions } from "./options.js"; + +/** + * Canonicalizes intrinsic types for HTTP. + */ +export class IntrinsicHttpCanonicalization extends IntrinsicMutation< + HttpCanonicalizationOptions, + HttpCanonicalizationMutations, + HttpCanonicalizer +> { + /** + * Canonicalization options. + */ + options: HttpCanonicalizationOptions; + /** + * Indicates if this intrinsic represents a named declaration. Always false. + */ + isDeclaration: boolean = false; + + /** + * Mutation subgraph for language types. + */ + get #languageSubgraph() { + return this.engine.getLanguageSubgraph(this.options); + } + + /** + * Mutation subgraph for wire types. + */ + get #wireSubgraph() { + return this.engine.getWireSubgraph(this.options); + } + + /** + * The possibly mutated language type for this intrinsic. + */ + get languageType() { + return this.getMutatedType(this.#languageSubgraph); + } + + /** + * The possibly mutated wire type for this intrinsic. + */ + get wireType() { + return this.getMutatedType(this.#wireSubgraph); + } + + constructor( + engine: HttpCanonicalizer, + sourceType: IntrinsicType, + referenceTypes: MemberType[], + options: HttpCanonicalizationOptions, + ) { + super(engine, sourceType, referenceTypes, options); + this.options = options; + } +} diff --git a/packages/http-canonicalization/src/literal.ts b/packages/http-canonicalization/src/literal.ts new file mode 100644 index 00000000000..a7b3d827cd1 --- /dev/null +++ b/packages/http-canonicalization/src/literal.ts @@ -0,0 +1,62 @@ +import type { BooleanLiteral, MemberType, NumericLiteral, StringLiteral } from "@typespec/compiler"; +import { LiteralMutation } from "@typespec/mutator-framework"; +import type { HttpCanonicalizationMutations } from "./http-canonicalization-classes.js"; +import type { HttpCanonicalizer } from "./http-canonicalization.js"; +import { HttpCanonicalizationOptions } from "./options.js"; + +/** + * Canonicalizes literal types for HTTP. + */ +export class LiteralHttpCanonicalization extends LiteralMutation< + HttpCanonicalizationOptions, + HttpCanonicalizationMutations, + HttpCanonicalizer +> { + /** + * Canonicalization options. + */ + options: HttpCanonicalizationOptions; + /** + * Indicates if the literal is defined as a named TypeSpec declaration. Always + * false for literals. + */ + isDeclaration: boolean = false; + + /** + * Mutation subgraph for language types. + */ + get #languageSubgraph() { + return this.engine.getLanguageSubgraph(this.options); + } + + /** + * Mutation subgraph for wire types. + */ + get #wireSubgraph() { + return this.engine.getWireSubgraph(this.options); + } + + /** + * The possibly mutated language type for this literal. + */ + get languageType() { + return this.getMutatedType(this.#languageSubgraph); + } + + /** + * The possibly mutated wire type for this literal. + */ + get wireType() { + return this.getMutatedType(this.#wireSubgraph); + } + + constructor( + engine: HttpCanonicalizer, + sourceType: StringLiteral | NumericLiteral | BooleanLiteral, + referenceTypes: MemberType[], + options: HttpCanonicalizationOptions, + ) { + super(engine, sourceType, referenceTypes, options); + this.options = options; + } +} diff --git a/packages/http-canonicalization/src/model-property.test.ts b/packages/http-canonicalization/src/model-property.test.ts new file mode 100644 index 00000000000..8f808115f6d --- /dev/null +++ b/packages/http-canonicalization/src/model-property.test.ts @@ -0,0 +1,33 @@ +import { t, type TesterInstance } from "@typespec/compiler/testing"; +import { $ } from "@typespec/compiler/typekit"; +import { Visibility } from "@typespec/http"; +import { beforeEach, expect, it } from "vitest"; +import { Tester } from "../test/test-host.js"; +import { HttpCanonicalizer } from "./http-canonicalization.js"; + +let runner: TesterInstance; +beforeEach(async () => { + runner = await Tester.createInstance(); +}); + +// skip, haven't implemented metadata stuff yet +it.skip("removes metadata properties from wire type", async () => { + const { Foo, program } = await runner.compile(t.code` + model ${t.model("Foo")} { + @visibility(Lifecycle.Read) + @header etag: string; + + id: string; + } + `); + + const tk = $(program); + + const canonicalizer = new HttpCanonicalizer(tk); + const write = canonicalizer.canonicalize(Foo, { + visibility: Visibility.Read, + }); + + expect(write.languageType.properties.has("etag")).toBe(true); + expect(write.wireType.properties.has("etag")).toBe(false); +}); diff --git a/packages/http-canonicalization/src/model-property.ts b/packages/http-canonicalization/src/model-property.ts new file mode 100644 index 00000000000..56bc9ebefaa --- /dev/null +++ b/packages/http-canonicalization/src/model-property.ts @@ -0,0 +1,163 @@ +import type { MemberType, ModelProperty } from "@typespec/compiler"; +import { getHeaderFieldOptions, getQueryParamOptions, isVisible } from "@typespec/http"; +import { ModelPropertyMutation } from "@typespec/mutator-framework"; +import { Codec, getJsonEncoderRegistry } from "./codecs.js"; +import type { + HttpCanonicalization, + HttpCanonicalizationMutations, +} from "./http-canonicalization-classes.js"; +import type { HttpCanonicalizer } from "./http-canonicalization.js"; +import { HttpCanonicalizationOptions } from "./options.js"; + +/** + * Canonicalizes model properties, tracking request/response metadata and visibility. + */ +export class ModelPropertyHttpCanonicalization extends ModelPropertyMutation< + HttpCanonicalizationOptions, + HttpCanonicalizationMutations, + HttpCanonicalizer +> { + /** + * Indicates if this property corresponds to a named declaration. Always + * false. + */ + isDeclaration: boolean = false; + + /** + * Whether the property is visible given the current visibility options. + */ + isVisible: boolean = false; + + /** + * Codec used to transform the property's type between language and wire views. + */ + codec: Codec; + + /** + * True when the property is a query parameter. + */ + isQueryParameter: boolean = false; + + /** + * The query parameter name when the property is a query parameter, else the + * empty string. + */ + queryParameterName: string = ""; + + /** + * True when the property is an HTTP header. + */ + isHeader: boolean = false; + /** + * The header name when the property is an HTTP header, else the empty string. + */ + headerName: string = ""; + + /** + * True when the property is a path parameter. + */ + isPathParameter: boolean = false; + /** + * The path parameter name when the property is a path parameter, else the + * empty string. + */ + pathParameterName: string = ""; + + /** + * Whether structured values should use explode semantics. + */ + explode: boolean = false; + + /** + * Mutation subgraph for language types. + */ + get #languageSubgraph() { + return this.engine.getLanguageSubgraph(this.options); + } + + /** + * Mutation subgraph for wire types. + */ + get #wireSubgraph() { + return this.engine.getWireSubgraph(this.options); + } + + /** + * The possibly mutated language type for this property. + */ + get languageType() { + return this.getMutatedType(this.#languageSubgraph); + } + + /** + * The possibly mutated wire type for this property. + */ + get wireType() { + return this.getMutatedType(this.#wireSubgraph); + } + + constructor( + engine: HttpCanonicalizer, + sourceType: ModelProperty, + referenceTypes: MemberType[], + options: HttpCanonicalizationOptions, + ) { + super(engine, sourceType, referenceTypes, options); + this.isDeclaration = !!this.sourceType.name; + this.isVisible = isVisible(this.engine.$.program, this.sourceType, this.options.visibility); + const headerInfo = getHeaderFieldOptions(this.engine.$.program, this.sourceType); + if (headerInfo) { + this.isHeader = true; + this.headerName = headerInfo.name; + this.explode = !!headerInfo.explode; + } else { + const queryInfo = getQueryParamOptions(this.engine.$.program, this.sourceType); + + if (queryInfo) { + this.isQueryParameter = true; + this.queryParameterName = queryInfo.name; + this.explode = !!queryInfo.explode; + } else { + const pathInfo = getQueryParamOptions(this.engine.$.program, this.sourceType); + if (pathInfo) { + this.isPathParameter = true; + this.pathParameterName = pathInfo.name; + this.explode = !!pathInfo.explode; + } + } + } + + const registry = getJsonEncoderRegistry(this.engine.$); + this.codec = registry.detect(this); + } + + /** + * Apply HTTP canonicalization. + */ + mutate() { + const languageNode = this.getMutationNode(this.#languageSubgraph); + const wireNode = this.getMutationNode(this.#wireSubgraph); + + if (!this.isVisible) { + languageNode.delete(); + wireNode.delete(); + return; + } + + const newOptions = this.isHeader + ? this.options.with({ + location: `header${this.explode ? "-explode" : ""}`, + }) + : this.isQueryParameter + ? this.options.with({ + location: `query${this.explode ? "-explode" : ""}`, + }) + : this.isPathParameter + ? this.options.with({ + location: `path${this.explode ? "-explode" : ""}`, + }) + : this.options.with({ location: "body" }); + + this.type = this.engine.mutateReference(this.sourceType, newOptions) as HttpCanonicalization; + } +} diff --git a/packages/http-canonicalization/src/model.ts b/packages/http-canonicalization/src/model.ts new file mode 100644 index 00000000000..e43d3228ec4 --- /dev/null +++ b/packages/http-canonicalization/src/model.ts @@ -0,0 +1,137 @@ +import type { MemberType, Model } from "@typespec/compiler"; +import { getVisibilitySuffix, Visibility } from "@typespec/http"; +import { ModelMutation } from "@typespec/mutator-framework"; +import { Codec, getJsonEncoderRegistry } from "./codecs.js"; +import type { HttpCanonicalizationMutations } from "./http-canonicalization-classes.js"; +import type { HttpCanonicalizer } from "./http-canonicalization.js"; +import type { ModelPropertyHttpCanonicalization } from "./model-property.js"; +import { HttpCanonicalizationOptions } from "./options.js"; +import type { ScalarHttpCanonicalization } from "./scalar.js"; + +/** + * Canonicalizes models for HTTP. + */ +export class ModelHttpCanonicalization extends ModelMutation< + HttpCanonicalizationOptions, + HttpCanonicalizationMutations, + HttpCanonicalizer +> { + /** + * Indicates if the canonicalization wraps a named TypeSpec declaration. + */ + isDeclaration: boolean = false; + + /** + * Codec chosen to transform language and wire types for this model. + */ + codec: Codec; + + /** + * Mutation subgraph for language types. + */ + get #languageSubgraph() { + return this.engine.getLanguageSubgraph(this.options); + } + + /** + * Mutation subgraph for wire types. + */ + get #wireSubgraph() { + return this.engine.getWireSubgraph(this.options); + } + + /** + * The possibly mutated language type for this model. + */ + get languageType() { + return this.getMutatedType(this.#languageSubgraph); + } + + /** + * The possibly mutated wire type for this model. + */ + get wireType() { + return this.getMutatedType(this.#wireSubgraph); + } + + constructor( + engine: HttpCanonicalizer, + sourceType: Model, + referenceTypes: MemberType[], + options: HttpCanonicalizationOptions, + ) { + super(engine, sourceType, referenceTypes, options); + this.isDeclaration = !!this.sourceType.name; + const registry = getJsonEncoderRegistry(this.engine.$); + this.codec = registry.detect(this); + } + + /** + * The canonical properties that are visible under the current visibility + * options. + */ + get visibleProperties(): Map { + return new Map( + [...(this.properties as Map)].filter( + ([_, p]) => (p as ModelPropertyHttpCanonicalization).isVisible, + ), + ); + } + + /** + * Applies mutations required to build the language and wire views of the model. + */ + mutate() { + const languageNode = this.getMutationNode(this.engine.getLanguageSubgraph(this.options)); + languageNode.whenMutated(this.#renameWhenMutated.bind(this)); + + const wireNode = this.getMutationNode(this.engine.getWireSubgraph(this.options)); + wireNode.whenMutated(this.#renameWhenMutated.bind(this)); + + if (this.engine.$.array.is(this.sourceType) && this.sourceType.name === "Array") { + if (this.sourceType.baseModel) { + this.baseModel = this.engine.mutate(this.sourceType.baseModel, this.options); + } + + for (const prop of this.sourceType.properties.values()) { + this.properties.set(prop.name, this.engine.mutate(prop, this.options)); + } + + const newIndexerOptions: Partial = { + visibility: this.options.visibility | Visibility.Item, + }; + + if (this.options.isJsonMergePatch()) { + newIndexerOptions.contentType = "application/json"; + } + + this.indexer = { + key: this.engine.mutate( + this.sourceType.indexer.key, + this.options, + ) as ScalarHttpCanonicalization, + value: this.engine.mutate( + this.sourceType.indexer.value, + this.options.with(newIndexerOptions), + ), + }; + + return; + } + + super.mutate(); + } + + /** + * Adds visibility-based suffixes to mutated models to ensure unique naming. + */ + #renameWhenMutated(m: Model | null) { + if (!m || !m.name) return; + + const suffix = getVisibilitySuffix(this.options.visibility, Visibility.Read); + + const mergePatchSuffix = + this.options.contentType === "application/merge-patch+json" ? "MergePatch" : ""; + m.name = `${m.name}${suffix}${mergePatchSuffix}`; + } +} diff --git a/packages/http-canonicalization/src/operation.test.ts b/packages/http-canonicalization/src/operation.test.ts new file mode 100644 index 00000000000..6e8befa1b4f --- /dev/null +++ b/packages/http-canonicalization/src/operation.test.ts @@ -0,0 +1,129 @@ +import { t, type TesterInstance } from "@typespec/compiler/testing"; +import { $ } from "@typespec/compiler/typekit"; +import { beforeEach, describe, expect, it } from "vitest"; +import { Tester } from "../test/test-host.js"; +import { HttpCanonicalizer } from "./http-canonicalization.js"; +import { ModelHttpCanonicalization } from "./model.js"; +import type { ScalarHttpCanonicalization } from "./scalar.js"; + +let runner: TesterInstance; +beforeEach(async () => { + runner = await Tester.createInstance(); +}); + +describe("Operation parameters", async () => { + it("works with implicit request bodies", async () => { + const { createFoo, program } = await runner.compile(t.code` + model ${t.model("Foo")} { + @visibility(Lifecycle.Read) createdAt: utcDateTime; + name: string; + } + + @route("/foo") + @post + op ${t.op("createFoo")}(foo: Foo): Foo; + `); + + const tk = $(program); + + const canonicalizer = new HttpCanonicalizer(tk); + const createFooCanonical = canonicalizer.canonicalize(createFoo); + const bodyType = createFooCanonical.requestParameters.body!.type as ModelHttpCanonicalization; + expect(bodyType).toBeDefined(); + expect(bodyType).toBeInstanceOf(ModelHttpCanonicalization); + const fooProp = bodyType.properties.get("foo")!; + expect(fooProp).toBeDefined(); + + const fooType = fooProp.type as ModelHttpCanonicalization; + expect(fooType.languageType.name).toBe("FooCreate"); + expect(fooType.visibleProperties.size).toBe(1); + expect(fooType.visibleProperties.has("name")).toBe(true); + expect(fooType.visibleProperties.has("createdAt")).toBe(false); + }); + + it("works with explicit request bodies", async () => { + const { createFoo, program } = await runner.compile(t.code` + model ${t.model("Foo")} { + @visibility(Lifecycle.Read) createdAt: utcDateTime; + name: string; + } + + @route("/foo") + @post + op ${t.op("createFoo")}(@body foo: Foo): Foo; + `); + + const tk = $(program); + const canonicalizer = new HttpCanonicalizer(tk); + const createFooCanonical = canonicalizer.canonicalize(createFoo); + const bodyType = createFooCanonical.requestParameters.body!.type as ModelHttpCanonicalization; + expect(bodyType).toBeDefined(); + expect(bodyType).toBeInstanceOf(ModelHttpCanonicalization); + expect(bodyType.languageType.name).toBe("FooCreate"); + }); + + it("works with headers", async () => { + const { createFoo, program } = await runner.compile(t.code` + model ${t.model("Foo")} { + @visibility(Lifecycle.Read) createdAt: utcDateTime; + name: string; + } + + @route("/foo") + @post + op ${t.op("createFoo")}(@header \`if-modified-since\`: utcDateTime, @body body: Foo): Foo; + `); + + const tk = $(program); + const canonicalizer = new HttpCanonicalizer(tk); + const createFooCanonical = canonicalizer.canonicalize(createFoo); + const dateProp = createFooCanonical.requestParameters.properties[0]; + expect(dateProp.kind).toBe("header"); + const scalarType = dateProp.property.type as ScalarHttpCanonicalization; + expect(scalarType.wireType === tk.builtin.string).toBe(true); + expect(scalarType.codec.id).toBe("rfc7231"); + expect(createFooCanonical.requestParameters.properties.length).toBe(2); + }); +}); + +describe("Operation responses", async () => { + it("canonicalizes response body and headers", async () => { + const { getFoo, program } = await runner.compile(t.code` + model ${t.model("Foo")} { + @visibility(Lifecycle.Read) createdAt: utcDateTime; + name: string; + } + + @route("/foo") + @get + op ${t.op("getFoo")}(): { + @statusCode status: 200; + @header etag: string; + @body result: Foo; + }; + `); + + const tk = $(program); + const canonicalizer = new HttpCanonicalizer(tk); + const canonical = canonicalizer.canonicalize(getFoo); + + expect(canonical.responses.length).toBe(1); + const response = canonical.responses[0]!; + expect(response.statusCodes).toBe(200); + + const content = response.responses[0]!; + expect(content.headers).toBeDefined(); + const etagHeader = content.headers!.etag; + expect(etagHeader).toBeDefined(); + const etagType = etagHeader!.type as ScalarHttpCanonicalization; + expect(etagType.wireType === tk.builtin.string).toBe(true); + + expect(content.body).toBeDefined(); + const body = content.body!; + expect(body.bodyKind).toBe("single"); + + const bodyType = body.type as ModelHttpCanonicalization; + expect(bodyType.visibleProperties.has("name")).toBe(true); + expect(bodyType.visibleProperties.has("createdAt")).toBe(true); + }); +}); diff --git a/packages/http-canonicalization/src/operation.ts b/packages/http-canonicalization/src/operation.ts new file mode 100644 index 00000000000..a4b15abd7a3 --- /dev/null +++ b/packages/http-canonicalization/src/operation.ts @@ -0,0 +1,731 @@ +import type { MemberType, ModelProperty, Operation } from "@typespec/compiler"; +import { + type HttpOperation, + type HttpVerb, + resolveRequestVisibility, + Visibility, +} from "@typespec/http"; +import "@typespec/http/experimental/typekit"; + +import { OperationMutation } from "@typespec/mutator-framework"; +import type { + HttpCanonicalization, + HttpCanonicalizationMutations, +} from "./http-canonicalization-classes.js"; +import type { HttpCanonicalizer } from "./http-canonicalization.js"; +import type { ModelPropertyHttpCanonicalization } from "./model-property.js"; +import type { ModelHttpCanonicalization } from "./model.js"; +import { HttpCanonicalizationOptions } from "./options.js"; +import type { ScalarHttpCanonicalization } from "./scalar.js"; + +export interface CanonicalHeaderParameterOptions { + type: "header"; + name: string; + /** + * Equivalent of adding `*` in the path parameter as per [RFC-6570](https://datatracker.ietf.org/doc/html/rfc6570#section-3.2.3) + * + * | Style | Explode | Primitive value = 5 | Array = [3, 4, 5] | Object = {"role": "admin", "firstName": "Alex"} | + * | ------ | ------- | ------------------- | ----------------- | ----------------------------------------------- | + * | simple | false | `id=5` | `3,4,5` | `role,admin,firstName,Alex` | + * | simple | true | `id=5` | `3,4,5` | `role=admin,firstName=Alex` | + * + */ + explode?: boolean; +} + +export interface CanonicalCookieParameterOptions { + type: "cookie"; + name: string; +} + +export interface CanonicalQueryParameterOptions { + readonly name: string; + readonly explode: boolean; +} + +export interface CanonicalPathParameterOptions { + readonly name: string; + readonly explode: boolean; + readonly style: "simple" | "label" | "matrix" | "fragment" | "path"; + readonly allowReserved: boolean; +} + +export type CanonicalHttpProperty = + | CanonicalHeaderProperty + | CanonicalCookieProperty + | CanonicalQueryProperty + | CanonicalPathProperty + | CanonicalContentTypeProperty + | CanonicalStatusCodeProperty + | CanonicalBodyProperty + | CanonicalBodyRootProperty + | CanonicalMultipartBodyProperty + | CanonicalBodyPropertyProperty; + +export interface CanonicalHttpPropertyBase { + readonly property: ModelPropertyHttpCanonicalization; + /** Path from the root of the operation parameters/returnType to the property. */ + readonly path: (string | number)[]; +} +export interface CanonicalHeaderProperty extends CanonicalHttpPropertyBase { + readonly kind: "header"; + readonly options: CanonicalHeaderParameterOptions; +} +export interface CanonicalCookieProperty extends CanonicalHttpPropertyBase { + readonly kind: "cookie"; + readonly options: CanonicalCookieParameterOptions; +} +export interface CanonicalContentTypeProperty extends CanonicalHttpPropertyBase { + readonly kind: "contentType"; +} + +export interface CanonicalQueryProperty extends CanonicalHttpPropertyBase { + readonly kind: "query"; + readonly options: CanonicalQueryParameterOptions; +} + +export interface CanonicalPathProperty extends CanonicalHttpPropertyBase { + readonly kind: "path"; + readonly options: CanonicalPathParameterOptions; +} + +export interface CanonicalStatusCodeProperty extends CanonicalHttpPropertyBase { + readonly kind: "statusCode"; +} +export interface CanonicalBodyProperty extends CanonicalHttpPropertyBase { + readonly kind: "body"; +} +export interface CanonicalBodyRootProperty extends CanonicalHttpPropertyBase { + readonly kind: "bodyRoot"; +} +export interface CanonicalMultipartBodyProperty extends CanonicalHttpPropertyBase { + readonly kind: "multipartBody"; +} +/** Property to include inside the body */ +export interface CanonicalBodyPropertyProperty extends CanonicalHttpPropertyBase { + readonly kind: "bodyProperty"; +} + +type HttpOperationResponseInfo = HttpOperation["responses"][number]; +type HttpOperationResponseContentInfo = HttpOperationResponseInfo["responses"][number]; +type HttpOperationPropertyInfo = + | HttpOperationResponseContentInfo["properties"][number] + | HttpOperation["parameters"]["properties"][number]; +type HttpOperationRequestBodyInfo = NonNullable; +type HttpOperationResponseBodyInfo = NonNullable; +type HttpPayloadBodyInfo = HttpOperationRequestBodyInfo | HttpOperationResponseBodyInfo; +// eslint-disable-next-line @typescript-eslint/no-unused-vars +type HttpOperationBodyInfo = Extract; +type HttpOperationMultipartBodyInfo = Extract; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +type HttpOperationFileBodyInfo = Extract; +type HttpOperationMultipartPartInfo = HttpOperationMultipartBodyInfo["parts"][number]; +type HttpOperationMultipartPartBodyInfo = HttpOperationMultipartPartInfo["body"]; + +interface CanonicalHttpOperationParameters { + body?: CanonicalHttpPayloadBody; + properties: CanonicalHttpProperty[]; +} + +export interface CanonicalHttpOperationResponse { + /** + * Status code or range of status code for the response. + */ + readonly statusCodes: HttpOperationResponseInfo["statusCodes"]; + /** + * Canonicalized response TypeSpec type. + */ + readonly type: HttpCanonicalization; + /** + * Response description. + */ + readonly description?: string; + /** + * Response contents. + */ + readonly responses: CanonicalHttpOperationResponseContent[]; +} + +export interface CanonicalHttpOperationResponseContent { + /** Canonical HTTP properties for this response */ + readonly properties: CanonicalHttpProperty[]; + readonly headers?: Record; + readonly body?: CanonicalHttpPayloadBody; +} + +export type CanonicalHttpPayloadBody = + | CanonicalHttpOperationBody + | CanonicalHttpOperationMultipartBody + | CanonicalHttpOperationFileBody; + +export interface CanonicalHttpOperationBodyBase { + /** Content types. */ + readonly contentTypes: string[]; + /** Property used to set the content type if exists */ + readonly contentTypeProperty?: ModelPropertyHttpCanonicalization; + /** + * The payload property that defined this body, if any. + */ + readonly property?: ModelPropertyHttpCanonicalization; +} + +export interface CanonicalHttpBody { + readonly type: HttpCanonicalization; + /** If the body was explicitly set with `@body`. */ + readonly isExplicit: boolean; + /** If the body contains metadata annotations to ignore. */ + readonly containsMetadataAnnotations: boolean; +} + +export interface CanonicalHttpOperationBody + extends CanonicalHttpOperationBodyBase, + CanonicalHttpBody { + readonly bodyKind: "single"; +} + +export type CanonicalHttpOperationMultipartBody = + | CanonicalHttpOperationMultipartBodyModel + | CanonicalHttpOperationMultipartBodyTuple; + +export interface CanonicalHttpOperationMultipartBodyCommon extends CanonicalHttpOperationBodyBase { + readonly bodyKind: "multipart"; + /** Property annotated with `@multipartBody` */ + readonly property: ModelPropertyHttpCanonicalization; + readonly parts: CanonicalHttpOperationPart[]; +} + +export interface CanonicalHttpOperationMultipartBodyModel + extends CanonicalHttpOperationMultipartBodyCommon { + readonly multipartKind: "model"; + readonly type: ModelHttpCanonicalization; + readonly parts: CanonicalHttpOperationModelPart[]; +} + +export interface CanonicalHttpOperationMultipartBodyTuple + extends CanonicalHttpOperationMultipartBodyCommon { + readonly multipartKind: "tuple"; + readonly type: HttpCanonicalization; + readonly parts: CanonicalHttpOperationTuplePart[]; +} + +export type CanonicalHttpOperationMultipartPartBody = + | CanonicalHttpOperationBody + | CanonicalHttpOperationFileBody; + +export interface CanonicalHttpOperationPartCommon { + /** Part body */ + readonly body: CanonicalHttpOperationMultipartPartBody; + /** If the Part is an HttpFile this is the property defining the filename */ + readonly filename?: ModelPropertyHttpCanonicalization; + /** Part headers */ + readonly headers: CanonicalHeaderProperty[]; + /** If there can be multiple of that part */ + readonly multi: boolean; + /** The part name, if any. */ + readonly name?: string; + /** If the part is optional */ + readonly optional: boolean; +} + +export type CanonicalHttpOperationPart = + | CanonicalHttpOperationModelPart + | CanonicalHttpOperationTuplePart; + +export interface CanonicalHttpOperationModelPart extends CanonicalHttpOperationPartCommon { + readonly partKind: "model"; + /** Property that defined the part if the model form is used. */ + readonly property: ModelPropertyHttpCanonicalization; + /** Part name */ + readonly name: string; +} + +export interface CanonicalHttpOperationTuplePart extends CanonicalHttpOperationPartCommon { + readonly partKind: "tuple"; + /** Property that defined the part -- always undefined for tuple entry parts. */ + readonly property?: undefined; +} + +export interface CanonicalHttpOperationFileBody extends CanonicalHttpOperationBodyBase { + readonly bodyKind: "file"; + /** + * The model type of the body that is or extends `Http.File`. + */ + readonly type: ModelHttpCanonicalization; + /** + * Whether the file contents should be represented as a string or raw byte stream. + */ + readonly isText: boolean; + /** + * The list of inner media types of the file. + */ + readonly contentTypes: string[]; + /** The `contentType` property. */ + readonly contentTypeProperty: ModelPropertyHttpCanonicalization; + /** The filename property. */ + readonly filename: ModelPropertyHttpCanonicalization; + /** The `contents` property. */ + readonly contents: ModelPropertyHttpCanonicalization & { + readonly type: ScalarHttpCanonicalization; + }; +} + +/** + * Canonicalizes operations by deriving HTTP-specific request and response shapes. + */ +export class OperationHttpCanonicalization extends OperationMutation< + HttpCanonicalizationOptions, + HttpCanonicalizationMutations, + HttpCanonicalizer +> { + /** + * Cached HTTP metadata for this operation. + */ + #httpOperationInfo: HttpOperation; + /** + * Indicates if the operation corresponds to a named declaration. Always true. + */ + isDeclaration: boolean = true; + /** + * Canonicalized request parameters grouped by location. + */ + requestParameters!: CanonicalHttpOperationParameters; + /** + * Canonicalized header parameters for the request. + */ + requestHeaders: CanonicalHeaderProperty[] = []; + /** + * Canonicalized query parameters for the request. + */ + queryParameters: CanonicalQueryProperty[] = []; + /** + * Canonicalized path parameters for the request. + */ + pathParameters: CanonicalPathProperty[] = []; + /** + * Canonicalized responses produced by the operation. + */ + responses: CanonicalHttpOperationResponse[] = []; + /** + * Concrete path for the HTTP operation. + */ + path: string; + /** + * URI template used for path and query expansion. + */ + uriTemplate: string; + /** + * Visibility applied when canonicalizing request parameters. + */ + parameterVisibility: Visibility; + /** + * Visibility applied when canonicalizing response payloads. + */ + returnTypeVisibility: Visibility; + /** + * HTTP method verb for the operation. + */ + method: HttpVerb; + /** + * Name assigned to the canonicalized operation. + */ + name: string; + + /** + * Mutation subgraph for language types. + */ + get #languageSubgraph() { + return this.engine.getLanguageSubgraph(this.options); + } + + /** + * Mutation subgraph for wire types. + */ + get #wireSubgraph() { + return this.engine.getWireSubgraph(this.options); + } + + /** + * The language type for this operation. + */ + get languageType() { + return this.getMutatedType(this.#languageSubgraph); + } + + /** + * The wire type for this operation. + */ + get wireType() { + return this.getMutatedType(this.#wireSubgraph); + } + + constructor( + engine: HttpCanonicalizer, + sourceType: Operation, + referenceTypes: MemberType[] = [], + options: HttpCanonicalizationOptions, + ) { + super(engine, sourceType, referenceTypes, options); + + this.#httpOperationInfo = this.engine.$.httpOperation.get(this.sourceType); + this.uriTemplate = this.#httpOperationInfo.uriTemplate; + this.path = this.#httpOperationInfo.path; + this.parameterVisibility = resolveRequestVisibility( + this.engine.$.program, + this.sourceType, + this.#httpOperationInfo.verb, + ); + this.returnTypeVisibility = Visibility.Read; + this.name = this.sourceType.name; + this.method = this.#httpOperationInfo.verb; + } + + /** + * Canonicalize this mutation for HTTP. + */ + mutate() { + this.#httpOperationInfo = this.engine.$.httpOperation.get(this.sourceType); + + this.uriTemplate = this.#httpOperationInfo.uriTemplate; + this.path = this.#httpOperationInfo.path; + this.parameterVisibility = resolveRequestVisibility( + this.engine.$.program, + this.sourceType, + this.#httpOperationInfo.verb, + ); + this.returnTypeVisibility = Visibility.Read; + this.name = this.sourceType.name; + this.method = this.#httpOperationInfo.verb; + + // unpack parameter info + this.requestParameters = { + properties: [], + }; + + const paramInfo = this.#httpOperationInfo.parameters; + + if (paramInfo.body) { + this.requestParameters.body = this.#canonicalizeBody( + paramInfo.body, + this.parameterVisibility, + ); + } + + for (const param of paramInfo.properties) { + this.requestParameters.properties.push( + this.#canonicalizeHttpProperty(param, this.parameterVisibility), + ); + } + + this.pathParameters = this.requestParameters.properties.filter( + (p) => p.kind === "path", + ) as CanonicalPathProperty[]; + + this.requestHeaders = this.requestParameters.properties.filter( + (p) => p.kind === "header", + ) as CanonicalHeaderProperty[]; + + this.queryParameters = this.requestParameters.properties.filter( + (p) => p.kind === "query", + ) as CanonicalQueryProperty[]; + + // unpack response info + const responseInfo = this.#httpOperationInfo.responses; + this.responses = responseInfo.map((response) => this.#canonicalizeResponse(response)); + } + + /** + * Canonicalizes an HTTP operation response container. + */ + #canonicalizeResponse(response: HttpOperationResponseInfo): CanonicalHttpOperationResponse { + return { + statusCodes: response.statusCodes, + type: this.engine.canonicalize(response.type, { + visibility: this.returnTypeVisibility, + }) as HttpCanonicalization, + description: response.description, + responses: response.responses.map((content) => this.#canonicalizeResponseContent(content)), + }; + } + + /** + * Canonicalizes a single response content entry. + */ + #canonicalizeResponseContent( + content: HttpOperationResponseContentInfo, + ): CanonicalHttpOperationResponseContent { + const canonicalHeaders: Record = {}; + if (content.headers) { + for (const [name, header] of Object.entries(content.headers) as [ + string, + NonNullable[string], + ][]) { + canonicalHeaders[name] = this.#canonicalizeModelProperty(header, this.returnTypeVisibility); + } + } + + return { + properties: content.properties.map((property) => + this.#canonicalizeHttpProperty(property, this.returnTypeVisibility), + ), + headers: Object.keys(canonicalHeaders).length > 0 ? canonicalHeaders : undefined, + body: content.body + ? this.#canonicalizeBody(content.body, this.returnTypeVisibility) + : undefined, + }; + } + + /** + * Canonicalizes an HTTP property descriptor. + */ + #canonicalizeHttpProperty( + property: HttpOperationPropertyInfo, + visibility: Visibility, + ): CanonicalHttpProperty { + const canonicalProperty = this.#canonicalizeModelProperty(property.property, visibility); + + switch (property.kind) { + case "header": + return { + kind: "header", + property: canonicalProperty, + path: property.path, + options: { + type: "header", + name: property.options.name, + explode: property.options.explode, + }, + }; + case "cookie": + return { + kind: "cookie", + property: canonicalProperty, + path: property.path, + options: { + type: "cookie", + name: property.options.name, + }, + }; + case "query": + return { + kind: "query", + property: canonicalProperty, + path: property.path, + options: { + name: property.options.name, + explode: property.options.explode, + }, + }; + case "path": + return { + kind: "path", + property: canonicalProperty, + path: property.path, + options: { + name: property.options.name, + explode: property.options.explode, + style: property.options.style, + allowReserved: property.options.allowReserved, + }, + }; + case "contentType": + return { + kind: "contentType", + property: canonicalProperty, + path: property.path, + }; + case "statusCode": + return { + kind: "statusCode", + property: canonicalProperty, + path: property.path, + }; + case "body": + return { + kind: "body", + property: canonicalProperty, + path: property.path, + }; + case "bodyRoot": + return { + kind: "bodyRoot", + property: canonicalProperty, + path: property.path, + }; + case "multipartBody": + return { + kind: "multipartBody", + property: canonicalProperty, + path: property.path, + }; + case "bodyProperty": + return { + kind: "bodyProperty", + property: canonicalProperty, + path: property.path, + }; + default: + throw new Error(`Unsupported HTTP property kind: ${(property as { kind: string }).kind}`); + } + } + + /** + * Canonicalizes the operation's request or response body metadata. + */ + #canonicalizeBody(body: HttpPayloadBodyInfo, visibility: Visibility): CanonicalHttpPayloadBody { + switch (body.bodyKind) { + case "single": + return { + bodyKind: "single", + contentTypes: body.contentTypes, + contentTypeProperty: body.contentTypeProperty + ? this.#canonicalizeModelProperty(body.contentTypeProperty, visibility) + : undefined, + property: body.property + ? this.#canonicalizeModelProperty(body.property, visibility) + : undefined, + type: this.engine.canonicalize(body.type, { + visibility, + contentType: body.contentTypes[0], + }) as HttpCanonicalization, + isExplicit: body.isExplicit, + containsMetadataAnnotations: body.containsMetadataAnnotations, + } satisfies CanonicalHttpOperationBody; + case "multipart": + return this.#canonicalizeMultipartBody(body, visibility); + case "file": + return { + bodyKind: "file", + contentTypes: body.contentTypes, + contentTypeProperty: this.#canonicalizeModelProperty( + body.contentTypeProperty, + visibility, + ), + property: body.property + ? this.#canonicalizeModelProperty(body.property, visibility) + : undefined, + type: this.engine.canonicalize(body.type, { + visibility, + }) as ModelHttpCanonicalization, + isText: body.isText, + filename: this.#canonicalizeModelProperty(body.filename, visibility), + contents: this.#canonicalizeModelProperty( + body.contents, + visibility, + ) as ModelPropertyHttpCanonicalization & { + readonly type: ScalarHttpCanonicalization; + }, + } satisfies CanonicalHttpOperationFileBody; + default: + return this.#assertNever(body); + } + } + + /** + * Canonicalizes multipart payload metadata. + */ + #canonicalizeMultipartBody( + body: HttpOperationMultipartBodyInfo, + visibility: Visibility, + ): CanonicalHttpOperationMultipartBody { + const property = this.#canonicalizeModelProperty(body.property, visibility); + const parts = body.parts.map((part) => this.#canonicalizeMultipartPart(part, visibility)); + + const base = { + bodyKind: "multipart" as const, + contentTypes: body.contentTypes, + contentTypeProperty: body.contentTypeProperty + ? this.#canonicalizeModelProperty(body.contentTypeProperty, visibility) + : undefined, + property, + parts, + } satisfies CanonicalHttpOperationMultipartBodyCommon; + + if (body.multipartKind === "model") { + return { + ...base, + multipartKind: "model", + parts: parts as CanonicalHttpOperationModelPart[], + type: this.engine.canonicalize(body.type, { + visibility, + }) as ModelHttpCanonicalization, + } satisfies CanonicalHttpOperationMultipartBodyModel; + } + + return { + ...base, + multipartKind: "tuple", + parts: parts as CanonicalHttpOperationTuplePart[], + type: this.engine.canonicalize(body.type, { + visibility, + }) as HttpCanonicalization, + } satisfies CanonicalHttpOperationMultipartBodyTuple; + } + + /** + * Canonicalizes a multipart part definition. + */ + #canonicalizeMultipartPart( + part: HttpOperationMultipartPartInfo, + visibility: Visibility, + ): CanonicalHttpOperationPart { + const base: CanonicalHttpOperationPartCommon = { + body: this.#canonicalizeMultipartPartBody(part.body, visibility), + headers: part.headers.map( + (header) => this.#canonicalizeHttpProperty(header, visibility) as CanonicalHeaderProperty, + ), + multi: part.multi, + optional: part.optional, + name: part.name, + filename: part.filename + ? this.#canonicalizeModelProperty(part.filename, visibility) + : undefined, + }; + + if (part.partKind === "model") { + return { + ...base, + partKind: "model", + property: this.#canonicalizeModelProperty(part.property, visibility), + name: part.name, + } satisfies CanonicalHttpOperationModelPart; + } + + return { + ...base, + partKind: "tuple", + property: undefined, + } satisfies CanonicalHttpOperationTuplePart; + } + + /** + * Canonicalizes the body associated with a multipart part. + */ + #canonicalizeMultipartPartBody( + body: HttpOperationMultipartPartBodyInfo, + visibility: Visibility, + ): CanonicalHttpOperationMultipartPartBody { + if (body.bodyKind === "file") { + return this.#canonicalizeBody(body, visibility) as CanonicalHttpOperationFileBody; + } + + return this.#canonicalizeBody(body, visibility) as CanonicalHttpOperationBody; + } + + /** + * Canonicalizes a model property with the supplied visibility. + */ + #canonicalizeModelProperty( + property: ModelProperty, + visibility: Visibility, + ): ModelPropertyHttpCanonicalization { + return this.engine.canonicalize(property, new HttpCanonicalizationOptions({ visibility })); + } + + /** + * Exhaustiveness guard for impossible code paths. + */ + #assertNever(value: never): never { + throw new Error("Unhandled HTTP payload body kind."); + } +} diff --git a/packages/http-canonicalization/src/options.ts b/packages/http-canonicalization/src/options.ts new file mode 100644 index 00000000000..ef7e7d40042 --- /dev/null +++ b/packages/http-canonicalization/src/options.ts @@ -0,0 +1,45 @@ +import { Visibility } from "@typespec/http"; +import { MutationOptions } from "@typespec/mutator-framework"; + +export type HttpCanonicalizationLocation = + | "header" + | "header-explode" + | "query" + | "query-explode" + | "path" + | "path-explode" + | "body"; + +export interface HttpCanonicalizationOptionsInit { + visibility?: Visibility; + location?: HttpCanonicalizationLocation; + contentType?: string; +} +export class HttpCanonicalizationOptions extends MutationOptions { + visibility: Visibility; + location: HttpCanonicalizationLocation; + contentType: string; + + constructor(options: HttpCanonicalizationOptionsInit = {}) { + super(); + this.visibility = options.visibility ?? Visibility.All; + this.location = options.location ?? "body"; + this.contentType = options.contentType ?? "none"; + } + + cacheKey(): string { + return `visibility:${this.visibility}|location:${this.location}|contentType:${this.contentType}`; + } + + with(newOptions: Partial): HttpCanonicalizationOptions { + return new HttpCanonicalizationOptions({ + visibility: newOptions.visibility ?? this.visibility, + location: newOptions.location ?? this.location, + contentType: newOptions.contentType ?? this.contentType, + }); + } + + isJsonMergePatch(): boolean { + return this.contentType === "application/merge-patch+json"; + } +} diff --git a/packages/http-canonicalization/src/scalar.test.ts b/packages/http-canonicalization/src/scalar.test.ts new file mode 100644 index 00000000000..58d6f3e1a28 --- /dev/null +++ b/packages/http-canonicalization/src/scalar.test.ts @@ -0,0 +1,122 @@ +import { t, type TesterInstance } from "@typespec/compiler/testing"; +import { $ } from "@typespec/compiler/typekit"; +import { Visibility } from "@typespec/http"; +import { beforeEach, expect, it } from "vitest"; +import { Tester } from "../test/test-host.js"; +import { HttpCanonicalizer } from "./http-canonicalization.js"; +import { ScalarHttpCanonicalization } from "./scalar.js"; + +let runner: TesterInstance; +beforeEach(async () => { + runner = await Tester.createInstance(); +}); + +it("canonicalizes a string", async () => { + const { myString, program } = await runner.compile(t.code` + scalar ${t.scalar("myString")} extends string; + `); + + const tk = $(program); + const engine = new HttpCanonicalizer(tk); + + const canonicalMyString = engine.canonicalize(myString, { + visibility: Visibility.Read, + }); + + // No mutation happens in this case, so: + expect(canonicalMyString.sourceType === canonicalMyString.languageType).toBe(true); + + expect(canonicalMyString.sourceType === canonicalMyString.wireType).toBe(true); + + expect(canonicalMyString.codec.id).toBe("identity"); +}); + +it("canonicalizes an int32 scalar", async () => { + const { myNumber, program } = await runner.compile(t.code` + scalar ${t.scalar("myNumber")} extends int32; + `); + + const tk = $(program); + const engine = new HttpCanonicalizer(tk); + + const canonicalMyString = engine.canonicalize(myNumber, { + visibility: Visibility.Read, + }); + + // We leave the language type the same + expect(canonicalMyString.sourceType === canonicalMyString.languageType).toBe(true); + + // but the wire type is a float64 + expect(canonicalMyString.sourceType === canonicalMyString.wireType).toBe(false); + expect(canonicalMyString.wireType === tk.builtin.float64).toBe(true); + expect(canonicalMyString.codec.id).toBe("coerce-to-float64"); +}); + +it("canonicalizes a utcDateTime scalar", async () => { + const { myDateTime, program } = await runner.compile(t.code` + scalar ${t.scalar("myDateTime")} extends utcDateTime; + `); + + const tk = $(program); + const engine = new HttpCanonicalizer(tk); + + const canonicalMyString = engine.canonicalize(myDateTime, { + visibility: Visibility.Read, + }); + + expect(canonicalMyString.wireType === tk.builtin.string).toBe(true); + expect(canonicalMyString.codec.id).toBe("rfc3339"); +}); + +it("canonicalizes a utcDateTime scalar with encode decorator", async () => { + const { myDateTime, program } = await runner.compile(t.code` + @encode(DateTimeKnownEncoding.rfc7231) + scalar ${t.scalar("myDateTime")} extends utcDateTime; + `); + + const tk = $(program); + const engine = new HttpCanonicalizer(tk); + + const canonicalMyString = engine.canonicalize(myDateTime, { + visibility: Visibility.Read, + }); + + // the codec is set appropriately + expect(canonicalMyString.codec.id).toBe("rfc7231"); + + // We leave the language type the same + expect(canonicalMyString.sourceType === canonicalMyString.languageType).toBe(true); + + // but the wire type is a string + expect(canonicalMyString.sourceType === canonicalMyString.wireType).toBe(false); + expect(canonicalMyString.wireType === tk.builtin.string).toBe(true); +}); + +it("canonicalizes a utcDateTime scalar with encode decorator on a member", async () => { + const { Foo, program } = await runner.compile(t.code` + model ${t.model("Foo")} { + @encode(DateTimeKnownEncoding.rfc7231) + @visibility(Lifecycle.Read) + createdAt: utcDateTime; + } + `); + + const tk = $(program); + const engine = new HttpCanonicalizer(tk); + const canonicalFoo = engine.canonicalize(Foo, { + visibility: Visibility.Read, + }); + + // navigating canonicalization + const canonicalDateTime = canonicalFoo.properties.get("createdAt")! + .type as ScalarHttpCanonicalization; + + expect(canonicalDateTime).toBeInstanceOf(ScalarHttpCanonicalization); + expect(canonicalDateTime.wireType === tk.builtin.string).toBe(true); + expect(canonicalDateTime.codec.id).toBe("rfc7231"); + + // navigating mutated type + const wireFoo = canonicalFoo.wireType; + const wireDateType = wireFoo.properties.get("createdAt")!.type; + expect(wireDateType === tk.builtin.string).toBe(true); +}); diff --git a/packages/http-canonicalization/src/scalar.ts b/packages/http-canonicalization/src/scalar.ts new file mode 100644 index 00000000000..67bf3079ff4 --- /dev/null +++ b/packages/http-canonicalization/src/scalar.ts @@ -0,0 +1,86 @@ +import type { MemberType, Scalar } from "@typespec/compiler"; +import { ScalarMutation } from "@typespec/mutator-framework"; +import { getJsonEncoderRegistry, type Codec } from "./codecs.js"; +import type { HttpCanonicalizationMutations } from "./http-canonicalization-classes.js"; +import type { HttpCanonicalizer } from "./http-canonicalization.js"; +import { HttpCanonicalizationOptions } from "./options.js"; + +/** + * Canonicalizes scalar types by applying encoding-specific mutations. + */ +export class ScalarHttpCanonicalization extends ScalarMutation< + HttpCanonicalizationOptions, + HttpCanonicalizationMutations, + HttpCanonicalizer +> { + /** + * Canonicalization options. + */ + options: HttpCanonicalizationOptions; + /** + * Codec responsible for transforming the scalar into language and wire types. + */ + codec: Codec; + /** + * Indicates whether the scalar is a named TypeSpec declaration. + */ + isDeclaration: boolean = false; + + /** + * Mutation subgraph for language types. + */ + get #languageSubgraph() { + return this.engine.getLanguageSubgraph(this.options); + } + + /** + * Mutation subgraph for wire types. + */ + get #wireSubgraph() { + return this.engine.getWireSubgraph(this.options); + } + + /** + * The possibly mutated language type for this scalar. + */ + get languageType() { + return this.getMutatedType(this.#languageSubgraph); + } + + /** + * The possibly mutated wire type for this scalar. + */ + get wireType() { + return this.getMutatedType(this.#wireSubgraph); + } + + constructor( + engine: HttpCanonicalizer, + sourceType: Scalar, + referenceTypes: MemberType[], + options: HttpCanonicalizationOptions, + ) { + super(engine, sourceType, referenceTypes, options); + this.options = options; + + const registry = getJsonEncoderRegistry(this.engine.$); + this.codec = registry.detect(this); + this.isDeclaration = false; + } + + /** + * Canonicalize this scalar for HTTP. + */ + mutate() { + const languageNode = this.getMutationNode(this.#languageSubgraph); + const wireNode = this.getMutationNode(this.#wireSubgraph); + + const { languageType, wireType } = this.codec.encode(); + if (languageType !== this.sourceType) { + languageNode.replace(languageType as Scalar); + } + if (wireType !== this.sourceType) { + wireNode.replace(wireType as Scalar); + } + } +} diff --git a/packages/http-canonicalization/src/union-variant.ts b/packages/http-canonicalization/src/union-variant.ts new file mode 100644 index 00000000000..cec46814cbe --- /dev/null +++ b/packages/http-canonicalization/src/union-variant.ts @@ -0,0 +1,82 @@ +import type { MemberType, UnionVariant } from "@typespec/compiler"; +import { UnionVariantMutation } from "@typespec/mutator-framework"; +import type { HttpCanonicalizationMutations } from "./http-canonicalization-classes.js"; +import type { HttpCanonicalizer } from "./http-canonicalization.js"; +import { HttpCanonicalizationOptions } from "./options.js"; + +/** + * Canonicalizes a union variant for HTTP. + */ +export class UnionVariantHttpCanonicalization extends UnionVariantMutation< + HttpCanonicalizationOptions, + HttpCanonicalizationMutations, + HttpCanonicalizer +> { + /** + * Canonicalization options. + */ + options: HttpCanonicalizationOptions; + /** + * Indicates if the variant corresponds to a named declaration. Always false. + */ + isDeclaration: boolean = false; + /** + * Whether the variant is visible under the current visibility options. + */ + isVisible: boolean = true; + + /** + * Mutation subgraph for language types. + */ + get #languageSubgraph() { + return this.engine.getLanguageSubgraph(this.options); + } + + /** + * Mutation subgraph for wire types. + */ + get #wireSubgraph() { + return this.engine.getWireSubgraph(this.options); + } + + /** + * The possibly mutated language type for this variant. + */ + get languageType() { + return this.getMutatedType(this.#languageSubgraph); + } + + /** + * The possibly mutated wire type for this variant. + */ + get wireType() { + return this.getMutatedType(this.#wireSubgraph); + } + + constructor( + engine: HttpCanonicalizer, + sourceType: UnionVariant, + referenceTypes: MemberType[], + options: HttpCanonicalizationOptions, + ) { + super(engine, sourceType, referenceTypes, options); + this.options = options; + this.isDeclaration = !!this.sourceType.name; + } + + /** + * Canonicalize this union variant for HTTP. + */ + mutate() { + const languageNode = this.getMutationNode(this.#languageSubgraph); + const wireNode = this.getMutationNode(this.#wireSubgraph); + + if (this.isVisible) { + super.mutate(); + return; + } + + languageNode.delete(); + wireNode.delete(); + } +} diff --git a/packages/http-canonicalization/src/union.test.ts b/packages/http-canonicalization/src/union.test.ts new file mode 100644 index 00000000000..c0597d1a480 --- /dev/null +++ b/packages/http-canonicalization/src/union.test.ts @@ -0,0 +1,116 @@ +import { t, type TesterInstance } from "@typespec/compiler/testing"; +import { $ } from "@typespec/compiler/typekit"; +import { Visibility } from "@typespec/http"; +import { beforeEach, describe, expect, it } from "vitest"; +import { Tester } from "../test/test-host.js"; +import { HttpCanonicalizer } from "./http-canonicalization.js"; +import type { UnionHttpCanonicalization } from "./union.js"; + +let runner: TesterInstance; + +beforeEach(async () => { + runner = await Tester.createInstance(); +}); + +describe("UnionCanonicalization variant detection", () => { + it("detects literal property discriminants for object unions", async () => { + const { Choice, First, Second, program } = await runner.compile(t.code` + model ${t.model("First")} { kind: "first"; } + model ${t.model("Second")} { kind: "second"; } + union ${t.union("Choice")} { First, Second } + `); + + const canonicalizer = new HttpCanonicalizer($(program)); + const canonical = canonicalizer.canonicalize(Choice, { + visibility: Visibility.All, + }) as UnionHttpCanonicalization; + + expect(canonical.languageVariantTests.length).toBe(2); + + const [firstVariant, secondVariant] = canonical.languageVariantTests; + + expect(firstVariant.variant.sourceType.type).toBe(First); + expect(firstVariant.tests).toEqual([{ kind: "literal", path: ["kind"], value: "first" }]); + + expect(secondVariant.variant.sourceType.type).toBe(Second); + expect(secondVariant.tests).toEqual([{ kind: "literal", path: ["kind"], value: "second" }]); + + expect(canonical.wireVariantTests).toEqual(canonical.languageVariantTests); + }); + + it("prioritizes primitives before object variants and prepends type guards", async () => { + const { Choice, First, Second, program } = await runner.compile(t.code` + model ${t.model("First")} { kind: "first"; } + model ${t.model("Second")} { kind: "second"; } + union ${t.union("Choice")} { First; Second; int32; } + `); + + const canonicalizer = new HttpCanonicalizer($(program)); + const canonical = canonicalizer.canonicalize(Choice, { + visibility: Visibility.All, + }) as UnionHttpCanonicalization; + + expect(canonical.languageVariantTests.length).toBe(3); + + const [numberVariant, firstVariant, secondVariant] = canonical.languageVariantTests; + + expect(numberVariant.variant.sourceType.type.kind).toBe("Scalar"); + expect(numberVariant.tests).toEqual([{ kind: "type", path: [], type: "number" }]); + + expect(firstVariant.variant.sourceType.type).toBe(First); + expect(firstVariant.tests).toEqual([ + { kind: "type", path: [], type: "object" }, + { kind: "literal", path: ["kind"], value: "first" }, + ]); + + expect(secondVariant.variant.sourceType.type).toBe(Second); + expect(secondVariant.tests).toEqual([ + { kind: "type", path: [], type: "object" }, + { kind: "literal", path: ["kind"], value: "second" }, + ]); + + expect(canonical.wireVariantTests.map((entry) => entry.variant)).toEqual( + canonical.languageVariantTests.map((entry) => entry.variant), + ); + expect(canonical.wireVariantTests.map((entry) => entry.tests)).toEqual( + canonical.languageVariantTests.map((entry) => entry.tests), + ); + }); + + it("distinguishes primitive scalar variants", async () => { + const { Choice, program } = await runner.compile(t.code` + union ${t.union("Choice")} { string; boolean; int32; } + `); + + const canonicalizer = new HttpCanonicalizer($(program)); + const canonical = canonicalizer.canonicalize(Choice, { + visibility: Visibility.All, + }) as UnionHttpCanonicalization; + + expect(canonical.languageVariantTests).toHaveLength(3); + expect(canonical.languageVariantTests.map((entry) => entry.tests)).toEqual([ + [{ kind: "type", path: [], type: "number" }], + [{ kind: "type", path: [], type: "boolean" }], + [{ kind: "type", path: [], type: "string" }], + ]); + + expect(canonical.wireVariantTests.map((entry) => entry.tests)).toEqual( + canonical.languageVariantTests.map((entry) => entry.tests), + ); + }); + + it("throws when variants cannot be distinguished", async () => { + await expect(async () => { + const { Choice, program } = await runner.compile(t.code` + model ${t.model("A")} { value: string; } + model ${t.model("B")} { value: string; } + union ${t.union("Choice")} { A, B } + `); + + const canonicalizer = new HttpCanonicalizer($(program)); + canonicalizer.canonicalize(Choice, { + visibility: Visibility.All, + }); + }).rejects.toThrow(/Unable to distinguish union variant/); + }); +}); diff --git a/packages/http-canonicalization/src/union.ts b/packages/http-canonicalization/src/union.ts new file mode 100644 index 00000000000..0ec2857cf0b --- /dev/null +++ b/packages/http-canonicalization/src/union.ts @@ -0,0 +1,678 @@ +import type { + BooleanLiteral, + DiscriminatedUnion, + MemberType, + Model, + NumericLiteral, + Scalar, + StringLiteral, + Type, + Union, + UnionVariant, +} from "@typespec/compiler"; +import { getVisibilitySuffix, Visibility } from "@typespec/http"; +import { UnionMutation } from "@typespec/mutator-framework"; +import type { + HttpCanonicalization, + HttpCanonicalizationMutations, +} from "./http-canonicalization-classes.js"; +import type { HttpCanonicalizer } from "./http-canonicalization.js"; +import { ModelHttpCanonicalization } from "./model.js"; +import { HttpCanonicalizationOptions } from "./options.js"; +import type { UnionVariantHttpCanonicalization } from "./union-variant.jsx"; + +export interface VariantDescriptor { + variant: UnionVariantHttpCanonicalization; + envelopeType: ModelHttpCanonicalization | null; + discriminatorValue?: string | number | boolean | null; +} + +export type VariantTest = ConstVariantTest | PropertyExistenceTest | TypeVariantTest; + +export interface VariantTestBase { + kind: string; + // the path of the type to test. An empty path means the type itself. + path: string[]; +} + +export interface ConstVariantTest extends VariantTestBase { + kind: "literal"; + value: string | number | boolean | null; +} + +export interface PropertyExistenceTest extends VariantTestBase { + kind: "propertyExistence"; + propertyName: string; +} + +export interface TypeVariantTest extends VariantTestBase { + kind: "type"; + type: "object" | "array" | "string" | "number" | "boolean"; +} + +type TypeCategory = TypeVariantTest["type"]; + +interface VariantAnalysis { + index: number; + variant: UnionVariantHttpCanonicalization; + mutatedVariant: UnionVariant; + mutatedType: Type; + typeCategory: TypeCategory | null; + constTests: ConstVariantTest[]; + availableTests: VariantTestDefinition[]; +} + +interface VariantTestDefinition { + test: VariantTest; + evaluate: (analysis: VariantAnalysis) => boolean; + required: boolean; +} + +/** + * Canonicalizes union types, tracking discriminators and runtime variant tests. + */ +export class UnionHttpCanonicalization extends UnionMutation< + HttpCanonicalizationOptions, + HttpCanonicalizationMutations, + HttpCanonicalizer +> { + /** + * Canonicalization options guiding union transformation. + */ + options: HttpCanonicalizationOptions; + /** + * Indicates if this union corresponds to a named declaration. + */ + isDeclaration: boolean = false; + + /** + * True when \@discriminator is present on the union. + */ + isDiscriminated: boolean = false; + /** + * Envelope structure used for discriminated unions. + */ + envelopeKind: "object" | "none" = "none"; + /** + * Canonicalized discriminator property for envelope unions. + */ + discriminatorProperty: ModelHttpCanonicalization | null = null; + /** + * Collection of descriptors describing each canonicalized variant. + */ + variantDescriptors: VariantDescriptor[] = []; + /** + * Runtime tests used to select a variant for language types. + */ + languageVariantTests: { + tests: VariantTest[]; + variant: UnionVariantHttpCanonicalization; + }[] = []; + /** + * Runtime tests used to select a variant for wire types. + */ + wireVariantTests: { + tests: VariantTest[]; + variant: UnionVariantHttpCanonicalization; + }[] = []; + /** + * Discriminated union metadata. + */ + #discriminatedUnionInfo: DiscriminatedUnion | null = null; + /** + * Name of the discriminator property when present. + */ + discriminatorPropertyName: string | null = null; + /** + * Name of the envelope property when present. + */ + envelopePropertyName: string | null = null; + + /** + * Mutation subgraph for language types. + */ + get #languageSubgraph() { + return this.engine.getLanguageSubgraph(this.options); + } + + /** + * Mutation subgraph for wire types. + */ + get #wireSubgraph() { + return this.engine.getWireSubgraph(this.options); + } + + /** + * The potentially mutated language type for this union. + */ + get languageType() { + return this.getMutatedType(this.#languageSubgraph); + } + + /** + * The potentially mutated wire type for this union. + */ + get wireType() { + return this.getMutatedType(this.#wireSubgraph); + } + + constructor( + engine: HttpCanonicalizer, + sourceType: Union, + referenceTypes: MemberType[], + options: HttpCanonicalizationOptions, + ) { + super(engine, sourceType, referenceTypes, options); + this.options = options; + this.isDeclaration = !!this.sourceType.name; + this.#discriminatedUnionInfo = this.engine.$.union.getDiscriminatedUnion(sourceType) ?? null; + this.isDiscriminated = !!this.#discriminatedUnionInfo; + this.envelopePropertyName = this.#discriminatedUnionInfo?.options.envelopePropertyName ?? null; + this.discriminatorPropertyName = + this.#discriminatedUnionInfo?.options.discriminatorPropertyName ?? null; + } + + /** + * Returns variants that remain visible under the current visibility rules. + */ + get visibleVariants(): Map { + return new Map( + [...(this.variants as Map)].filter( + ([_, p]) => (p as UnionVariantHttpCanonicalization).isVisible, + ), + ); + } + + /** + * Canonicalize this union for HTTP. + */ + mutate() { + const languageNode = this.getMutationNode(this.#languageSubgraph); + languageNode.whenMutated(this.#renameWhenMutated.bind(this)); + + const wireNode = this.getMutationNode(this.#wireSubgraph); + wireNode.whenMutated(this.#renameWhenMutated.bind(this)); + + super.mutate(); + + if (this.isDiscriminated) { + const envelopeProp = this.#discriminatedUnionInfo!.options.envelopePropertyName; + const discriminatorProp = this.#discriminatedUnionInfo!.options.discriminatorPropertyName; + + for (const [variantName, variant] of this.variants) { + if (typeof variantName !== "string") { + throw new Error("symbolic variant names are not supported"); + } + + const descriptor: VariantDescriptor = { + variant, + envelopeType: this.engine.canonicalize( + this.engine.$.model.create({ + name: "", + properties: { + [discriminatorProp]: this.engine.$.modelProperty.create({ + name: discriminatorProp, + type: this.engine.$.literal.create(variantName), + }), + [envelopeProp]: this.engine.$.modelProperty.create({ + name: envelopeProp, + type: variant.languageType.type, + }), + }, + }), + this.options, + ) as unknown as ModelHttpCanonicalization, + discriminatorValue: variantName, + }; + + this.variantDescriptors.push(descriptor); + } + } else { + this.#detectVariantTests(); + } + } + + /** + * Appends visibility-specific suffixes to mutated union names. + */ + #renameWhenMutated(mutated: Union | null) { + if (!mutated) { + return; + } + + const suffix = getVisibilitySuffix(this.options.visibility, Visibility.Read); + + mutated.name = `${mutated.name}${suffix}`; + } + + /** + * Computes runtime test suites for non-discriminated unions. + */ + #detectVariantTests() { + const variants = [...this.visibleVariants.values()]; + + if (variants.length === 0) { + this.languageVariantTests = []; + this.wireVariantTests = []; + return; + } + + this.languageVariantTests = this.#computeVariantTests(variants, "language"); + this.wireVariantTests = this.#computeVariantTests(variants, "wire"); + } + + /** + * Produces ordered variant test plans for the specified output target. + */ + #computeVariantTests(variants: UnionVariantHttpCanonicalization[], target: "language" | "wire") { + const analyses = variants.map((variant, index) => this.#analyzeVariant(variant, target, index)); + + for (const analysis of analyses) { + analysis.availableTests = this.#buildTestDefinitions(analysis, analyses); + } + + const orderedAnalyses = [...analyses].sort((a, b) => { + const priorityDiff = this.#variantPriority(a) - this.#variantPriority(b); + if (priorityDiff !== 0) { + return priorityDiff; + } + + return a.index - b.index; + }); + + return orderedAnalyses.map((analysis) => { + const selected = this.#selectTestsForVariant(analysis, analyses); + const tests = selected + .slice() + .sort((a, b) => this.#testPriority(a.test) - this.#testPriority(b.test)) + .map((definition) => definition.test); + + return { variant: analysis.variant, tests }; + }); + } + + /** + * Inspects a variant to gather metadata needed for test selection. + */ + #analyzeVariant( + variant: UnionVariantHttpCanonicalization, + target: "language" | "wire", + index: number, + ): VariantAnalysis { + const mutatedVariant = target === "language" ? variant.languageType : variant.wireType; + const mutatedType = mutatedVariant.type; + const typeCategory = this.#getTypeCategory(mutatedType); + const constTests = this.#collectConstVariantTests(variant, mutatedVariant, target); + + return { + index, + variant, + mutatedVariant, + mutatedType, + typeCategory, + constTests, + availableTests: [], + }; + } + + /** + * Generates candidate tests for differentiating the provided variant. + */ + #buildTestDefinitions( + analysis: VariantAnalysis, + analyses: VariantAnalysis[], + ): VariantTestDefinition[] { + const definitions: VariantTestDefinition[] = []; + + if (analysis.typeCategory) { + const required = this.#isTypeTestRequired(analysis, analyses); + const typeTest: TypeVariantTest = { + kind: "type", + path: [], + type: analysis.typeCategory, + }; + definitions.push({ + test: typeTest, + evaluate: (candidate) => candidate.typeCategory === analysis.typeCategory, + required, + }); + } + + for (const constTest of analysis.constTests) { + definitions.push({ + test: constTest, + evaluate: (candidate) => this.#passesConstTest(constTest, candidate), + required: false, + }); + } + + return definitions; + } + + /** + * Chooses the minimal set of tests that uniquely identify a variant. + */ + #selectTestsForVariant( + analysis: VariantAnalysis, + analyses: VariantAnalysis[], + ): VariantTestDefinition[] { + const others = analyses.filter((candidate) => candidate !== analysis); + + if (analysis.availableTests.length === 0) { + if (others.length === 0) { + return []; + } + + throw new Error( + `Unable to determine distinguishing runtime checks for union variant "${this.#getVariantDebugName(analysis.variant)}".`, + ); + } + + const required = analysis.availableTests.filter((test) => test.required); + const optional = analysis.availableTests.filter((test) => !test.required); + + const remaining = others.filter((candidate) => + required.every((test) => test.evaluate(candidate)), + ); + + if (remaining.length === 0) { + return required; + } + + const optionalSubset = this.#findCoveringSubset(optional, remaining); + + if (!optionalSubset) { + throw new Error( + `Unable to distinguish union variant "${this.#getVariantDebugName(analysis.variant)}" with available runtime checks.`, + ); + } + + return [...required, ...optionalSubset]; + } + + /** + * Finds a set of optional tests that differentiate the provided candidates. + */ + #findCoveringSubset( + tests: VariantTestDefinition[], + candidates: VariantAnalysis[], + ): VariantTestDefinition[] | null { + if (tests.length === 0) { + return null; + } + + for (let size = 1; size <= tests.length; size++) { + const subset = this.#findCoveringSubsetOfSize(tests, candidates, size, 0, []); + if (subset) { + return subset; + } + } + + return null; + } + + /** + * Searches for a covering subset of tests of the requested size. + */ + #findCoveringSubsetOfSize( + tests: VariantTestDefinition[], + candidates: VariantAnalysis[], + size: number, + start: number, + working: VariantTestDefinition[], + ): VariantTestDefinition[] | null { + if (working.length === size) { + const coversAll = candidates.every((candidate) => + working.some((test) => !test.evaluate(candidate)), + ); + + return coversAll ? [...working] : null; + } + + for (let index = start; index <= tests.length - (size - working.length); index++) { + working.push(tests[index]!); + const result = this.#findCoveringSubsetOfSize(tests, candidates, size, index + 1, working); + if (result) { + return result; + } + working.pop(); + } + + return null; + } + + /** + * Determines if a type-test is required to distinguish a variant. + */ + #isTypeTestRequired(analysis: VariantAnalysis, analyses: VariantAnalysis[]) { + if (analysis.typeCategory === "object") { + return analyses.some( + (candidate) => candidate !== analysis && candidate.typeCategory !== "object", + ); + } + + if (analysis.typeCategory === "array") { + return analyses.some( + (candidate) => candidate !== analysis && candidate.typeCategory !== "array", + ); + } + + return false; + } + + /** + * Collects literal-based tests for the provided variant. + */ + #collectConstVariantTests( + variant: UnionVariantHttpCanonicalization, + mutatedVariant: UnionVariant, + target: "language" | "wire", + ) { + const tests: ConstVariantTest[] = []; + + const literalValue = this.#getLiteralValueFromType(mutatedVariant.type); + if (literalValue !== undefined) { + tests.push({ kind: "literal", path: [], value: literalValue }); + } + + if (variant.type instanceof ModelHttpCanonicalization) { + tests.push(...this.#collectModelConstTests(variant.type, target)); + } + + return tests; + } + + /** + * Gathers literal tests from a variant's model properties. + */ + #collectModelConstTests( + model: ModelHttpCanonicalization, + target: "language" | "wire", + ): ConstVariantTest[] { + const tests: ConstVariantTest[] = []; + + for (const property of model.visibleProperties.values()) { + if (property.sourceType.optional) { + continue; + } + + const propertyName = + target === "language" ? property.languageType?.name : property.wireType?.name; + + if (!propertyName) { + continue; + } + + const propertyType = + target === "language" + ? (property.type as HttpCanonicalization).languageType + : (property.type as HttpCanonicalization).wireType; + const literalValue = this.#getLiteralValueFromType(propertyType); + if (literalValue === undefined) { + continue; + } + + tests.push({ + kind: "literal", + path: [propertyName], + value: literalValue, + }); + } + + return tests; + } + + /** + * Evaluates whether a candidate analysis satisfies a literal test. + */ + #passesConstTest(test: ConstVariantTest, analysis: VariantAnalysis) { + if (test.path.length === 0) { + const literalValue = this.#getLiteralValueFromType(analysis.mutatedType); + return literalValue !== undefined && literalValue === test.value; + } + + let currentType: Type | undefined = analysis.mutatedType; + + for (const segment of test.path) { + if (!currentType || currentType.kind !== "Model") { + return false; + } + + const property = this.#getModelProperty(currentType, segment); + if (!property) { + return false; + } + + currentType = property.type; + } + + const literalValue = this.#getLiteralValueFromType(currentType); + return literalValue !== undefined && literalValue === test.value; + } + + /** + * Extracts a literal value from a TypeSpec type when possible. + */ + #getLiteralValueFromType(type: Type | undefined) { + if (!type) { + return undefined; + } + + switch (type.kind) { + case "String": + case "Number": + case "Boolean": + return (type as StringLiteral | NumericLiteral | BooleanLiteral).value; + case "Intrinsic": + if (type.name === "null") { + return null; + } + // eslint-disable-next-line no-fallthrough + default: + return undefined; + } + } + + /** + * Retrieves a model property by name from a TypeSpec model. + */ + #getModelProperty(model: Model, name: string) { + return model.properties.get(name); + } + + /** + * Maps a TypeSpec type to the coarse category used by runtime tests. + */ + #getTypeCategory(type: Type): TypeCategory | null { + if (this.engine.$.array.is(type)) { + return "array"; + } + + switch (type.kind) { + case "Model": + return "object"; + case "String": + return "string"; + case "Number": + return "number"; + case "Boolean": + return "boolean"; + case "Scalar": + return this.#mapScalarRootToCategory(type); + default: + return null; + } + } + + /** + * Maps a scalar's root type to the corresponding runtime category. + */ + #mapScalarRootToCategory(scalar: Scalar | null): TypeCategory | null { + if (!scalar) { + return null; + } + const $ = this.engine.$; + + if ($.scalar.extendsString(scalar)) { + return "string"; + } else if ($.scalar.extendsNumeric(scalar)) { + return "number"; + } else if ($.scalar.extendsBoolean(scalar)) { + return "boolean"; + } + + return null; + } + + /** + * Provides a deterministic priority used to order variants. + */ + #variantPriority(analysis: VariantAnalysis) { + switch (analysis.typeCategory) { + case "number": + return 0; + case "boolean": + return 1; + case "string": + return 2; + case "array": + return 3; + case "object": + return 4; + default: + return 5; + } + } + + /** + * Provides a deterministic priority used to order variant tests. + */ + #testPriority(test: VariantTest) { + switch (test.kind) { + case "type": + return 0; + case "literal": + return 1; + case "propertyExistence": + return 2; + default: + return 10; + } + } + + /** + * Formats a variant name for debugging and error messages. + */ + #getVariantDebugName(variant: UnionVariantHttpCanonicalization) { + const name = variant.sourceType.name; + if (typeof name === "string") { + return name; + } + if (typeof name === "symbol") { + return name.toString(); + } + + const kind = variant.sourceType.type?.kind; + return kind ? `${kind} variant` : "anonymous variant"; + } +} diff --git a/packages/http-canonicalization/test/test-host.ts b/packages/http-canonicalization/test/test-host.ts new file mode 100644 index 00000000000..93d92e3b8c3 --- /dev/null +++ b/packages/http-canonicalization/test/test-host.ts @@ -0,0 +1,8 @@ +import { resolvePath } from "@typespec/compiler"; +import { createTester } from "@typespec/compiler/testing"; + +export const Tester = createTester(resolvePath(import.meta.dirname, ".."), { + libraries: ["@typespec/http"], +}) + .importLibraries() + .using("Http"); diff --git a/packages/http-canonicalization/tsconfig.json b/packages/http-canonicalization/tsconfig.json new file mode 100644 index 00000000000..6c2a9ca11db --- /dev/null +++ b/packages/http-canonicalization/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "lib": ["es2023", "DOM"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "target": "es2022", + "strict": true, + "skipLibCheck": true, + "isolatedModules": true, + "declaration": true, + "sourceMap": true, + "declarationMap": true, + "outDir": "dist", + "rootDir": "./", + "verbatimModuleSyntax": true, + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "test/**/*.ts", "test/**/*.tsx"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/http-canonicalization/vitest.config.ts b/packages/http-canonicalization/vitest.config.ts new file mode 100644 index 00000000000..63cad767f57 --- /dev/null +++ b/packages/http-canonicalization/vitest.config.ts @@ -0,0 +1,4 @@ +import { defineConfig, mergeConfig } from "vitest/config"; +import { defaultTypeSpecVitestConfig } from "../../vitest.config.js"; + +export default mergeConfig(defaultTypeSpecVitestConfig, defineConfig({})); diff --git a/packages/mutator-framework/package.json b/packages/mutator-framework/package.json new file mode 100644 index 00000000000..85b93b675e2 --- /dev/null +++ b/packages/mutator-framework/package.json @@ -0,0 +1,42 @@ +{ + "name": "@typespec/mutator-framework", + "version": "0.12.0", + "type": "module", + "main": "dist/src/index.js", + "repository": { + "type": "git", + "url": "git+https://github.com/microsoft/typespec.git" + }, + "scripts": { + "build": "tsc -p .", + "clean": "rimraf ./dist", + "format": "prettier . --write", + "watch": "tsc -p . --watch", + "test": "vitest run", + "test:ui": "vitest --ui", + "test:watch": "vitest -w", + "test:ci": "vitest run --coverage --reporter=junit --reporter=default", + "prepack": "tsx ../../eng/tsp-core/scripts/strip-dev-import-exports.ts", + "postpack": "tsx ../../eng/tsp-core/scripts/strip-dev-import-exports.ts --restore", + "lint": "eslint . --max-warnings=0", + "lint:fix": "eslint . --fix" + }, + "exports": { + ".": { + "import": "./dist/src/index.js" + } + }, + "keywords": [], + "author": "", + "license": "MIT", + "description": "", + "peerDependencies": { + "@typespec/compiler": "workspace:^" + }, + "devDependencies": { + "@types/node": "~24.3.0", + "@typespec/compiler": "workspace:^", + "concurrently": "^9.1.2", + "prettier": "~3.6.2" + } +} diff --git a/packages/mutator-framework/readme.md b/packages/mutator-framework/readme.md new file mode 100644 index 00000000000..d550760f41c --- /dev/null +++ b/packages/mutator-framework/readme.md @@ -0,0 +1,339 @@ +# Mutator Framework + +** WARNING: THIS PACKAGE IS EXPERIMENTAL AND WILL CHANGE ** + +This package provides utilities for building mutations of the TypeSpec type +graph. Mutations are modifications to the original type graph that live in a +parallel type graph and contain additional metadata relevant to consumers of +those mutated types. + +At a high level you: + +- Create mutation classes that control how each TypeSpec type (models, + properties, unions, scalars, literals, operations, etc.) is traversed and + transformed. +- Use strongly-typed helper APIs on mutation nodes to mutate into new types or + traverse to related nodes. +- Instantiate a `MutationEngine` subtype (e.g. `SimpleMutationEngine`) with the + `Typekit` from the TypeSpec program you want to mutate. + +The key APIs are: + +- `MutationEngine` – orchestrates creation, caching, and traversal of mutation nodes. +- `SimpleMutationEngine` – a convenience engine that exposes a single default mutation subgraph. +- `MutationOptions` – lets you parameterize a mutation run and cache its results. +- `ModelMutation`, `ModelPropertyMutation`, `UnionMutation`, `UnionVariantMutation`, + `OperationMutation`, etc. – base classes for crafting custom mutations per TypeSpec kind. +- `MutationSubgraph` – creates an isolated graph of mutated types that can be inspected or + retrieved later. +- `ModelMutationNode`, `ModelPropertyMutationNode`, `UnionMutationNode`, etc. - + nodes which represent the possible mutation of a particular type graph type. + +## Getting Started + +```ts +import type { Model, Program } from "@typespec/compiler"; +import { $, type Typekit } from "@typespec/compiler/typekit"; +import { SimpleMutationEngine } from "@typespec/mutator-framework"; + +// Create a typekit for the program +const tk: Typekit = $(program); + +// Instantiate an engine for running the mutations. +// Might be the built-in SimpleMutationEngine, or a +// custom `MutationEngine` subclass. +const engine = new SimpleMutationEngine(tk, { + Model: RenameModelMutation, // defined later in this guide +}); +const renamedMutation = engine.mutate(someType); +const mutatedType = renamedMutation.mutatedType; +``` + +## Defining Custom Mutation Options + +Options derive from `MutationOptions`. They let you tune mutations (for example, +to switch on features or change naming rules) and provide a cache key used to +memoize results. Extend the class and override `cacheKey()` to represent your +configuration. + +```ts +// rename-mutations.ts +import { MutationOptions } from "@typespec/mutator-framework"; + +export class RenameMutationOptions extends MutationOptions { + constructor( + readonly prefix: string, + readonly suffix: string, + ) { + super(); + } + + override cacheKey() { + return `${this.prefix}-${this.suffix}`; + } +} +``` + +## Creating a Custom Mutation Engine + +`MutationEngine` is responsible for coordinating mutation nodes and subgraphs. Supply constructors +for each type kind you want to customize. Anything you omit defaults to the base implementations +(`ModelMutation`, `ModelPropertyMutation`, etc.). + +You can also register additional mutation subgraphs. Each subgraph represents an isolated view of +the mutated graph. This is useful when you want to compare alternative transformations side by side +(for example, with different naming conventions). + +```ts +// rename-mutations.ts +import { MutationEngine, type MutationSubgraph } from "@typespec/mutator-framework"; +import type { Typekit } from "@typespec/compiler/typekit"; + +export class RenameMutationEngine extends MutationEngine<{ Model: RenameModelMutation }> { + constructor(typekit: Typekit) { + super(typekit, { Model: RenameModelMutation }); + this.registerSubgraph("prefix"); + this.registerSubgraph("suffix"); + } + + getPrefix(options: RenameMutationOptions): MutationSubgraph { + return this.getMutationSubgraph(options, "prefix"); + } + + getSuffix(options: RenameMutationOptions): MutationSubgraph { + return this.getMutationSubgraph(options, "suffix"); + } +} +``` + +The base `MutationEngine` does not define a default subgraph. If you just need a single mutated +view, use the `SimpleMutationEngine`. It auto-registers a `"subgraph"` and wires +`getDefaultMutationSubgraph` for you: + +```ts +import { SimpleMutationEngine } from "@typespec/mutator-framework"; + +const engine = new SimpleMutationEngine(tk, { + Model: RenameModelMutation, +}); +``` + +## Writing Mutation Classes + +Mutation classes derive from the base classes included in the framework. Each class receives the +engine, the source TypeSpec type, the list of reference members that referenced that type (if any), +and the options. Override `mutate()` to perform your transformations. + +Inside `mutate()` you can: + +- Traverse connected types via `this.engine.mutate(...)` or `this.engine.mutateReference(...)`. +- Retrieve or create mutation nodes with `this.getMutationNode()`. +- Mutate values using `this.mutateType()` or `engine.mutateType(...)`. +- Switch subgraphs by calling `this.engine.getMutationSubgraph(...)`. + +### Example: Renaming Models in Multiple Subgraphs + +```ts +// rename-mutations.ts +import { ModelMutation } from "@typespec/mutator-framework"; + +export class RenameModelMutation extends ModelMutation< + RenameMutationOptions, + { Model: RenameModelMutation }, + RenameMutationEngine +> { + get withPrefix() { + return this.getMutatedType(this.engine.getPrefix(this.options)); + } + + get withSuffix() { + return this.getMutatedType(this.engine.getSuffix(this.options)); + } + + mutate() { + if ("name" in this.sourceType && typeof this.sourceType.name === "string") { + this.mutateType(this.engine.getPrefix(this.options), (model) => { + model.name = `${this.options.prefix}${model.name}`; + }); + + this.mutateType(this.engine.getSuffix(this.options), (model) => { + model.name = `${model.name}${this.options.suffix}`; + }); + } + + // Always call super.mutate() if you still want the base implementation + // to traverse properties, base models, indexers, etc. with the same options. + super.mutate(); + } +} +``` + +### Running the Mutation + +```ts +import type { Model, Program } from "@typespec/compiler"; +import { $ } from "@typespec/compiler/typekit"; +import { RenameMutationEngine, RenameMutationOptions } from "./rename-mutations.js"; + +export function applyRename(program: Program, fooModel: Model) { + const engine = new RenameMutationEngine($(program)); + const options = new RenameMutationOptions("Pre", "Suf"); + + const fooMutation = engine.mutate(fooModel, options); + const prefixFoo = fooMutation.withPrefix; + const suffixFoo = fooMutation.withSuffix; + + const propMutation = fooMutation.properties.get("prop")!; + const barMutation = propMutation.type as RenameModelMutation; + + return { + prefixFoo, + suffixFoo, + barWithSuffix: barMutation.withSuffix, + }; +} +``` + +### Example: Mutating Referenced Types + +`ModelPropertyMutation` exposes `mutateReference` and `replaceReferencedType` +helpers that make it easy to mutate types referenced by properties. When +mutating references, a clone of the referenced type is made, so changes to not +affect the referenced type. This enables references to reference a unique type +with mutations that are particular to that type when referenced in that context. +For example, if the model property contains a decorator that affects the +mutation of a referenced scalar. + +```ts +import type { Model, Program } from "@typespec/compiler"; +import { $ } from "@typespec/compiler/typekit"; +import { + ModelMutation, + ModelPropertyMutation, + MutationOptions, + SimpleMutationEngine, +} from "@typespec/mutator-framework"; + +class UnionifyOptions extends MutationOptions {} + +class UnionifyModel extends ModelMutation< + UnionifyOptions, + UnionifyMutations, + SimpleMutationEngine +> { + get unionified() { + return this.getMutatedType(); + } +} + +class UnionifyProperty extends ModelPropertyMutation< + UnionifyOptions, + UnionifyMutations, + SimpleMutationEngine +> { + get unionified() { + return this.getMutatedType(); + } + + mutate() { + if (!this.engine.$.union.is(this.sourceType.type)) { + const unionVariant = this.engine.$.unionVariant.create({ type: this.sourceType.type }); + const fallbackVariant = this.engine.$.unionVariant.create({ + type: this.engine.$.builtin.string, + }); + + const unionType = this.engine.$.union.create({ variants: [unionVariant, fallbackVariant] }); + + this.type = this.replaceReferencedType( + this.engine.getDefaultMutationSubgraph(this.options), + unionType, + ); + } else { + super.mutate(); + } + } +} + +interface UnionifyMutations { + Model: UnionifyModel; + ModelProperty: UnionifyProperty; +} + +export function createUnionifyEngine(program: Program) { + const tk = $(program); + return new SimpleMutationEngine(tk, { + Model: UnionifyModel, + ModelProperty: UnionifyProperty, + }); +} + +export function unionifyModel(program: Program, fooModel: Model) { + const engine = createUnionifyEngine(program); + const fooMutation = engine.mutate(fooModel, new UnionifyOptions()); + const propMutation = fooMutation.properties.get("prop")!; + + return { + property: propMutation.unionified, + model: fooMutation.unionified, + }; +} +``` + +## Core Mutation Base Classes + +| Class | Source Type | Responsibilities | +| ----------------------- | ------------------------------ | ---------------------------------------------------------- | +| `ModelMutation` | `Model` | Traverses base models, properties, and indexers. | +| `ModelPropertyMutation` | `ModelProperty` | Mutates referenced types, exposes `replaceReferencedType`. | +| `UnionMutation` | `Union` | Iterates over variants and lazy-loads their mutations. | +| `UnionVariantMutation` | `UnionVariant` | Handles referenced variant types. | +| `ScalarMutation` | `Scalar` | Provides access to scalar definitions and projections. | +| `LiteralMutation` | string/number/boolean literals | Provides literal values and traversal control. | +| `OperationMutation` | `Operation` | Mutates parameters, return types, and decorators. | +| `InterfaceMutation` | `Interface` | Walks operations declared on the interface. | +| `IntrinsicMutation` | `Intrinsic` | Surfaces intrinsic TypeSpec types. | + +Each class inherits from the foundational `Mutation` class, which provides +shared helpers for mutated types (`getMutatedType`) and nodes +(`getMutationNode`). Override them or add convenience getters/setters to tailor +the experience for your consumers. + +## Working with Mutation Subgraphs + +The engine builds mutation nodes inside a `MutationSubgraph`. Each subgraph +captures a set of mutations that share the same options and transformation +logic. + +```ts +const prefixGraph = engine.getPrefix(renameOptions); +const prefixFoo = engine.getMutatedType(prefixGraph, Foo); + +const suffixGraph = engine.getSuffix(renameOptions); +const suffixFoo = engine.getMutatedType(suffixGraph, Foo); + +console.log(prefixFoo.name, suffixFoo.name); +``` + +When you call `engine.mutate(type, options)` the engine automatically creates mutation nodes in all +registered subgraphs for the provided options. Subsequent calls reuse the cached nodes, so you can +freely navigate the mutation graph without re-running your transformation logic. + +## Tips for Building Mutations + +- **Always call `super.mutate()`** when you want the default traversal logic after your custom + changes. Skipping it gives you full control, but you must handle traversal yourself. +- **Use `MutationOptions` subclasses** whenever your mutation behavior depends on input + configuration. Return a stable `cacheKey()` to reuse work. +- **Inspect `referenceTypes`** to learn which `ModelProperty` or `UnionVariant` led to the current + mutation node. This helps you emit diagnostics or perform context-sensitive logic. +- **Mutate lazily**. Mutations only run once per `(type, options)` pair. If you expose getters that + trigger work, they should go through `engine.mutate(...)` so the cache stays consistent. +- **Prefer `SimpleMutationEngine`** unless you need named subgraphs. You can graduate to a custom + engine later. + +## Additional Resources + +- Browse the rest of the files under `packages/mutator-framework/src/mutation` to see the built-in + mutation implementations. +- The unit tests in `mutation-engine.test.ts` demonstrate more end-to-end usage patterns, including + multi-subgraph mutations and reference replacements. diff --git a/packages/mutator-framework/src/index.ts b/packages/mutator-framework/src/index.ts new file mode 100644 index 00000000000..6efe2c99ae4 --- /dev/null +++ b/packages/mutator-framework/src/index.ts @@ -0,0 +1,4 @@ +export * from "./mutation/index.js"; + +// this ordering is important to avoid circular reference errors. +export * from "./mutation-node/index.js"; diff --git a/packages/mutator-framework/src/mutation-node/enum-member.test.ts b/packages/mutator-framework/src/mutation-node/enum-member.test.ts new file mode 100644 index 00000000000..54b72fb5e7e --- /dev/null +++ b/packages/mutator-framework/src/mutation-node/enum-member.test.ts @@ -0,0 +1,26 @@ +import { t, type TesterInstance } from "@typespec/compiler/testing"; +import { beforeEach, expect, it } from "vitest"; +import { Tester } from "../../test/test-host.js"; +import { getSubgraph } from "../../test/utils.js"; +let runner: TesterInstance; +beforeEach(async () => { + runner = await Tester.createInstance(); +}); + +it("handles mutation of member values", async () => { + const { program, Foo, a } = await runner.compile(t.code` + enum ${t.enum("Foo")} { + ${t.enumMember("a")}: "valueA"; + b: "valueB"; + } + `); + + const subgraph = getSubgraph(program); + const fooNode = subgraph.getNode(Foo); + const aNode = subgraph.getNode(a); + aNode.mutate((clone) => (clone.value = "valueARenamed")); + expect(aNode.isMutated).toBe(true); + expect(fooNode.isMutated).toBe(true); + expect(fooNode.mutatedType.members.get("a") === aNode.mutatedType).toBe(true); + expect(aNode.mutatedType.value).toBe("valueARenamed"); +}); diff --git a/packages/mutator-framework/src/mutation-node/enum-member.ts b/packages/mutator-framework/src/mutation-node/enum-member.ts new file mode 100644 index 00000000000..7c1bcf73a33 --- /dev/null +++ b/packages/mutator-framework/src/mutation-node/enum-member.ts @@ -0,0 +1,8 @@ +import type { EnumMember } from "@typespec/compiler"; +import { MutationNode } from "./mutation-node.js"; + +export class EnumMemberMutationNode extends MutationNode { + readonly kind = "EnumMember"; + + traverse() {} +} diff --git a/packages/mutator-framework/src/mutation-node/enum.test.ts b/packages/mutator-framework/src/mutation-node/enum.test.ts new file mode 100644 index 00000000000..ca8f419d12e --- /dev/null +++ b/packages/mutator-framework/src/mutation-node/enum.test.ts @@ -0,0 +1,26 @@ +import { t, type TesterInstance } from "@typespec/compiler/testing"; +import { beforeEach, expect, it } from "vitest"; +import { Tester } from "../../test/test-host.js"; +import { getSubgraph } from "../../test/utils.js"; +let runner: TesterInstance; +beforeEach(async () => { + runner = await Tester.createInstance(); +}); + +it("handles mutation of members", async () => { + const { program, Foo, a } = await runner.compile(t.code` + enum ${t.enum("Foo")} { + ${t.enumMember("a")}; + b; + } + `); + + const subgraph = getSubgraph(program); + const fooNode = subgraph.getNode(Foo); + const aNode = subgraph.getNode(a); + aNode.mutate((clone) => (clone.name = "aRenamed")); + expect(aNode.isMutated).toBe(true); + expect(fooNode.isMutated).toBe(true); + expect(fooNode.mutatedType.members.get("a") === undefined).toBeTruthy(); + expect(fooNode.mutatedType.members.get("aRenamed") === aNode.mutatedType).toBeTruthy(); +}); diff --git a/packages/mutator-framework/src/mutation-node/enum.ts b/packages/mutator-framework/src/mutation-node/enum.ts new file mode 100644 index 00000000000..8a0ee5cfa93 --- /dev/null +++ b/packages/mutator-framework/src/mutation-node/enum.ts @@ -0,0 +1,33 @@ +import type { Enum, EnumMember } from "@typespec/compiler"; +import { MutationEdge } from "./mutation-edge.js"; +import { MutationNode } from "./mutation-node.js"; + +export class EnumMutationNode extends MutationNode { + readonly kind = "Enum"; + + traverse() { + for (const member of this.sourceType.members.values()) { + const memberNode = this.subgraph.getNode(member); + this.connectMember(memberNode, member.name); + } + } + + connectMember(memberNode: MutationNode, sourcePropName: string) { + MutationEdge.create(this, memberNode, { + onTailMutation: () => { + this.mutatedType.members.delete(sourcePropName); + this.mutatedType.members.set(memberNode.mutatedType.name, memberNode.mutatedType); + }, + onTailDeletion: () => { + this.mutatedType.members.delete(sourcePropName); + }, + onTailReplaced: (newTail) => { + if (newTail.mutatedType.kind !== "EnumMember") { + throw new Error("Cannot replace enum member with non-enum member type"); + } + this.mutatedType.members.delete(sourcePropName); + this.mutatedType.members.set(newTail.mutatedType.name, newTail.mutatedType); + }, + }); + } +} diff --git a/packages/mutator-framework/src/mutation-node/factory.ts b/packages/mutator-framework/src/mutation-node/factory.ts new file mode 100644 index 00000000000..e8930083d8a --- /dev/null +++ b/packages/mutator-framework/src/mutation-node/factory.ts @@ -0,0 +1,95 @@ +import type { + BooleanLiteral, + Enum, + EnumMember, + Interface, + IntrinsicType, + Model, + ModelProperty, + NumericLiteral, + Operation, + Scalar, + StringLiteral, + Tuple, + Type, + Union, + UnionVariant, +} from "@typespec/compiler"; +import { EnumMemberMutationNode } from "./enum-member.js"; +import { EnumMutationNode } from "./enum.js"; +import { InterfaceMutationNode } from "./interface.js"; +import { IntrinsicMutationNode } from "./intrinsic.js"; +import { LiteralMutationNode } from "./literal.js"; +import { ModelPropertyMutationNode } from "./model-property.js"; +import { ModelMutationNode } from "./model.js"; +import type { MutationSubgraph } from "./mutation-subgraph.js"; +import { OperationMutationNode } from "./operation.js"; +import { ScalarMutationNode } from "./scalar.js"; +import { TupleMutationNode } from "./tuple.js"; +import { UnionVariantMutationNode } from "./union-variant.js"; +import { UnionMutationNode } from "./union.js"; + +export function mutationNodeFor( + subgraph: MutationSubgraph, + sourceType: T, +): MutationNodeForType { + switch (sourceType.kind) { + case "Operation": + return new OperationMutationNode(subgraph, sourceType) as MutationNodeForType; + case "Interface": + return new InterfaceMutationNode(subgraph, sourceType) as MutationNodeForType; + case "Model": + return new ModelMutationNode(subgraph, sourceType) as MutationNodeForType; + case "ModelProperty": + return new ModelPropertyMutationNode(subgraph, sourceType) as MutationNodeForType; + case "Scalar": + return new ScalarMutationNode(subgraph, sourceType) as MutationNodeForType; + case "Tuple": + return new TupleMutationNode(subgraph, sourceType) as MutationNodeForType; + case "Union": + return new UnionMutationNode(subgraph, sourceType) as MutationNodeForType; + case "UnionVariant": + return new UnionVariantMutationNode(subgraph, sourceType) as MutationNodeForType; + case "Enum": + return new EnumMutationNode(subgraph, sourceType) as MutationNodeForType; + case "EnumMember": + return new EnumMemberMutationNode(subgraph, sourceType) as MutationNodeForType; + case "String": + case "Number": + case "Boolean": + return new LiteralMutationNode( + subgraph, + sourceType as StringLiteral | NumericLiteral | BooleanLiteral, + ) as MutationNodeForType; + case "Intrinsic": + return new IntrinsicMutationNode(subgraph, sourceType) as MutationNodeForType; + default: + throw new Error("Unsupported type kind: " + sourceType.kind); + } +} + +export type MutationNodeForType = T extends Model + ? ModelMutationNode + : T extends Interface + ? InterfaceMutationNode + : T extends Operation + ? OperationMutationNode + : T extends ModelProperty + ? ModelPropertyMutationNode + : T extends Scalar + ? ScalarMutationNode + : T extends Tuple + ? TupleMutationNode + : T extends Union + ? UnionMutationNode + : T extends UnionVariant + ? UnionVariantMutationNode + : T extends Enum + ? EnumMutationNode + : T extends EnumMember + ? EnumMemberMutationNode + : T extends StringLiteral | NumericLiteral | BooleanLiteral + ? LiteralMutationNode + : T extends IntrinsicType + ? IntrinsicMutationNode + : never; diff --git a/packages/mutator-framework/src/mutation-node/index.ts b/packages/mutator-framework/src/mutation-node/index.ts new file mode 100644 index 00000000000..fd16b2fc830 --- /dev/null +++ b/packages/mutator-framework/src/mutation-node/index.ts @@ -0,0 +1,16 @@ +export * from "./mutation-node.js"; + +export * from "./enum.js"; +export * from "./interface.js"; +export * from "./intrinsic.js"; +export * from "./literal.js"; +export * from "./model-property.js"; +export * from "./model.js"; +export * from "./mutation-edge.js"; +export * from "./mutation-subgraph.js"; +export * from "./operation.js"; +export * from "./scalar.js"; +export * from "./tuple.js"; +export * from "./union-variant.js"; +export * from "./union.js"; +//export * from "./enum-member.js"; diff --git a/packages/mutator-framework/src/mutation-node/interface.ts b/packages/mutator-framework/src/mutation-node/interface.ts new file mode 100644 index 00000000000..9429a377e69 --- /dev/null +++ b/packages/mutator-framework/src/mutation-node/interface.ts @@ -0,0 +1,33 @@ +import type { Interface, Operation } from "@typespec/compiler"; +import { MutationEdge } from "./mutation-edge.js"; +import { MutationNode } from "./mutation-node.js"; + +export class InterfaceMutationNode extends MutationNode { + readonly kind = "Interface"; + + traverse() { + for (const [opName, op] of this.sourceType.operations) { + const opNode = this.subgraph.getNode(op); + this.connectOperation(opNode, opName); + } + } + + connectOperation(opNode: MutationNode, opName: string) { + MutationEdge.create(this, opNode, { + onTailMutation: () => { + this.mutatedType.operations.delete(opName); + this.mutatedType.operations.set(opNode.mutatedType.name, opNode.mutatedType); + }, + onTailDeletion: () => { + this.mutatedType.operations.delete(opName); + }, + onTailReplaced: (newTail) => { + if (newTail.mutatedType.kind !== "Operation") { + throw new Error("Cannot replace operation with non-operation type"); + } + this.mutatedType.operations.delete(opName); + this.mutatedType.operations.set(newTail.mutatedType.name, newTail.mutatedType); + }, + }); + } +} diff --git a/packages/mutator-framework/src/mutation-node/intrinsic.ts b/packages/mutator-framework/src/mutation-node/intrinsic.ts new file mode 100644 index 00000000000..aca76b7e2c5 --- /dev/null +++ b/packages/mutator-framework/src/mutation-node/intrinsic.ts @@ -0,0 +1,8 @@ +import type { IntrinsicType } from "@typespec/compiler"; +import { MutationNode } from "./mutation-node.js"; + +export class IntrinsicMutationNode extends MutationNode { + readonly kind = "Intrinsic"; + + traverse() {} +} diff --git a/packages/mutator-framework/src/mutation-node/literal.ts b/packages/mutator-framework/src/mutation-node/literal.ts new file mode 100644 index 00000000000..0571cefd02d --- /dev/null +++ b/packages/mutator-framework/src/mutation-node/literal.ts @@ -0,0 +1,10 @@ +import type { BooleanLiteral, NumericLiteral, StringLiteral } from "@typespec/compiler"; +import { MutationNode } from "./mutation-node.js"; + +export class LiteralMutationNode extends MutationNode< + StringLiteral | NumericLiteral | BooleanLiteral +> { + readonly kind = "Literal"; + + traverse() {} +} diff --git a/packages/mutator-framework/src/mutation-node/model-property.test.ts b/packages/mutator-framework/src/mutation-node/model-property.test.ts new file mode 100644 index 00000000000..542a838d2b3 --- /dev/null +++ b/packages/mutator-framework/src/mutation-node/model-property.test.ts @@ -0,0 +1,136 @@ +import { t, type TesterInstance } from "@typespec/compiler/testing"; +import { $ } from "@typespec/compiler/typekit"; +import { beforeEach, expect, it } from "vitest"; +import { Tester } from "../../test/test-host.js"; +import { getSubgraph } from "../../test/utils.js"; + +let runner: TesterInstance; +beforeEach(async () => { + runner = await Tester.createInstance(); +}); + +it("handles mutation of property types", async () => { + const { prop, program } = await runner.compile(t.code` + model Foo { + ${t.modelProperty("prop")}: string; + } + `); + const subgraph = getSubgraph(program); + const propNode = subgraph.getNode(prop); + const stringNode = subgraph.getNode($(program).builtin.string); + stringNode.mutate(); + expect(propNode.isMutated).toBe(true); + expect(propNode.mutatedType.type === stringNode.mutatedType).toBe(true); +}); + +it("handles mutating a reference", async () => { + const { Foo, Bar, prop, program } = await runner.compile(t.code` + model ${t.model("Foo")} { + ${t.modelProperty("prop")}: Bar; + }; + model ${t.model("Bar")} {} + `); + + const subgraph = getSubgraph(program); + const fooNode = subgraph.getNode(Foo); + const propNode = subgraph.getNode(prop); + const barPrime = subgraph.getReferenceNode(prop); + + // initially the source type is just Bar. + expect(barPrime.sourceType === Bar).toBe(true); + + barPrime.mutate(); + expect(fooNode.isMutated).toBe(true); + expect(propNode.isMutated).toBe(true); + expect(barPrime.isMutated).toBe(true); + expect(fooNode.mutatedType.properties.get("prop")!.type === barPrime.mutatedType).toBeTruthy(); + + const barNode = subgraph.getNode(Bar); + barNode.mutate(); + expect(barNode.isMutated).toBe(true); + expect(barPrime.isMutated).toBe(true); + // the mutated type doesn't change here. + expect(fooNode.mutatedType.properties.get("prop")!.type === barPrime.mutatedType).toBeTruthy(); +}); + +it.only("handles replacing the model reference", async () => { + const { Foo, Bar, prop, program } = await runner.compile(t.code` + model ${t.model("Foo")} { + ${t.modelProperty("prop")}: Bar; + }; + model ${t.model("Bar")} {} + `); + const tk = $(program); + const subgraph = getSubgraph(program); + const fooNode = subgraph.getNode(Foo); + const propNode = subgraph.getNode(prop); + const barPrime = subgraph.getReferenceNode(prop); + const unionType = tk.union.create({ + variants: [ + tk.unionVariant.create({ type: tk.builtin.string }), + tk.unionVariant.create({ type: Bar }), + ], + }); + + const replacedBarPrime = barPrime.replace(unionType); + + // the subgraph now returns the new reference node + expect(subgraph.getReferenceNode(prop) === replacedBarPrime).toBe(true); + + // foo and prop are marked mutated, barPrime is replaced + expect(fooNode.isMutated).toBe(true); + expect(propNode.isMutated).toBe(true); + expect(barPrime.isReplaced).toBe(true); + + // prop's type is the replaced type + expect(tk.union.is(propNode.mutatedType.type)).toBe(true); + expect( + fooNode.mutatedType!.properties.get("prop")!.type === replacedBarPrime.mutatedType, + ).toBeTruthy(); +}); + +it("handles mutating a reference to a reference", async () => { + const { myString, Foo, fprop, Bar, program } = await runner.compile(t.code` + scalar ${t.scalar("myString")} extends string; + model ${t.model("Foo")} { + ${t.modelProperty("fprop")}: myString; + }; + model ${t.model("Bar")} { + bprop: Foo.fprop; + } + `); + + const subgraph = getSubgraph(program); + const fooNode = subgraph.getNode(Foo); + const barNode = subgraph.getNode(Bar); + const myStringNode = subgraph.getNode(myString); + + myStringNode.mutate(); + expect(myStringNode.isMutated).toBe(true); + expect(fooNode.isMutated).toBe(true); + expect(barNode.isMutated).toBe(true); + + // Foo.prop's type is the mutated myString + expect( + fooNode.mutatedType.properties.get("fprop")!.type === myStringNode.mutatedType, + ).toBeTruthy(); + + // Bar.prop's type is the mutated Foo.prop + expect( + barNode.mutatedType.properties.get("bprop")!.type === + fooNode.mutatedType.properties.get("fprop")!, + ).toBeTruthy(); + + const fpropRefNode = subgraph.getReferenceNode(fprop); + fpropRefNode.mutate(); + expect(fpropRefNode.isMutated).toBe(true); + expect( + fooNode.mutatedType.properties.get("fprop")!.type === fpropRefNode.mutatedType, + ).toBeTruthy(); + + // Bar.bprop references the mutated type (though is the same reference since fprop was already mutated) + expect( + barNode.mutatedType.properties.get("bprop")!.type === + fooNode.mutatedType.properties.get("fprop")!, + ).toBeTruthy(); +}); diff --git a/packages/mutator-framework/src/mutation-node/model-property.ts b/packages/mutator-framework/src/mutation-node/model-property.ts new file mode 100644 index 00000000000..c22f4b64400 --- /dev/null +++ b/packages/mutator-framework/src/mutation-node/model-property.ts @@ -0,0 +1,53 @@ +import type { ModelProperty, Type } from "@typespec/compiler"; +import { MutationEdge } from "./mutation-edge.js"; +import { MutationNode } from "./mutation-node.js"; + +export class ModelPropertyMutationNode extends MutationNode { + readonly kind = "ModelProperty"; + #referenceMutated = false; + + traverse() { + const typeNode = this.subgraph.getNode(this.sourceType.type); + const referenceNode = this.subgraph.getReferenceNode(this.sourceType); + + this.connectType(typeNode); + this.connectReference(referenceNode); + } + + connectReference(referenceNode: MutationNode) { + MutationEdge.create(this, referenceNode, { + onTailMutation: () => { + this.#referenceMutated = true; + this.mutatedType.type = referenceNode.mutatedType; + }, + onTailDeletion: () => { + this.#referenceMutated = true; + this.mutatedType.type = this.$.intrinsic.any; + }, + onTailReplaced: (newTail) => { + this.#referenceMutated = true; + this.mutatedType.type = newTail.mutatedType; + }, + }); + } + + connectType(typeNode: MutationNode) { + MutationEdge.create(this, typeNode, { + onTailMutation: () => { + if (this.#referenceMutated) { + return; + } + this.mutatedType.type = typeNode.mutatedType; + }, + onTailDeletion: () => { + if (this.#referenceMutated) { + return; + } + this.mutatedType.type = this.$.intrinsic.any; + }, + onTailReplaced: (newTail) => { + this.mutatedType.type = newTail.mutatedType; + }, + }); + } +} diff --git a/packages/mutator-framework/src/mutation-node/model.test.ts b/packages/mutator-framework/src/mutation-node/model.test.ts new file mode 100644 index 00000000000..b68eb927968 --- /dev/null +++ b/packages/mutator-framework/src/mutation-node/model.test.ts @@ -0,0 +1,151 @@ +import type { Model, Type } from "@typespec/compiler"; +import { t, type TesterInstance } from "@typespec/compiler/testing"; +import { beforeEach, expect, it } from "vitest"; +import { Tester } from "../../test/test-host.js"; +import { getSubgraph } from "../../test/utils.js"; + +let runner: TesterInstance; +beforeEach(async () => { + runner = await Tester.createInstance(); +}); + +it("handles mutation of properties", async () => { + const { Foo, program } = await runner.compile(t.code` + model ${t.model("Foo")} { + prop: string; + } + `); + const subgraph = getSubgraph(program); + const fooNode = subgraph.getNode(Foo); + const propNode = subgraph.getNode(Foo.properties.get("prop")!); + propNode.mutate(); + expect(fooNode.isMutated).toBe(true); + expect(fooNode.mutatedType.properties.get("prop") === propNode.mutatedType).toBe(true); +}); + +it("handles deletion of properties", async () => { + const { Foo, program } = await runner.compile(t.code` + model ${t.model("Foo")} { + prop: string; + } + `); + const subgraph = getSubgraph(program); + const fooNode = subgraph.getNode(Foo); + const propNode = subgraph.getNode(Foo.properties.get("prop")!); + propNode.delete(); + expect(fooNode.isMutated).toBe(true); + expect(fooNode.mutatedType.properties.get("prop")).toBeUndefined(); +}); + +it("handles mutation of properties with name change", async () => { + const { Foo, program } = await runner.compile(t.code` + model ${t.model("Foo")} { + prop: string; + } + `); + const subgraph = getSubgraph(program); + const fooNode = subgraph.getNode(Foo); + const propNode = subgraph.getNode(Foo.properties.get("prop")!); + propNode.mutate((clone) => (clone.name = "propRenamed")); + expect(fooNode.isMutated).toBe(true); + expect(fooNode.mutatedType.properties.get("prop") === undefined).toBe(true); + expect(fooNode.mutatedType.properties.get("propRenamed") === propNode.mutatedType).toBe(true); +}); + +it("handles mutation of base models", async () => { + const { Foo, Bar, program } = await runner.compile(t.code` + model ${t.model("Foo")} extends Bar { + barProp: string; + } + + model ${t.model("Bar")} { + bazProp: string; + } + `); + const subgraph = getSubgraph(program); + const fooNode = subgraph.getNode(Foo); + const barNode = subgraph.getNode(Bar); + + barNode.mutate(); + expect(barNode.isMutated).toBe(true); + expect(fooNode.isMutated).toBe(true); + expect(fooNode.mutatedType.baseModel === barNode.mutatedType).toBeTruthy(); +}); + +it("handles deletion of base models", async () => { + const { Foo, Bar, program } = await runner.compile(t.code` + model ${t.model("Foo")} extends Bar { + barProp: string; + } + + model ${t.model("Bar")} { + bazProp: string; + } + `); + const subgraph = getSubgraph(program); + const fooNode = subgraph.getNode(Foo); + const barNode = subgraph.getNode(Bar); + + barNode.delete(); + expect(barNode.isDeleted).toBe(true); + expect(fooNode.isMutated).toBe(true); + expect(fooNode.mutatedType.baseModel).toBeUndefined(); +}); + +it("handles mutation of indexers", async () => { + const { Foo, Bar, program } = await runner.compile(t.code` + model ${t.model("Foo")} is Record {}; + model ${t.model("Bar")} { + bazProp: string; + } + `); + const subgraph = getSubgraph(program); + const fooNode = subgraph.getNode(Foo); + const barNode = subgraph.getNode(Bar); + + barNode.mutate(); + expect(barNode.isMutated).toBe(true); + expect(fooNode.isMutated).toBe(true); + expect((fooNode.mutatedType.indexer?.value as Type) === barNode.mutatedType).toBeTruthy(); +}); + +it("handles mutation of arrays", async () => { + const { Foo, Bar, bazProp, program } = await runner.compile(t.code` + model ${t.model("Foo")} {}; + model ${t.model("Bar")} { + ${t.modelProperty("bazProp")}: Foo[]; + } + `); + + const subgraph = getSubgraph(program); + const fooNode = subgraph.getNode(Foo); + const barNode = subgraph.getNode(Bar); + const bazPropNode = subgraph.getNode(bazProp); + + fooNode.mutate(); + expect(fooNode.isMutated).toBe(true); + expect(barNode.isMutated).toBe(true); + expect(bazPropNode.isMutated).toBe(true); + expect( + (bazPropNode.mutatedType.type as Model).indexer!.value === fooNode.mutatedType, + ).toBeTruthy(); +}); + +it("handles circular models", async () => { + const { Foo, Bar, program } = await runner.compile(t.code` + model ${t.model("Foo")} { + bar: Bar; + }; + model ${t.model("Bar")} { + foo: Foo; + } + `); + + const subgraph = getSubgraph(program); + const fooNode = subgraph.getNode(Foo); + const barNode = subgraph.getNode(Bar); + + fooNode.mutate(); + expect(fooNode.isMutated).toBe(true); + expect(barNode.isMutated).toBe(true); +}); diff --git a/packages/mutator-framework/src/mutation-node/model.ts b/packages/mutator-framework/src/mutation-node/model.ts new file mode 100644 index 00000000000..742550f46b1 --- /dev/null +++ b/packages/mutator-framework/src/mutation-node/model.ts @@ -0,0 +1,89 @@ +import type { Model, ModelProperty, Type } from "@typespec/compiler"; +import { MutationEdge } from "./mutation-edge.js"; +import { MutationNode } from "./mutation-node.js"; + +export class ModelMutationNode extends MutationNode { + readonly kind = "Model"; + + traverse() { + if (this.sourceType.baseModel) { + const baseNode = this.subgraph.getNode(this.sourceType.baseModel); + this.connectToBase(baseNode); + } + + for (const [propName, prop] of this.sourceType.properties) { + const propNode = this.subgraph.getNode(prop); + this.connectProperty(propNode, propName); + } + + if (this.sourceType.indexer) { + const indexerNode = this.subgraph.getNode(this.sourceType.indexer.value); + this.connectIndexerValue(indexerNode); + } + } + + connectToBase(baseNode: MutationNode) { + MutationEdge.create(this, baseNode, { + onTailMutation: () => { + this.mutatedType!.baseModel = baseNode.mutatedType; + }, + onTailDeletion: () => { + this.mutatedType.baseModel = undefined; + }, + onTailReplaced: (newTail) => { + if (newTail.mutatedType.kind !== "Model") { + throw new Error("Cannot replace base model with non-model type"); + } + this.mutatedType.baseModel = newTail.mutatedType; + }, + }); + } + + connectProperty(propNode: MutationNode, sourcePropName: string) { + MutationEdge.create(this, propNode, { + onTailMutation: () => { + this.mutatedType.properties.delete(sourcePropName); + this.mutatedType.properties.set(propNode.mutatedType.name, propNode.mutatedType); + }, + onTailDeletion: () => { + this.mutatedType.properties.delete(sourcePropName); + }, + onTailReplaced: (newTail) => { + if (newTail.mutatedType.kind !== "ModelProperty") { + throw new Error("Cannot replace model property with non-model property type"); + } + this.mutatedType.properties.delete(sourcePropName); + this.mutatedType.properties.set(newTail.mutatedType.name, newTail.mutatedType); + }, + }); + } + + connectIndexerValue(indexerNode: MutationNode) { + MutationEdge.create(this, indexerNode, { + onTailMutation: () => { + if (this.mutatedType.indexer) { + this.mutatedType.indexer = { + key: this.mutatedType.indexer.key, + value: indexerNode.mutatedType, + }; + } + }, + onTailDeletion: () => { + if (this.mutatedType.indexer) { + this.mutatedType.indexer = { + key: this.mutatedType.indexer.key, + value: this.$.intrinsic.any, + }; + } + }, + onTailReplaced: (newTail) => { + if (this.mutatedType.indexer) { + this.mutatedType.indexer = { + key: this.mutatedType.indexer.key, + value: newTail.mutatedType, + }; + } + }, + }); + } +} diff --git a/packages/mutator-framework/src/mutation-node/mutation-edge.ts b/packages/mutator-framework/src/mutation-node/mutation-edge.ts new file mode 100644 index 00000000000..3c537ce8970 --- /dev/null +++ b/packages/mutator-framework/src/mutation-node/mutation-edge.ts @@ -0,0 +1,43 @@ +import type { Type } from "@typespec/compiler"; +import { MutationNode } from "./mutation-node.js"; + +export interface MutationEdgeOptions { + onTailMutation: () => void; + onTailDeletion: () => void; + onTailReplaced: (newTail: MutationNode) => void; +} + +export class MutationEdge { + public head: MutationNode; + public tail: MutationNode; + #options: MutationEdgeOptions; + + constructor(head: MutationNode, tail: MutationNode, options: MutationEdgeOptions) { + this.head = head; + this.tail = tail; + this.#options = options; + this.tail.addInEdge(this); + } + + static create(head: MutationNode, tail: MutationNode, options: MutationEdgeOptions) { + return new MutationEdge(head, tail, options); + } + + tailMutated(): void { + this.head.mutate(); + this.#options.onTailMutation(); + } + + tailDeleted() { + this.head.mutate(); + this.#options.onTailDeletion(); + } + + tailReplaced(newTail: MutationNode) { + this.head.mutate(); + this.tail.deleteInEdge(this); + this.tail = newTail; + this.tail.addInEdge(this); + this.#options.onTailReplaced(newTail); + } +} diff --git a/packages/mutator-framework/src/mutation-node/mutation-node.test.ts b/packages/mutator-framework/src/mutation-node/mutation-node.test.ts new file mode 100644 index 00000000000..f39c4405186 --- /dev/null +++ b/packages/mutator-framework/src/mutation-node/mutation-node.test.ts @@ -0,0 +1,94 @@ +import { t, type TesterInstance } from "@typespec/compiler/testing"; +import { beforeEach, expect, it } from "vitest"; +import { Tester } from "../../test/test-host.js"; +import { getSubgraph } from "../../test/utils.js"; + +let runner: TesterInstance; +beforeEach(async () => { + runner = await Tester.createInstance(); +}); + +it("Subgraph#getNode returns the same node for the same type when called", async () => { + const { Foo, program } = await runner.compile(t.code` + model ${t.model("Foo")} { + prop: string; + } + `); + const subgraph = getSubgraph(program); + const fooNode1 = subgraph.getNode(Foo); + const fooNode2 = subgraph.getNode(Foo); + expect(fooNode1 === fooNode2).toBe(true); +}); + +it("Creates the same node when constructing the subgraph and coming back to the same type", async () => { + const { Foo, Bar, Baz, program } = await runner.compile(t.code` + model ${t.model("Foo")} { + prop: string; + } + + model ${t.model("Bar")} { + foo: Foo; + } + + model ${t.model("Baz")} { + foo: Foo; + } + `); + const subgraph = getSubgraph(program); + const fooNode = subgraph.getNode(Foo); + subgraph.getNode(Bar); + subgraph.getNode(Baz); + + expect(fooNode.inEdges.size).toBe(2); +}); + +it("starts with the mutatedType and sourceType being the same", async () => { + const { Foo, program } = await runner.compile(t.code` + model ${t.model("Foo")} { + prop: string; + } + `); + const subgraph = getSubgraph(program); + const fooNode = subgraph.getNode(Foo); + expect(fooNode.isMutated).toBe(false); + expect(fooNode.sourceType === fooNode.mutatedType).toBe(true); +}); + +it("clones the source type when mutating and sets isMutated to true", async () => { + const { Foo, program } = await runner.compile(t.code` + model ${t.model("Foo")} { + prop: string; + } + `); + const subgraph = getSubgraph(program); + const fooNode = subgraph.getNode(Foo); + expect(fooNode.isMutated).toBe(false); + expect(fooNode.sourceType === fooNode.mutatedType).toBe(true); + fooNode.mutate(); + expect(fooNode.isMutated).toBe(true); + expect(fooNode.sourceType === fooNode.mutatedType).toBe(false); + expect(fooNode.sourceType.name).toEqual(fooNode.mutatedType.name); +}); + +it("invokes whenMutated callbacks when mutating", async () => { + const { Foo, program } = await runner.compile(t.code` + model ${t.model("Foo")} { + prop: Bar; + } + + model ${t.model("Bar")} { + prop: string; + } + `); + const subgraph = getSubgraph(program); + const fooNode = subgraph.getNode(Foo); + const barNode = subgraph.getNode(Foo); + let called = false; + fooNode.whenMutated((mutatedType) => { + called = true; + expect(mutatedType).toBe(fooNode.mutatedType); + }); + expect(called).toBe(false); + barNode.mutate(); + expect(called).toBe(true); +}); diff --git a/packages/mutator-framework/src/mutation-node/mutation-node.ts b/packages/mutator-framework/src/mutation-node/mutation-node.ts new file mode 100644 index 00000000000..d855c151c9e --- /dev/null +++ b/packages/mutator-framework/src/mutation-node/mutation-node.ts @@ -0,0 +1,110 @@ +import type { MemberType, Type } from "@typespec/compiler"; +import type { Typekit } from "@typespec/compiler/typekit"; +import { mutationNodeFor, type MutationNodeForType } from "./factory.js"; +import type { MutationEdge } from "./mutation-edge.js"; +import type { MutationSubgraph } from "./mutation-subgraph.js"; + +let nextId = 0; + +export abstract class MutationNode { + abstract readonly kind: string; + + id = nextId++; + sourceType: T; + mutatedType: T; + isMutated: boolean = false; + isDeleted: boolean = false; + isReplaced: boolean = false; + replacementNode: MutationNodeForType | null = null; + inEdges: Set> = new Set(); + subgraph: MutationSubgraph; + referenceType: MemberType | null = null; + $: Typekit; + + #whenMutatedCallbacks: ((mutatedType: Type | null) => void)[] = []; + + constructor(subgraph: MutationSubgraph, sourceNode: T) { + this.subgraph = subgraph; + this.$ = this.subgraph.engine.$; + this.sourceType = sourceNode; + this.mutatedType = sourceNode; + } + + abstract traverse(): void; + + addInEdge(edge: MutationEdge) { + this.inEdges.add(edge); + } + + deleteInEdge(edge: MutationEdge) { + this.inEdges.delete(edge); + } + + whenMutated(cb: (mutatedType: T | null) => void) { + this.#whenMutatedCallbacks.push(cb as any); + } + + mutate(initializeMutation?: (type: T) => void) { + if (this.isMutated || this.isDeleted || this.isReplaced) { + return; + } + + this.mutatedType = this.$.type.clone(this.sourceType); + this.isMutated = true; + initializeMutation?.(this.mutatedType); + for (const cb of this.#whenMutatedCallbacks) { + cb(this.mutatedType); + } + + for (const edge of this.inEdges) { + edge.tailMutated(); + } + + this.$.type.finishType(this.mutatedType); + } + + delete() { + if (this.isMutated || this.isDeleted || this.isReplaced) { + return; + } + + this.isDeleted = true; + + for (const cb of this.#whenMutatedCallbacks) { + cb(null); + } + + this.mutatedType = this.$.intrinsic.never as T; + + for (const edge of this.inEdges) { + edge.tailDeleted(); + } + } + + replace(newType: Type) { + if (this.isMutated || this.isDeleted || this.isReplaced) { + return this; + } + + // We need to make a new node because different types need to handle edge mutations differently. + + this.isReplaced = true; + this.replacementNode = mutationNodeFor(this.subgraph, newType); + this.replacementNode.traverse(); + // we don't need to do the clone stuff with this node, but we mark it as + // mutated because we don't want to allow further mutations on it. + this.replacementNode.isMutated = true; + + if (this.referenceType) { + this.subgraph.replaceReferenceNode(this.referenceType, this.replacementNode); + } else { + this.subgraph.replaceNode(this, this.replacementNode); + } + + for (const edge of this.inEdges) { + edge.tailReplaced(this.replacementNode); + } + + return this.replacementNode; + } +} diff --git a/packages/mutator-framework/src/mutation-node/mutation-subgraph.ts b/packages/mutator-framework/src/mutation-node/mutation-subgraph.ts new file mode 100644 index 00000000000..4ada71da931 --- /dev/null +++ b/packages/mutator-framework/src/mutation-node/mutation-subgraph.ts @@ -0,0 +1,59 @@ +import type { MemberType, Type } from "@typespec/compiler"; +import type { MutationEngine } from "../mutation/mutation-engine.js"; +import { mutationNodeFor, type MutationNodeForType } from "./factory.js"; +import type { MutationNode } from "./mutation-node.js"; + +/** + * A subgraph of mutation nodes such that there is one node per type in the graph. + */ +export class MutationSubgraph { + #seenNodes = new Map>(); + #seenReferenceNodes = new Map>(); + + engine: MutationEngine; + + constructor(engine: MutationEngine) { + this.engine = engine; + } + + getNode(type: T, memberReferences: MemberType[] = []): MutationNodeForType { + if (memberReferences.length > 0) { + return this.getReferenceNode(memberReferences[0]!) as any; + } + + if (this.#seenNodes.has(type)) { + return this.#seenNodes.get(type)! as MutationNodeForType; + } + + const node = mutationNodeFor(this, type); + this.#seenNodes.set(type, node); + node.traverse(); + + return node; + } + + getReferenceNode(memberType: MemberType): MutationNode { + if (this.#seenReferenceNodes.has(memberType)) { + return this.#seenReferenceNodes.get(memberType)!; + } + + let referencedType: Type = memberType; + while (referencedType.kind === "ModelProperty" || referencedType.kind === "UnionVariant") { + referencedType = referencedType.type; + } + const node = mutationNodeFor(this, referencedType); + node.referenceType = memberType; + this.#seenReferenceNodes.set(memberType, node); + node.traverse(); + + return node; + } + + replaceNode(oldNode: MutationNode, newNode: MutationNode) { + this.#seenNodes.set(oldNode.sourceType, newNode); + } + + replaceReferenceNode(referenceType: MemberType, newNode: MutationNode) { + this.#seenReferenceNodes.set(referenceType, newNode); + } +} diff --git a/packages/mutator-framework/src/mutation-node/operation.ts b/packages/mutator-framework/src/mutation-node/operation.ts new file mode 100644 index 00000000000..ad60773efaa --- /dev/null +++ b/packages/mutator-framework/src/mutation-node/operation.ts @@ -0,0 +1,49 @@ +import type { Model, Operation, Type } from "@typespec/compiler"; +import { MutationEdge } from "./mutation-edge.js"; +import { MutationNode } from "./mutation-node.js"; + +export class OperationMutationNode extends MutationNode { + readonly kind = "Operation"; + + traverse() { + const parameterNode = this.subgraph.getNode(this.sourceType.parameters); + this.connectParameters(parameterNode); + + const returnTypeNode = this.subgraph.getNode(this.sourceType.returnType); + this.connectReturnType(returnTypeNode); + } + + connectParameters(baseNode: MutationNode) { + MutationEdge.create(this, baseNode, { + onTailMutation: () => { + this.mutatedType!.parameters = baseNode.mutatedType; + }, + onTailDeletion: () => { + this.mutatedType.parameters = this.$.model.create({ + name: "", + properties: {}, + }); + }, + onTailReplaced: (newTail) => { + if (newTail.mutatedType.kind !== "Model") { + throw new Error("Cannot replace parameters with non-model type"); + } + this.mutatedType.parameters = newTail.mutatedType; + }, + }); + } + + connectReturnType(typeNode: MutationNode) { + MutationEdge.create(this, typeNode, { + onTailMutation: () => { + this.mutatedType!.returnType = typeNode.mutatedType; + }, + onTailDeletion: () => { + this.mutatedType.returnType = this.$.intrinsic.void; + }, + onTailReplaced: (newTail) => { + this.mutatedType.returnType = newTail.mutatedType; + }, + }); + } +} diff --git a/packages/mutator-framework/src/mutation-node/scalar.test.ts b/packages/mutator-framework/src/mutation-node/scalar.test.ts new file mode 100644 index 00000000000..40d600b00f5 --- /dev/null +++ b/packages/mutator-framework/src/mutation-node/scalar.test.ts @@ -0,0 +1,44 @@ +import { t, type TesterInstance } from "@typespec/compiler/testing"; +import { $ } from "@typespec/compiler/typekit"; +import { beforeEach, expect, it } from "vitest"; +import { Tester } from "../../test/test-host.js"; +import { getSubgraph } from "../../test/utils.js"; +let runner: TesterInstance; +beforeEach(async () => { + runner = await Tester.createInstance(); +}); +it("handles mutation of base scalars", async () => { + const { program, Base, Derived } = await runner.compile(t.code` + scalar ${t.scalar("Base")}; + scalar ${t.scalar("Derived")} extends Base; + `); + const subgraph = getSubgraph(program); + const baseNode = subgraph.getNode(Base); + const derivedNode = subgraph.getNode(Derived); + + baseNode.mutate(); + expect(baseNode.isMutated).toBe(true); + expect(derivedNode.isMutated).toBe(true); + expect(derivedNode.mutatedType.baseScalar === baseNode.mutatedType).toBeTruthy(); +}); + +it("handles replacement of scalars", async () => { + const { program, Base, Derived } = await runner.compile(t.code` + scalar ${t.scalar("Base")}; + scalar ${t.scalar("Derived")} extends Base; + `); + const subgraph = getSubgraph(program); + const baseNode = subgraph.getNode(Base); + const derivedNode = subgraph.getNode(Derived); + + const replacedNode = baseNode.replace($(program).builtin.string); + expect(replacedNode.isMutated).toBe(true); + expect(baseNode.isReplaced).toBe(true); + + // subgraph is updated + expect(replacedNode === subgraph.getNode(Base)).toBe(true); + + // derived node is updated + expect(derivedNode.isMutated).toBe(true); + expect(derivedNode.mutatedType.baseScalar === replacedNode.sourceType).toBe(true); +}); diff --git a/packages/mutator-framework/src/mutation-node/scalar.ts b/packages/mutator-framework/src/mutation-node/scalar.ts new file mode 100644 index 00000000000..bc30e41779d --- /dev/null +++ b/packages/mutator-framework/src/mutation-node/scalar.ts @@ -0,0 +1,32 @@ +import type { Scalar } from "@typespec/compiler"; +import { MutationEdge } from "./mutation-edge.js"; +import { MutationNode } from "./mutation-node.js"; + +export class ScalarMutationNode extends MutationNode { + readonly kind = "Scalar"; + + traverse() { + if (this.sourceType.baseScalar) { + const baseScalarNode = this.subgraph.getNode(this.sourceType.baseScalar); + this.connectBaseScalar(baseScalarNode); + } + } + + connectBaseScalar(baseScalar: MutationNode) { + MutationEdge.create(this, baseScalar, { + onTailReplaced: (newTail) => { + if (!this.$.scalar.is(newTail.mutatedType)) { + throw new Error("Cannot replace base scalar with non-scalar type"); + } + + this.mutatedType.baseScalar = newTail.mutatedType; + }, + onTailMutation: () => { + this.mutatedType.baseScalar = baseScalar.mutatedType; + }, + onTailDeletion: () => { + this.mutatedType.baseScalar = undefined; + }, + }); + } +} diff --git a/packages/mutator-framework/src/mutation-node/tuple.test.ts b/packages/mutator-framework/src/mutation-node/tuple.test.ts new file mode 100644 index 00000000000..eec38073dfc --- /dev/null +++ b/packages/mutator-framework/src/mutation-node/tuple.test.ts @@ -0,0 +1,30 @@ +import type { Tuple } from "@typespec/compiler"; +import { t, type TesterInstance } from "@typespec/compiler/testing"; +import { $ } from "@typespec/compiler/typekit"; +import { beforeEach, expect, it } from "vitest"; +import { Tester } from "../../test/test-host.js"; +import { getSubgraph } from "../../test/utils.js"; +let runner: TesterInstance; +beforeEach(async () => { + runner = await Tester.createInstance(); +}); + +it("handles mutation of element types", async () => { + const { program, Foo, prop, Bar } = await runner.compile(t.code` + model ${t.model("Foo")} { + ${t.modelProperty("prop")}: [Bar, string]; + } + model ${t.model("Bar")} {} + + `); + const subgraph = getSubgraph(program); + const fooNode = subgraph.getNode(Foo); + const propNode = subgraph.getNode(prop); + const barNode = subgraph.getNode(Bar); + barNode.mutate(); + expect(barNode.isMutated).toBe(true); + expect(propNode.isMutated).toBe(true); + expect(fooNode.isMutated).toBe(true); + expect((propNode.mutatedType.type as Tuple).values[0] === barNode.mutatedType).toBeTruthy(); + expect((propNode.mutatedType.type as Tuple).values[1] === $(program).builtin.string).toBeTruthy(); +}); diff --git a/packages/mutator-framework/src/mutation-node/tuple.ts b/packages/mutator-framework/src/mutation-node/tuple.ts new file mode 100644 index 00000000000..de191b15267 --- /dev/null +++ b/packages/mutator-framework/src/mutation-node/tuple.ts @@ -0,0 +1,35 @@ +import type { Tuple, Type } from "@typespec/compiler"; +import { MutationEdge } from "./mutation-edge.js"; +import { MutationNode } from "./mutation-node.js"; + +export class TupleMutationNode extends MutationNode { + readonly kind = "Tuple"; + #indexMap: number[] = []; + + traverse() { + for (let i = 0; i < this.sourceType.values.length; i++) { + const elemType = this.sourceType.values[i]; + const elemNode = this.subgraph.getNode(elemType); + this.#indexMap[i] = i; + this.connectElement(elemNode, i); + } + } + + connectElement(elemNode: MutationNode, index: number) { + MutationEdge.create(this, elemNode, { + onTailMutation: () => { + this.mutatedType.values[this.#indexMap[index]] = elemNode.mutatedType; + }, + onTailDeletion: () => { + const spliceIndex = this.#indexMap[index]; + this.mutatedType.values.splice(spliceIndex, 1); + for (let i = spliceIndex + 1; i < this.#indexMap.length; i++) { + this.#indexMap[i]--; + } + }, + onTailReplaced: (newTail) => { + this.mutatedType.values[this.#indexMap[index]] = newTail.mutatedType; + }, + }); + } +} diff --git a/packages/mutator-framework/src/mutation-node/union-variant.test.ts b/packages/mutator-framework/src/mutation-node/union-variant.test.ts new file mode 100644 index 00000000000..a4658d7946f --- /dev/null +++ b/packages/mutator-framework/src/mutation-node/union-variant.test.ts @@ -0,0 +1,28 @@ +import { t, type TesterInstance } from "@typespec/compiler/testing"; +import { $ } from "@typespec/compiler/typekit"; +import { beforeEach, expect, it } from "vitest"; +import { Tester } from "../../test/test-host.js"; +import { getSubgraph } from "../../test/utils.js"; +let runner: TesterInstance; +beforeEach(async () => { + runner = await Tester.createInstance(); +}); + +it("handles mutation of variant types", async () => { + const { program, Foo, v1 } = await runner.compile(t.code` + union ${t.union("Foo")} { + ${t.unionVariant("v1")}: string; + v2: int32; + } + `); + + const subgraph = getSubgraph(program); + const fooNode = subgraph.getNode(Foo); + const v1Node = subgraph.getNode(v1); + const stringNode = subgraph.getNode($(program).builtin.string); + stringNode.mutate(); + expect(stringNode.isMutated).toBe(true); + expect(v1Node.isMutated).toBe(true); + expect(fooNode.isMutated).toBe(true); + expect(v1Node.mutatedType.type === stringNode.mutatedType).toBeTruthy(); +}); diff --git a/packages/mutator-framework/src/mutation-node/union-variant.ts b/packages/mutator-framework/src/mutation-node/union-variant.ts new file mode 100644 index 00000000000..6a848668c4d --- /dev/null +++ b/packages/mutator-framework/src/mutation-node/union-variant.ts @@ -0,0 +1,26 @@ +import type { Type, UnionVariant } from "@typespec/compiler"; +import { MutationEdge } from "./mutation-edge.js"; +import { MutationNode } from "./mutation-node.js"; + +export class UnionVariantMutationNode extends MutationNode { + readonly kind = "UnionVariant"; + + traverse() { + const typeNode = this.subgraph.getNode(this.sourceType.type); + this.connectType(typeNode); + } + + connectType(typeNode: MutationNode) { + MutationEdge.create(this, typeNode, { + onTailMutation: () => { + this.mutatedType.type = typeNode.mutatedType; + }, + onTailDeletion: () => { + this.mutatedType.type = this.$.intrinsic.any; + }, + onTailReplaced: (newTail) => { + this.mutatedType.type = newTail.mutatedType; + }, + }); + } +} diff --git a/packages/mutator-framework/src/mutation-node/union.test.ts b/packages/mutator-framework/src/mutation-node/union.test.ts new file mode 100644 index 00000000000..ca6c07c4f73 --- /dev/null +++ b/packages/mutator-framework/src/mutation-node/union.test.ts @@ -0,0 +1,26 @@ +import { t, type TesterInstance } from "@typespec/compiler/testing"; +import { beforeEach, expect, it } from "vitest"; +import { Tester } from "../../test/test-host.js"; +import { getSubgraph } from "../../test/utils.js"; +let runner: TesterInstance; +beforeEach(async () => { + runner = await Tester.createInstance(); +}); + +it("handles mutation of variants", async () => { + const { program, Foo, v1 } = await runner.compile(t.code` + union ${t.union("Foo")} { + ${t.unionVariant("v1")}: string; + v2: int32; + } + `); + + const subgraph = getSubgraph(program); + const fooNode = subgraph.getNode(Foo); + const v1Node = subgraph.getNode(v1); + v1Node.mutate((clone) => (clone.name = "v1Renamed")); + expect(v1Node.isMutated).toBe(true); + expect(fooNode.isMutated).toBe(true); + expect(fooNode.mutatedType.variants.get("v1") === undefined).toBeTruthy(); + expect(fooNode.mutatedType.variants.get("v1Renamed") === v1Node.mutatedType).toBeTruthy(); +}); diff --git a/packages/mutator-framework/src/mutation-node/union.ts b/packages/mutator-framework/src/mutation-node/union.ts new file mode 100644 index 00000000000..1874e20eb4a --- /dev/null +++ b/packages/mutator-framework/src/mutation-node/union.ts @@ -0,0 +1,33 @@ +import type { Union, UnionVariant } from "@typespec/compiler"; +import { MutationEdge } from "./mutation-edge.js"; +import { MutationNode } from "./mutation-node.js"; + +export class UnionMutationNode extends MutationNode { + readonly kind = "Union"; + + traverse(): void { + for (const variant of this.sourceType.variants.values()) { + const variantNode = this.subgraph.getNode(variant); + this.connectVariant(variantNode, variant.name); + } + } + + connectVariant(variantNode: MutationNode, sourcePropName: string | symbol) { + MutationEdge.create(this, variantNode, { + onTailMutation: () => { + this.mutatedType.variants.delete(sourcePropName); + this.mutatedType.variants.set(variantNode.mutatedType.name, variantNode.mutatedType); + }, + onTailDeletion: () => { + this.mutatedType.variants.delete(sourcePropName); + }, + onTailReplaced: (newTail) => { + if (newTail.mutatedType.kind !== "UnionVariant") { + throw new Error("Cannot replace union variant with non-union variant type"); + } + this.mutatedType.variants.delete(sourcePropName); + this.mutatedType.variants.set(newTail.mutatedType.name, newTail.mutatedType); + }, + }); + } +} diff --git a/packages/mutator-framework/src/mutation/index.ts b/packages/mutator-framework/src/mutation/index.ts new file mode 100644 index 00000000000..808052a7d23 --- /dev/null +++ b/packages/mutator-framework/src/mutation/index.ts @@ -0,0 +1,12 @@ +export * from "./interface.js"; +export * from "./intrinsic.js"; +export * from "./literal.js"; +export * from "./model-property.js"; +export * from "./model.js"; +export * from "./mutation-engine.js"; +export * from "./mutation.js"; +export * from "./operation.js"; +export * from "./scalar.js"; +export * from "./simple-mutation-engine.js"; +export * from "./union-variant.js"; +export * from "./union.js"; diff --git a/packages/mutator-framework/src/mutation/interface.ts b/packages/mutator-framework/src/mutation/interface.ts new file mode 100644 index 00000000000..f4770ee7239 --- /dev/null +++ b/packages/mutator-framework/src/mutation/interface.ts @@ -0,0 +1,38 @@ +import type { Interface, MemberType } from "@typespec/compiler"; +import type { + CustomMutationClasses, + MutationEngine, + MutationFor, + MutationOptions, +} from "./mutation-engine.js"; +import { Mutation } from "./mutation.js"; + +export class InterfaceMutation< + TOptions extends MutationOptions, + TCustomMutations extends CustomMutationClasses, +> extends Mutation { + readonly kind = "Interface"; + operations: Map> = new Map(); + + constructor( + engine: MutationEngine, + sourceType: Interface, + referenceTypes: MemberType[] = [], + options: TOptions, + ) { + super(engine, sourceType, referenceTypes, options); + } + + protected mutateOperations() { + for (const op of this.sourceType.operations.values()) { + this.operations.set( + op.name, + this.engine.mutate(op, this.options) as MutationFor, + ); + } + } + + mutate() { + this.mutateOperations(); + } +} diff --git a/packages/mutator-framework/src/mutation/intrinsic.ts b/packages/mutator-framework/src/mutation/intrinsic.ts new file mode 100644 index 00000000000..726bbbf9153 --- /dev/null +++ b/packages/mutator-framework/src/mutation/intrinsic.ts @@ -0,0 +1,23 @@ +import type { IntrinsicType, MemberType } from "@typespec/compiler"; +import type { CustomMutationClasses, MutationEngine, MutationOptions } from "./mutation-engine.js"; +import { Mutation } from "./mutation.js"; + +export class IntrinsicMutation< + TOptions extends MutationOptions, + TCustomMutations extends CustomMutationClasses, + TEngine extends MutationEngine = MutationEngine, +> extends Mutation { + readonly kind = "Intrinsic"; + constructor( + engine: TEngine, + sourceType: IntrinsicType, + referenceTypes: MemberType[] = [], + options: TOptions, + ) { + super(engine, sourceType, referenceTypes, options); + } + + mutate() { + // No mutations needed for intrinsic types + } +} diff --git a/packages/mutator-framework/src/mutation/literal.ts b/packages/mutator-framework/src/mutation/literal.ts new file mode 100644 index 00000000000..15cd3f53312 --- /dev/null +++ b/packages/mutator-framework/src/mutation/literal.ts @@ -0,0 +1,29 @@ +import type { BooleanLiteral, MemberType, NumericLiteral, StringLiteral } from "@typespec/compiler"; +import type { CustomMutationClasses, MutationEngine, MutationOptions } from "./mutation-engine.js"; +import { Mutation } from "./mutation.js"; + +export class LiteralMutation< + TOptions extends MutationOptions, + TCustomMutations extends CustomMutationClasses, + TEngine extends MutationEngine = MutationEngine, +> extends Mutation< + StringLiteral | NumericLiteral | BooleanLiteral, + TCustomMutations, + TOptions, + TEngine +> { + readonly kind = "Literal"; + + constructor( + engine: TEngine, + sourceType: StringLiteral | NumericLiteral | BooleanLiteral, + referenceTypes: MemberType[] = [], + options: TOptions, + ) { + super(engine, sourceType, referenceTypes, options); + } + + mutate() { + // No mutations needed for literal types + } +} diff --git a/packages/mutator-framework/src/mutation/model-property.ts b/packages/mutator-framework/src/mutation/model-property.ts new file mode 100644 index 00000000000..607c976f89a --- /dev/null +++ b/packages/mutator-framework/src/mutation/model-property.ts @@ -0,0 +1,35 @@ +import type { ModelProperty, Type } from "@typespec/compiler"; +import type { MutationSubgraph } from "../mutation-node/mutation-subgraph.js"; +import type { + CustomMutationClasses, + MutationEngine, + MutationFor, + MutationOptions, +} from "./mutation-engine.js"; +import { Mutation } from "./mutation.js"; + +export class ModelPropertyMutation< + TOptions extends MutationOptions, + TCustomMutations extends CustomMutationClasses, + TEngine extends MutationEngine = MutationEngine, +> extends Mutation { + readonly kind = "ModelProperty"; + type!: MutationFor; + + mutate() { + this.type = this.engine.mutateReference(this.sourceType, this.options); + } + + getReferenceMutationNode( + subgraph: MutationSubgraph = this.engine.getDefaultMutationSubgraph(this.options), + ) { + return subgraph.getReferenceNode(this.sourceType); + } + + replaceReferencedType(subgraph: MutationSubgraph, newType: Type) { + // First, update the mutation node + subgraph.getReferenceNode(this.sourceType).replace(newType); + // then return a new reference mutation for the new type + return this.engine.mutateReference(this.sourceType, newType, this.options); + } +} diff --git a/packages/mutator-framework/src/mutation/model.ts b/packages/mutator-framework/src/mutation/model.ts new file mode 100644 index 00000000000..7cd7311b500 --- /dev/null +++ b/packages/mutator-framework/src/mutation/model.ts @@ -0,0 +1,58 @@ +import type { MemberType, Model, Type } from "@typespec/compiler"; +import type { + CustomMutationClasses, + MutationEngine, + MutationFor, + MutationOptions, +} from "./mutation-engine.js"; +import { Mutation } from "./mutation.js"; + +export class ModelMutation< + TOptions extends MutationOptions, + TCustomMutations extends CustomMutationClasses, + TEngine extends MutationEngine = MutationEngine, +> extends Mutation { + readonly kind = "Model"; + baseModel?: MutationFor; + properties: Map> = new Map(); + indexer?: { + key: MutationFor; + value: MutationFor; + }; + + constructor( + engine: TEngine, + sourceType: Model, + referenceTypes: MemberType[] = [], + options: TOptions, + ) { + super(engine, sourceType, referenceTypes, options); + } + + protected mutateBaseModel() { + if (this.sourceType.baseModel) { + this.baseModel = this.engine.mutate(this.sourceType.baseModel, this.options); + } + } + + protected mutateProperties() { + for (const prop of this.sourceType.properties.values()) { + this.properties.set(prop.name, this.engine.mutate(prop, this.options)); + } + } + + protected mutateIndexer() { + if (this.sourceType.indexer) { + this.indexer = { + key: this.engine.mutate(this.sourceType.indexer.key, this.options), + value: this.engine.mutate(this.sourceType.indexer.value, this.options), + }; + } + } + + mutate() { + this.mutateBaseModel(); + this.mutateProperties(); + this.mutateIndexer(); + } +} diff --git a/packages/mutator-framework/src/mutation/mutation-engine.test.ts b/packages/mutator-framework/src/mutation/mutation-engine.test.ts new file mode 100644 index 00000000000..1f49d4a82a7 --- /dev/null +++ b/packages/mutator-framework/src/mutation/mutation-engine.test.ts @@ -0,0 +1,202 @@ +import type { Model } from "@typespec/compiler"; +import { t } from "@typespec/compiler/testing"; +import { $, type Typekit } from "@typespec/compiler/typekit"; +import { expect, it } from "vitest"; +import { Tester } from "../../test/test-host.js"; +import type { MutationSubgraph } from "../mutation-node/mutation-subgraph.js"; +import { ModelPropertyMutation } from "./model-property.js"; +import { ModelMutation } from "./model.js"; +import { MutationEngine, MutationOptions } from "./mutation-engine.js"; +import { SimpleMutationEngine } from "./simple-mutation-engine.js"; + +class RenameMutationOptions extends MutationOptions { + prefix: string; + suffix: string; + + constructor(prefix: string, suffix: string) { + super(); + this.prefix = prefix; + this.suffix = suffix; + } + + cacheKey() { + return `${this.prefix}-${this.suffix}`; + } +} + +class RenameMutationEngine extends MutationEngine { + constructor($: Typekit) { + super($, { + Model: RenameModelMutation, + }); + this.registerSubgraph("prefix"); + this.registerSubgraph("suffix"); + } + + getPrefixSubgraph(options: RenameMutationOptions): MutationSubgraph { + return this.getMutationSubgraph(options, "prefix"); + } + + getSuffixSubgraph(options: RenameMutationOptions): MutationSubgraph { + return this.getMutationSubgraph(options, "suffix"); + } +} + +interface RenameMutationClasses { + Model: RenameModelMutation; +} + +class RenameModelMutation extends ModelMutation< + RenameMutationOptions, + RenameMutationClasses, + RenameMutationEngine +> { + get #prefixSubgraph() { + return this.engine.getPrefixSubgraph(this.options); + } + + get #suffixSubgraph() { + return this.engine.getSuffixSubgraph(this.options); + } + + get withPrefix() { + return this.getMutatedType(this.#prefixSubgraph); + } + + get withSuffix() { + return this.getMutatedType(this.#suffixSubgraph); + } + + mutate() { + if ("name" in this.sourceType && typeof this.sourceType.name === "string") { + this.mutateType( + this.#prefixSubgraph, + (m) => (m.name = `${this.options.prefix}${this.sourceType.name}`), + ); + this.mutateType( + this.#suffixSubgraph, + (m) => (m.name = `${this.sourceType.name}${this.options.suffix}`), + ); + } + + // mutate all connected types passing on the same options + super.mutate(); + } +} +it("creates mutations", async () => { + const runner = await Tester.createInstance(); + const { Foo, Bar, prop, program } = await runner.compile(t.code` + model ${t.model("Foo")} { + ${t.modelProperty("prop")}: Bar; + } + + model ${t.model("Bar")} { + prop: string; + } + `); + + const tk = $(program); + const engine = new RenameMutationEngine(tk); + const options = new RenameMutationOptions("Pre", "Suf"); + const fooMutation = engine.mutate(Foo, options); + + // can navigate the mutation result to get prefix and suffix names side-by-side + expect(fooMutation.properties.size).toBe(1); + + const barMutation = fooMutation.properties.get("prop")!.type as RenameModelMutation; + expect(barMutation.withPrefix.name).toBe("PreBar"); + + // Or you could get barMutation like: + const barMutation2 = engine.mutate(Bar, options); + + // but these are not the same mutation node because the mutation accessed via + // the property is a distinct from the one accessed from the scalar. + expect(barMutation === barMutation2).toBe(false); + expect(barMutation.referenceTypes.length).toEqual(1); + expect(barMutation.referenceTypes[0] === prop).toBe(true); + expect(barMutation2.referenceTypes.length).toEqual(0); + + // The graph is mutated + const prefixModel = fooMutation.withPrefix; + const suffixModel = fooMutation.withSuffix; + + expect(prefixModel.name).toBe("PreFoo"); + expect((prefixModel.properties.get("prop")!.type as Model).name).toBe("PreBar"); + expect(suffixModel.name).toBe("FooSuf"); + expect((suffixModel.properties.get("prop")!.type as Model).name).toBe("BarSuf"); +}); + +interface UnionifyMutations { + Model: UnionifyModel; + ModelProperty: UnionifyProperty; +} + +class UnionifyModel extends ModelMutation< + MutationOptions, + UnionifyMutations, + SimpleMutationEngine +> { + get unionified() { + return this.getMutatedType(); + } +} + +class UnionifyProperty extends ModelPropertyMutation< + MutationOptions, + UnionifyMutations, + SimpleMutationEngine +> { + get unionified() { + return this.getMutatedType(); + } + + mutate() { + if (!this.engine.$.union.is(this.sourceType.type)) { + // turn it into this union: + const newUnionType = this.engine.$.union.create({ + variants: [ + this.engine.$.unionVariant.create({ type: this.sourceType.type }), + this.engine.$.unionVariant.create({ + type: this.engine.$.builtin.string, + }), + ], + }); + + this.type = this.replaceReferencedType( + this.engine.getDefaultMutationSubgraph(this.options), + newUnionType, + ); + } else { + super.mutate(); + } + } +} + +it.only("mutates model properties into unions", async () => { + const runner = await Tester.createInstance(); + const { Foo, program } = await runner.compile(t.code` + model ${t.model("Foo")} { + ${t.modelProperty("prop")}: Bar; + } + + model ${t.model("Bar")} { + barProp: string; + } + `); + + const tk = $(program); + const engine = new SimpleMutationEngine(tk, { + ModelProperty: UnionifyProperty, + Model: UnionifyModel, + }); + + const fooMutation = engine.mutate(Foo); + const propMutation = fooMutation.properties.get("prop")!; + const typeMutation = propMutation.type as UnionifyModel; + expect(typeMutation.kind).toBe("Union"); + const propType = propMutation.unionified; + expect(tk.union.is(propType.type)).toBe(true); + + const mutatedFoo = fooMutation.unionified; + expect(tk.union.is(mutatedFoo.properties.get("prop")!.type)).toBe(true); +}); diff --git a/packages/mutator-framework/src/mutation/mutation-engine.ts b/packages/mutator-framework/src/mutation/mutation-engine.ts new file mode 100644 index 00000000000..0664d1e5067 --- /dev/null +++ b/packages/mutator-framework/src/mutation/mutation-engine.ts @@ -0,0 +1,288 @@ +import type { MemberType, Type } from "@typespec/compiler"; +import type { Typekit } from "@typespec/compiler/typekit"; +import type { MutationNodeForType } from "../mutation-node/factory.js"; +import { MutationSubgraph } from "../mutation-node/mutation-subgraph.js"; +import { InterfaceMutation } from "./interface.js"; +import { IntrinsicMutation } from "./intrinsic.js"; +import { LiteralMutation } from "./literal.js"; +import { ModelPropertyMutation } from "./model-property.js"; +import { ModelMutation } from "./model.js"; +import { Mutation } from "./mutation.js"; +import { OperationMutation } from "./operation.js"; +import { ScalarMutation } from "./scalar.js"; +import { UnionVariantMutation } from "./union-variant.js"; +import { UnionMutation } from "./union.js"; + +export type MutationRegistry = Record>; + +export interface DefaultMutationClasses + extends MutationRegistry { + Operation: OperationMutation; + Interface: InterfaceMutation; + Model: ModelMutation; + Scalar: ScalarMutation; + ModelProperty: ModelPropertyMutation; + Union: UnionMutation; + UnionVariant: UnionVariantMutation; + String: LiteralMutation; + Number: LiteralMutation; + Boolean: LiteralMutation; + Intrinsic: IntrinsicMutation; +} + +export type CustomMutationClasses = Partial; + +export type WithDefaultMutations = + TCustomMutationClasses & DefaultMutationClasses; + +export type MutationFor< + TCustomMutations extends CustomMutationClasses, + TKind extends Type["kind"] = Type["kind"], +> = WithDefaultMutations[TKind]; + +export type Constructor = new (...args: any[]) => T; +export type ConstructorsFor = { [K in keyof T]: Constructor }; +export type InstancesFor any>> = { + [K in keyof T]: InstanceType; +}; + +export class MutationEngine { + $: Typekit; + + // Map of Type -> (Map of options.cacheKey() -> Mutation) + #mutationCache = new Map>>(); + + // Map of MemberType -> (Map of options.cacheKey() -> Mutation) + #referenceMutationCache = new Map>>(); + + #subgraphNames = new Set(); + + // Map of subgraph names -> (Map of options.cacheKey() -> MutationSubgraph) + #subgraphs = new Map>(); + + #mutatorClasses: MutationRegistry; + + constructor($: Typekit, mutatorClasses: ConstructorsFor) { + this.$ = $; + this.#mutatorClasses = { + Operation: mutatorClasses.Operation ?? OperationMutation, + Interface: mutatorClasses.Interface ?? InterfaceMutation, + Model: mutatorClasses.Model ?? ModelMutation, + Scalar: mutatorClasses.Scalar ?? ScalarMutation, + ModelProperty: mutatorClasses.ModelProperty ?? ModelPropertyMutation, + Union: mutatorClasses.Union ?? UnionMutation, + UnionVariant: mutatorClasses.UnionVariant ?? UnionVariantMutation, + String: mutatorClasses.String ?? LiteralMutation, + Number: mutatorClasses.Number ?? LiteralMutation, + Boolean: mutatorClasses.Boolean ?? LiteralMutation, + Intrinsic: mutatorClasses.Intrinsic ?? IntrinsicMutation, + } as any; + } + + protected registerSubgraph(name: string) { + this.#subgraphNames.add(name); + } + + protected getMutationSubgraph(options: MutationOptions, name?: string) { + const optionsKey = options?.cacheKey() ?? "default"; + if (!this.#subgraphs.has(optionsKey)) { + this.#subgraphs.set(optionsKey, new Map()); + } + const subgraphsForOptions = this.#subgraphs.get(optionsKey)!; + + name = name ?? "default"; + if (!subgraphsForOptions.has(name)) { + subgraphsForOptions.set(name, new MutationSubgraph(this)); + } + + return subgraphsForOptions.get(name)!; + } + + getDefaultMutationSubgraph(options?: MutationOptions): MutationSubgraph { + throw new Error("This mutation engine does not provide a default mutation subgraph."); + } + + /** + * Retrieve the mutated type from the default mutation subgraph for the given options. + */ + getMutatedType(options: MutationOptions, sourceType: T): T; + /** + * Retrieve the mutated type from a specific mutation subgraph. + */ + getMutatedType(subgraph: MutationSubgraph, sourceType: T): T; + /** + * Retrieve the mutated type from either the default subgraph with the given + * options or a specific subgraph. + */ + getMutatedType( + subgraphOrOptions: MutationOptions | MutationSubgraph, + sourceType: T, + ): T; + getMutatedType( + subgraphOrOptions: MutationOptions | MutationSubgraph, + sourceType: T, + ) { + if (subgraphOrOptions instanceof MutationOptions) { + return this.getMutationNode(subgraphOrOptions, sourceType).mutatedType; + } + return this.getMutationNode(subgraphOrOptions, sourceType).mutatedType; + } + + /** + * Get (and potentially create) the mutation node for the provided type in the default subgraph. + */ + getMutationNode(options: MutationOptions, type: T): MutationNodeForType; + /** + * Get (and potentially create) the mutation node for the provided type in a specific subgraph. + */ + getMutationNode(subgraph: MutationSubgraph, type: T): MutationNodeForType; + + /** + * Get (and potentially create) the mutation node for the provided type in + * either the default subgraph with the given options or a specific subgraph. + */ + getMutationNode( + subgraphOrOptions: MutationOptions | MutationSubgraph, + type: T, + ): MutationNodeForType; + getMutationNode(subgraphOrOptions: MutationOptions | MutationSubgraph, type: T) { + let subgraph: MutationSubgraph; + if (subgraphOrOptions instanceof MutationOptions) { + subgraph = this.getDefaultMutationSubgraph(subgraphOrOptions); + } else { + subgraph = subgraphOrOptions; + } + return subgraph.getNode(type); + } + + mutateType( + subgraphOrOptions: MutationOptions | MutationSubgraph, + type: T, + initializeMutation: (type: T) => void, + ) { + const subgraph = this.#getSubgraphFromOptions(subgraphOrOptions); + this.getMutationNode(subgraph, type).mutate(initializeMutation as (type: Type) => void); + } + + #getSubgraphFromOptions(subgraphOrOptions: MutationOptions | MutationSubgraph) { + if (subgraphOrOptions instanceof MutationOptions) { + return this.getDefaultMutationSubgraph(subgraphOrOptions); + } else { + return subgraphOrOptions; + } + } + + mutate( + type: TType, + options: MutationOptions = new MutationOptions(), + ): MutationFor { + if (!this.#mutationCache.has(type)) { + this.#mutationCache.set(type, new Map>()); + } + + const byType = this.#mutationCache.get(type)!; + const key = options.cacheKey(); + if (byType.has(key)) { + const existing = byType.get(key)! as any; + if (!existing.isMutated) { + existing.isMutated = true; + existing.mutate(); + } + return existing; + } + + this.#initializeSubgraphs(type, options); + + const mutatorClass = this.#mutatorClasses[type.kind]; + if (!mutatorClass) { + throw new Error("No mutator registered for type kind: " + type.kind); + } + + // TS doesn't like this abstract class here, but it will be a derivative + // class in practice. + const mutation = new (mutatorClass as any)(this, type, [], options); + + byType.set(key, mutation); + mutation.isMutated = true; + mutation.mutate(); + return mutation; + } + + mutateReference( + memberType: TType, + referencedMutationNode: Type, + options: MutationOptions, + ): MutationFor; + mutateReference( + memberType: TType, + options: MutationOptions, + ): MutationFor; + mutateReference( + memberType: TType, + referencedMutationNodeOrOptions: Type | MutationOptions, + options?: MutationOptions, + ): MutationFor { + let referencedMutationNode: Type | undefined; + let finalOptions: MutationOptions; + if (referencedMutationNodeOrOptions instanceof MutationOptions) { + finalOptions = referencedMutationNodeOrOptions; + referencedMutationNode = undefined; + } else { + referencedMutationNode = referencedMutationNodeOrOptions as Type; + finalOptions = options!; + } + + if (!this.#referenceMutationCache.has(memberType)) { + this.#referenceMutationCache.set( + memberType, + new Map>(), + ); + } + + const byType = this.#referenceMutationCache.get(memberType)!; + const key = finalOptions.cacheKey(); + if (byType.has(key)) { + const existing = byType.get(key)! as any; + if (!existing.isMutated) { + existing.isMutated = true; + existing.mutate(); + } + return existing; + } + + this.#initializeSubgraphs(memberType, finalOptions); + const sources: MemberType[] = []; + + let referencedType: Type = memberType; + while (referencedType.kind === "ModelProperty" || referencedType.kind === "UnionVariant") { + sources.push(referencedType); + referencedType = referencedType.type; + } + + const typeToMutate = referencedMutationNode ?? referencedType; + const mutatorClass = this.#mutatorClasses[typeToMutate.kind]; + if (!mutatorClass) { + throw new Error("No mutator registered for type kind: " + typeToMutate.kind); + } + + const mutation = new (mutatorClass as any)(this, typeToMutate, sources, finalOptions); + + byType.set(key, mutation); + mutation.isMutated = true; + mutation.mutate(); + return mutation; + } + + #initializeSubgraphs(root: Type, options: MutationOptions) { + for (const name of this.#subgraphNames) { + const subgraph = this.getMutationSubgraph(options, name); + subgraph.getNode(root); + } + } +} + +export class MutationOptions { + cacheKey(): string { + return ""; + } +} diff --git a/packages/mutator-framework/src/mutation/mutation.ts b/packages/mutator-framework/src/mutation/mutation.ts new file mode 100644 index 00000000000..40b77243682 --- /dev/null +++ b/packages/mutator-framework/src/mutation/mutation.ts @@ -0,0 +1,90 @@ +import type { MemberType, Type } from "@typespec/compiler"; +import type { MutationNodeForType } from "../mutation-node/factory.js"; +import type { MutationSubgraph } from "../mutation-node/mutation-subgraph.js"; +import type { CustomMutationClasses, MutationEngine, MutationOptions } from "./mutation-engine.js"; + +export abstract class Mutation< + TSourceType extends Type, + TCustomMutations extends CustomMutationClasses, + TOptions extends MutationOptions = MutationOptions, + TEngine extends MutationEngine = MutationEngine, +> { + abstract readonly kind: string; + + static readonly subgraphNames: string[] = []; + + engine: TEngine; + sourceType: TSourceType; + options: TOptions; + isMutated: boolean = false; + referenceTypes: MemberType[]; + + constructor( + engine: TEngine, + sourceType: TSourceType, + referenceTypes: MemberType[], + options: TOptions, + ) { + this.engine = engine; + this.sourceType = sourceType; + this.options = options; + this.referenceTypes = referenceTypes; + } + + abstract mutate(): void; + + /** + * Retrieve the mutated type for this mutation's default subgraph. + */ + protected getMutatedType(): TSourceType; + /** + * Retrieve the mutated type for the provided subgraph. + */ + protected getMutatedType(subgraph: MutationSubgraph): TSourceType; + protected getMutatedType(subgraphOrOptions?: MutationSubgraph | MutationOptions) { + return this.engine.getMutatedType(subgraphOrOptions ?? this.options, this.sourceType); + } + + /** + * Retrieve the mutation node for this mutation's default subgraph. + */ + protected getMutationNode(): MutationNodeForType; + /** + * Retrieve the mutation node for the provided subgraph. + */ + protected getMutationNode(subgraph: MutationSubgraph): MutationNodeForType; + /** + * Retrieve the mutation node for either the default subgraph with the given + * options or a specific subgraph. + */ + protected getMutationNode( + subgraphOrOptions: MutationSubgraph | MutationOptions, + ): MutationNodeForType; + protected getMutationNode(subgraphOrOptions?: MutationSubgraph | MutationOptions) { + return this.engine.getMutationNode(subgraphOrOptions ?? this.options, this.sourceType); + } + + /** + * Mutate this type in the default subgraph. + */ + protected mutateType(initializeMutation?: (type: TSourceType) => void): void; + /** + * Mutate this type in the given subgraph + */ + protected mutateType( + subgraph: MutationSubgraph, + initializeMutation?: (type: TSourceType) => void, + ): void; + + protected mutateType( + subgraphOrInit?: MutationSubgraph | ((type: TSourceType) => void), + initializeMutation?: (type: TSourceType) => void, + ) { + if (typeof subgraphOrInit === "function") { + initializeMutation = subgraphOrInit; + subgraphOrInit = undefined; + } + const node = this.getMutationNode(subgraphOrInit ?? this.options); + node.mutate(initializeMutation as (type: Type) => void); + } +} diff --git a/packages/mutator-framework/src/mutation/operation.ts b/packages/mutator-framework/src/mutation/operation.ts new file mode 100644 index 00000000000..06b102da4ca --- /dev/null +++ b/packages/mutator-framework/src/mutation/operation.ts @@ -0,0 +1,40 @@ +import type { MemberType, Operation, Type } from "@typespec/compiler"; +import type { + CustomMutationClasses, + MutationEngine, + MutationFor, + MutationOptions, +} from "./mutation-engine.js"; +import { Mutation } from "./mutation.js"; + +export class OperationMutation< + TOptions extends MutationOptions, + TCustomMutations extends CustomMutationClasses, + TEngine extends MutationEngine = MutationEngine, +> extends Mutation { + readonly kind = "Operation"; + parameters!: MutationFor; + returnType!: MutationFor; + + constructor( + engine: TEngine, + sourceType: Operation, + referenceTypes: MemberType[] = [], + options: TOptions, + ) { + super(engine, sourceType, referenceTypes, options); + } + + protected mutateParameters() { + this.parameters = this.engine.mutate(this.sourceType.parameters, this.options); + } + + protected mutateReturnType() { + this.returnType = this.engine.mutate(this.sourceType.returnType, this.options); + } + + mutate() { + this.mutateParameters(); + this.mutateReturnType(); + } +} diff --git a/packages/mutator-framework/src/mutation/scalar.ts b/packages/mutator-framework/src/mutation/scalar.ts new file mode 100644 index 00000000000..01ec5c65499 --- /dev/null +++ b/packages/mutator-framework/src/mutation/scalar.ts @@ -0,0 +1,36 @@ +import type { MemberType, Scalar } from "@typespec/compiler"; +import type { + CustomMutationClasses, + MutationEngine, + MutationFor, + MutationOptions, +} from "./mutation-engine.js"; +import { Mutation } from "./mutation.js"; + +export class ScalarMutation< + TOptions extends MutationOptions, + TCustomMutations extends CustomMutationClasses, + TEngine extends MutationEngine = MutationEngine, +> extends Mutation { + readonly kind = "Scalar"; + baseScalar?: MutationFor; + + constructor( + engine: TEngine, + sourceType: Scalar, + referenceTypes: MemberType[] = [], + options: TOptions, + ) { + super(engine, sourceType, referenceTypes, options); + } + + protected mutateBaseScalar() { + if (this.sourceType.baseScalar) { + this.baseScalar = this.engine.mutate(this.sourceType.baseScalar, this.options); + } + } + + mutate() { + this.mutateBaseScalar(); + } +} diff --git a/packages/mutator-framework/src/mutation/simple-mutation-engine.ts b/packages/mutator-framework/src/mutation/simple-mutation-engine.ts new file mode 100644 index 00000000000..9ed19bdc51e --- /dev/null +++ b/packages/mutator-framework/src/mutation/simple-mutation-engine.ts @@ -0,0 +1,21 @@ +import type { Typekit } from "@typespec/compiler/typekit"; +import type { MutationSubgraph } from "../mutation-node/mutation-subgraph.js"; +import { + type ConstructorsFor, + type CustomMutationClasses, + MutationEngine, + MutationOptions, +} from "./mutation-engine.js"; + +export class SimpleMutationEngine< + TCustomMutations extends CustomMutationClasses, +> extends MutationEngine { + constructor($: Typekit, mutatorClasses: ConstructorsFor) { + super($, mutatorClasses); + this.registerSubgraph("subgraph"); + } + + getDefaultMutationSubgraph(options: MutationOptions): MutationSubgraph { + return super.getMutationSubgraph(options, "subgraph"); + } +} diff --git a/packages/mutator-framework/src/mutation/union-variant.ts b/packages/mutator-framework/src/mutation/union-variant.ts new file mode 100644 index 00000000000..e542d9aad14 --- /dev/null +++ b/packages/mutator-framework/src/mutation/union-variant.ts @@ -0,0 +1,30 @@ +import type { MemberType, Type, UnionVariant } from "@typespec/compiler"; +import type { + CustomMutationClasses, + MutationEngine, + MutationFor, + MutationOptions, +} from "./mutation-engine.js"; +import { Mutation } from "./mutation.js"; + +export class UnionVariantMutation< + TOptions extends MutationOptions, + TCustomMutations extends CustomMutationClasses, + TEngine extends MutationEngine = MutationEngine, +> extends Mutation { + readonly kind = "UnionVariant"; + type!: MutationFor; + + constructor( + engine: TEngine, + sourceType: UnionVariant, + referenceTypes: MemberType[] = [], + options: TOptions, + ) { + super(engine, sourceType, referenceTypes, options); + } + + mutate(): void { + this.type = this.engine.mutate(this.sourceType.type, this.options); + } +} diff --git a/packages/mutator-framework/src/mutation/union.ts b/packages/mutator-framework/src/mutation/union.ts new file mode 100644 index 00000000000..2a79d29cd5b --- /dev/null +++ b/packages/mutator-framework/src/mutation/union.ts @@ -0,0 +1,39 @@ +import type { MemberType, Union } from "@typespec/compiler"; +import type { + CustomMutationClasses, + MutationEngine, + MutationFor, + MutationOptions, +} from "./mutation-engine.js"; +import { Mutation } from "./mutation.js"; + +export class UnionMutation< + TOptions extends MutationOptions, + TCustomMutations extends CustomMutationClasses, + TEngine extends MutationEngine = MutationEngine, +> extends Mutation { + readonly kind = "Union"; + variants: Map> = new Map(); + + constructor( + engine: TEngine, + sourceType: Union, + referenceTypes: MemberType[] = [], + options: TOptions, + ) { + super(engine, sourceType, referenceTypes, options); + } + + protected mutateVariants() { + this.variants = new Map( + [...this.sourceType.variants].map(([name, variant]) => [ + name, + this.engine.mutate(variant, this.options), + ]), + ); + } + + mutate() { + this.mutateVariants(); + } +} diff --git a/packages/mutator-framework/test/test-host.ts b/packages/mutator-framework/test/test-host.ts new file mode 100644 index 00000000000..5223601fac9 --- /dev/null +++ b/packages/mutator-framework/test/test-host.ts @@ -0,0 +1,6 @@ +import { resolvePath } from "@typespec/compiler"; +import { createTester } from "@typespec/compiler/testing"; + +export const Tester = createTester(resolvePath((import.meta as any).dirname, ".."), { + libraries: [], +}); diff --git a/packages/mutator-framework/test/utils.ts b/packages/mutator-framework/test/utils.ts new file mode 100644 index 00000000000..1feba3fd7ff --- /dev/null +++ b/packages/mutator-framework/test/utils.ts @@ -0,0 +1,9 @@ +import type { Program } from "@typespec/compiler"; +import { $ } from "@typespec/compiler/typekit"; +import { MutationEngine, MutationSubgraph } from "../src/index.js"; + +export function getSubgraph(program: Program) { + const tk = $(program); + const engine = new MutationEngine(tk, {}); + return new MutationSubgraph(engine); +} diff --git a/packages/mutator-framework/tsconfig.json b/packages/mutator-framework/tsconfig.json new file mode 100644 index 00000000000..6c2a9ca11db --- /dev/null +++ b/packages/mutator-framework/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "lib": ["es2023", "DOM"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "target": "es2022", + "strict": true, + "skipLibCheck": true, + "isolatedModules": true, + "declaration": true, + "sourceMap": true, + "declarationMap": true, + "outDir": "dist", + "rootDir": "./", + "verbatimModuleSyntax": true, + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "test/**/*.ts", "test/**/*.tsx"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/mutator-framework/vitest.config.ts b/packages/mutator-framework/vitest.config.ts new file mode 100644 index 00000000000..63cad767f57 --- /dev/null +++ b/packages/mutator-framework/vitest.config.ts @@ -0,0 +1,4 @@ +import { defineConfig, mergeConfig } from "vitest/config"; +import { defaultTypeSpecVitestConfig } from "../../vitest.config.js"; + +export default mergeConfig(defaultTypeSpecVitestConfig, defineConfig({})); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 19ed15825d1..d03e5e341ff 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -655,6 +655,28 @@ importers: specifier: ^3.1.2 version: 3.2.4(@types/debug@4.1.12)(@types/node@24.3.1)(@vitest/ui@3.2.4)(happy-dom@18.0.1)(jsdom@25.0.1)(tsx@4.20.5)(yaml@2.8.1) + packages/http-canonicalization: + dependencies: + '@typespec/http': + specifier: workspace:^ + version: link:../http + '@typespec/mutator-framework': + specifier: workspace:^ + version: link:../mutator-framework + devDependencies: + '@types/node': + specifier: ~24.3.0 + version: 24.3.1 + '@typespec/compiler': + specifier: workspace:^ + version: link:../compiler + concurrently: + specifier: ^9.1.2 + version: 9.2.1 + prettier: + specifier: ~3.6.2 + version: 3.6.2 + packages/http-client: devDependencies: '@alloy-js/cli': @@ -1205,6 +1227,25 @@ importers: specifier: ^3.1.2 version: 3.2.4(@types/debug@4.1.12)(@types/node@24.3.1)(@vitest/ui@3.2.4)(happy-dom@18.0.1)(jsdom@25.0.1)(tsx@4.20.5)(yaml@2.8.1) + packages/mutator-framework: + dependencies: + '@typespec/http': + specifier: workspace:^ + version: link:../http + devDependencies: + '@types/node': + specifier: ~24.3.0 + version: 24.3.1 + '@typespec/compiler': + specifier: workspace:^ + version: link:../compiler + concurrently: + specifier: ^9.1.2 + version: 9.2.1 + prettier: + specifier: ~3.6.2 + version: 3.6.2 + packages/openapi: devDependencies: '@types/node': @@ -6015,9 +6056,6 @@ packages: '@types/node@17.0.45': resolution: {integrity: sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw==} - '@types/node@18.19.124': - resolution: {integrity: sha512-hY4YWZFLs3ku6D2Gqo3RchTd9VRCcrjqp/I0mmohYeUVA5Y8eCXKJEasHxLAJVZRJuQogfd1GiJ9lgogBgKeuQ==} - '@types/node@18.19.127': resolution: {integrity: sha512-gSjxjrnKXML/yo0BO099uPixMqfpJU0TKYjpfLU7TrtA2WWDki412Np/RSTPRil1saKBhvVVKzVx/p/6p94nVA==} @@ -18353,10 +18391,6 @@ snapshots: '@types/node@17.0.45': {} - '@types/node@18.19.124': - dependencies: - undici-types: 5.26.5 - '@types/node@18.19.127': dependencies: undici-types: 5.26.5 @@ -19337,7 +19371,7 @@ snapshots: '@yarnpkg/pnp@4.1.1': dependencies: - '@types/node': 18.19.124 + '@types/node': 18.19.127 '@yarnpkg/fslib': 3.1.2 '@yarnpkg/pnp@4.1.2': From b420782876005ede857d21cf2cdd95683d6bc3f8 Mon Sep 17 00:00:00 2001 From: Brian Terlson Date: Sat, 25 Oct 2025 11:13:34 -0700 Subject: [PATCH 2/3] Update lock file --- pnpm-lock.yaml | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d03e5e341ff..6ee91a594bb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -657,6 +657,9 @@ importers: packages/http-canonicalization: dependencies: + '@typespec/compiler': + specifier: workspace:^ + version: link:../compiler '@typespec/http': specifier: workspace:^ version: link:../http @@ -667,9 +670,6 @@ importers: '@types/node': specifier: ~24.3.0 version: 24.3.1 - '@typespec/compiler': - specifier: workspace:^ - version: link:../compiler concurrently: specifier: ^9.1.2 version: 9.2.1 @@ -1228,10 +1228,6 @@ importers: version: 3.2.4(@types/debug@4.1.12)(@types/node@24.3.1)(@vitest/ui@3.2.4)(happy-dom@18.0.1)(jsdom@25.0.1)(tsx@4.20.5)(yaml@2.8.1) packages/mutator-framework: - dependencies: - '@typespec/http': - specifier: workspace:^ - version: link:../http devDependencies: '@types/node': specifier: ~24.3.0 @@ -19153,7 +19149,7 @@ snapshots: algoliasearch: 4.25.2 clipanion: 4.0.0-rc.4(typanion@3.14.0) diff: 5.2.0 - ink: 3.2.0(@types/react@18.3.24)(react@18.3.1) + ink: 3.2.0(@types/react@18.3.24)(react@17.0.2) ink-text-input: 4.0.3(ink@3.2.0(@types/react@18.3.24)(react@17.0.2))(react@17.0.2) react: 17.0.2 semver: 7.7.2 @@ -19346,7 +19342,7 @@ snapshots: '@yarnpkg/plugin-git': 3.1.3(@yarnpkg/core@4.4.3(typanion@3.14.0))(typanion@3.14.0) clipanion: 4.0.0-rc.4(typanion@3.14.0) es-toolkit: 1.39.10 - ink: 3.2.0(@types/react@18.3.24)(react@17.0.2) + ink: 3.2.0(@types/react@18.3.24)(react@18.3.1) react: 17.0.2 semver: 7.7.2 tslib: 2.8.1 From 558a9de9ffa4c273704db9ba0f62a32c8889c1e8 Mon Sep 17 00:00:00 2001 From: Brian Terlson Date: Sat, 25 Oct 2025 11:29:13 -0700 Subject: [PATCH 3/3] Cleanup --- .chronus/changes/canon-2025-9-25-11-28-12.md | 7 +++++++ .chronus/changes/canon-2025-9-25-11-28-30.md | 7 +++++++ cspell.yaml | 4 ++++ packages/http-canonicalization/tsconfig.json | 2 +- .../src/mutation-node/model-property.test.ts | 2 +- .../mutator-framework/src/mutation/mutation-engine.test.ts | 2 +- packages/mutator-framework/tsconfig.json | 2 +- 7 files changed, 22 insertions(+), 4 deletions(-) create mode 100644 .chronus/changes/canon-2025-9-25-11-28-12.md create mode 100644 .chronus/changes/canon-2025-9-25-11-28-30.md diff --git a/.chronus/changes/canon-2025-9-25-11-28-12.md b/.chronus/changes/canon-2025-9-25-11-28-12.md new file mode 100644 index 00000000000..7d157f26bd6 --- /dev/null +++ b/.chronus/changes/canon-2025-9-25-11-28-12.md @@ -0,0 +1,7 @@ +--- +changeKind: feature +packages: + - "@typespec/http-canonicalization" +--- + +Add experimental HTTP canonicalization utilities. diff --git a/.chronus/changes/canon-2025-9-25-11-28-30.md b/.chronus/changes/canon-2025-9-25-11-28-30.md new file mode 100644 index 00000000000..7ce62207c1e --- /dev/null +++ b/.chronus/changes/canon-2025-9-25-11-28-30.md @@ -0,0 +1,7 @@ +--- +changeKind: feature +packages: + - "@typespec/mutator-framework" +--- + +Add experimental mutator framework. \ No newline at end of file diff --git a/cspell.yaml b/cspell.yaml index 3fc6cfce781..8e8883a4737 100644 --- a/cspell.yaml +++ b/cspell.yaml @@ -34,6 +34,7 @@ words: - cadleditor - cadleng - cadlplayground + - canonicalizer - clsx - cobertura - codehaus @@ -267,6 +268,8 @@ words: - Ungroup - uninstantiated - unioned + - unionified + - unionify - unparented - unprefixed - unprojected @@ -314,6 +317,7 @@ ignorePaths: - packages/http-client-java/generator/http-client-generator-clientcore-test/** - packages/http-client-js/test/e2e/** - packages/http-client-js/sample/** + - packages/mutator-framework/**/*.test.ts - packages/typespec-vscode/test/scenarios/** - pnpm-lock.yaml - "**/*.mp4" diff --git a/packages/http-canonicalization/tsconfig.json b/packages/http-canonicalization/tsconfig.json index 6c2a9ca11db..6b38294b73c 100644 --- a/packages/http-canonicalization/tsconfig.json +++ b/packages/http-canonicalization/tsconfig.json @@ -12,7 +12,7 @@ "declarationMap": true, "outDir": "dist", "rootDir": "./", - "verbatimModuleSyntax": true, + "verbatimModuleSyntax": true }, "include": ["src/**/*.ts", "src/**/*.tsx", "test/**/*.ts", "test/**/*.tsx"], "exclude": ["node_modules", "dist"] diff --git a/packages/mutator-framework/src/mutation-node/model-property.test.ts b/packages/mutator-framework/src/mutation-node/model-property.test.ts index 542a838d2b3..b20b4e805a3 100644 --- a/packages/mutator-framework/src/mutation-node/model-property.test.ts +++ b/packages/mutator-framework/src/mutation-node/model-property.test.ts @@ -53,7 +53,7 @@ it("handles mutating a reference", async () => { expect(fooNode.mutatedType.properties.get("prop")!.type === barPrime.mutatedType).toBeTruthy(); }); -it.only("handles replacing the model reference", async () => { +it("handles replacing the model reference", async () => { const { Foo, Bar, prop, program } = await runner.compile(t.code` model ${t.model("Foo")} { ${t.modelProperty("prop")}: Bar; diff --git a/packages/mutator-framework/src/mutation/mutation-engine.test.ts b/packages/mutator-framework/src/mutation/mutation-engine.test.ts index 1f49d4a82a7..6a106e6748f 100644 --- a/packages/mutator-framework/src/mutation/mutation-engine.test.ts +++ b/packages/mutator-framework/src/mutation/mutation-engine.test.ts @@ -172,7 +172,7 @@ class UnionifyProperty extends ModelPropertyMutation< } } -it.only("mutates model properties into unions", async () => { +it("mutates model properties into unions", async () => { const runner = await Tester.createInstance(); const { Foo, program } = await runner.compile(t.code` model ${t.model("Foo")} { diff --git a/packages/mutator-framework/tsconfig.json b/packages/mutator-framework/tsconfig.json index 6c2a9ca11db..6b38294b73c 100644 --- a/packages/mutator-framework/tsconfig.json +++ b/packages/mutator-framework/tsconfig.json @@ -12,7 +12,7 @@ "declarationMap": true, "outDir": "dist", "rootDir": "./", - "verbatimModuleSyntax": true, + "verbatimModuleSyntax": true }, "include": ["src/**/*.ts", "src/**/*.tsx", "test/**/*.ts", "test/**/*.tsx"], "exclude": ["node_modules", "dist"]