diff --git a/.chronus/changes/joheredi-efv2-main-2025-1-13-1-49-16.md b/.chronus/changes/joheredi-efv2-main-2025-1-13-1-49-16.md new file mode 100644 index 0000000000..d333a697e4 --- /dev/null +++ b/.chronus/changes/joheredi-efv2-main-2025-1-13-1-49-16.md @@ -0,0 +1,11 @@ +--- +changeKind: feature +packages: + - "@typespec/html-program-viewer" + - "@typespec/http-client-csharp" + - "@typespec/http-client-java" + - "@typespec/http-server-javascript" + - "@typespec/http" +--- + +Emitter Framework V2 \ No newline at end of file diff --git a/.chronus/changes/joheredi-efv2-main-2025-1-13-21-57-25.md b/.chronus/changes/joheredi-efv2-main-2025-1-13-21-57-25.md new file mode 100644 index 0000000000..d261ea1382 --- /dev/null +++ b/.chronus/changes/joheredi-efv2-main-2025-1-13-21-57-25.md @@ -0,0 +1,8 @@ +--- +changeKind: feature +packages: + - "@typespec/emitter-framework" + - "@typespec/http-client" +--- + +Adding Emitter Framework and Http Client packages \ No newline at end of file diff --git a/.chronus/changes/joheredi-efv2-main-2025-1-13-5-45-17.md b/.chronus/changes/joheredi-efv2-main-2025-1-13-5-45-17.md new file mode 100644 index 0000000000..4a437e7ede --- /dev/null +++ b/.chronus/changes/joheredi-efv2-main-2025-1-13-5-45-17.md @@ -0,0 +1,7 @@ +--- +changeKind: feature +packages: + - "@typespec/compiler" +--- + +Add Typekits to support EFV2 \ No newline at end of file diff --git a/.prettierrc.json b/.prettierrc.json index ed0b6cce00..92f7d67297 100644 --- a/.prettierrc.json +++ b/.prettierrc.json @@ -9,6 +9,7 @@ "plugins": [ "./packages/prettier-plugin-typespec/dist/index.js", "./node_modules/prettier-plugin-organize-imports/index.js", + "@alloy-js/prettier-plugin-alloy", "prettier-plugin-astro", "prettier-plugin-sh" ], @@ -19,6 +20,18 @@ "parser": "typespec" } }, + { + "files": ["packages/http-client/**/*.tsx"], + "options": { + "parser": "alloy-ts" + } + }, + { + "files": ["packages/emitter-framework/**/*.tsx"], + "options": { + "parser": "alloy-ts" + } + }, { "files": "*.astro", "options": { diff --git a/cspell.yaml b/cspell.yaml index 309eba102b..fea07b6a36 100644 --- a/cspell.yaml +++ b/cspell.yaml @@ -88,6 +88,7 @@ words: - imple - Infima - initcs + - Initializable - inlines - inmemory - instanceid diff --git a/package.json b/package.json index cea78f038f..52ceb3c186 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "upload-manifest": "pnpm -r --filter=@typespec/http-specs run upload-manifest" }, "devDependencies": { + "@alloy-js/prettier-plugin-alloy": "^0.1.0", "@chronus/chronus": "^0.14.1", "@chronus/github": "^0.4.7", "@eslint/js": "^9.18.0", diff --git a/packages/compiler/src/experimental/typekit/define-kit.ts b/packages/compiler/src/experimental/typekit/define-kit.ts index 04f0c97a92..a2c3ecd3e9 100644 --- a/packages/compiler/src/experimental/typekit/define-kit.ts +++ b/packages/compiler/src/experimental/typekit/define-kit.ts @@ -32,6 +32,8 @@ export type StripGuards = { : StripGuards; }; +export const TypekitNamespaceSymbol = Symbol.for("TypekitNamespace"); + /** * Defines an extension to the Typekit interface. * @@ -43,6 +45,21 @@ export function defineKit>( source: StripGuards & ThisType, ): void { for (const [name, fnOrNs] of Object.entries(source)) { - TypekitPrototype[name] = fnOrNs; + let kits = fnOrNs; + + if (TypekitPrototype[name] !== undefined) { + kits = { ...TypekitPrototype[name], ...fnOrNs }; + } + + // Tag top-level namespace objects with the symbol + if (typeof kits === "object" && kits !== null) { + Object.defineProperty(kits, TypekitNamespaceSymbol, { + value: true, + enumerable: false, // Keep the symbol non-enumerable + configurable: false, + }); + } + + TypekitPrototype[name] = kits; } } diff --git a/packages/compiler/src/experimental/typekit/index.ts b/packages/compiler/src/experimental/typekit/index.ts index 5a3056b5a5..63bb5afd73 100644 --- a/packages/compiler/src/experimental/typekit/index.ts +++ b/packages/compiler/src/experimental/typekit/index.ts @@ -1,6 +1,6 @@ import { compilerAssert, Program } from "../../core/index.js"; import { Realm } from "../realm.js"; -import { Typekit, TypekitPrototype } from "./define-kit.js"; +import { Typekit, TypekitNamespaceSymbol, TypekitPrototype } from "./define-kit.js"; export * from "./define-kit.js"; export * from "./kits/index.js"; @@ -32,14 +32,16 @@ export function createTypekit(realm: Realm): Typekit { const value = Reflect.get(target, prop, receiver); + // Wrap functions to set `this` correctly if (typeof value === "function") { return function (this: any, ...args: any[]) { return value.apply(proxy, args); }; } - if (typeof value === "object" && value !== null) { - return new Proxy(value, handler); + // Only wrap objects marked as Typekit namespaces + if (typeof value === "object" && value !== null && isTypekitNamespace(value)) { + return new Proxy(value, handler); // Wrap namespace objects } return value; @@ -50,6 +52,11 @@ export function createTypekit(realm: Realm): Typekit { return proxy; } +// Helper function to check if an object is a Typekit namespace +function isTypekitNamespace(obj: any): boolean { + return obj && !!obj[TypekitNamespaceSymbol]; +} + // #region Default Typekit const CURRENT_PROGRAM = Symbol.for("TypeSpec.Typekit.CURRENT_PROGRAM"); diff --git a/packages/compiler/src/experimental/typekit/kits/array.ts b/packages/compiler/src/experimental/typekit/kits/array.ts new file mode 100644 index 0000000000..cf36456383 --- /dev/null +++ b/packages/compiler/src/experimental/typekit/kits/array.ts @@ -0,0 +1,56 @@ +import { isArrayModelType } from "../../../core/type-utils.js"; +import { Model, Type } from "../../../core/types.js"; +import { defineKit } from "../define-kit.js"; + +/** + * @experimental + */ +export interface ArrayKit { + /** + * Check if a type is an array. + */ + is(type: Type): boolean; + /** + * Get the element type of an array. + */ + getElementType(type: Model): Type; + /** + * Create an array type. + */ + create(elementType: Type): Model; +} + +interface TypekitExtension { + /** @experimental */ + array: ArrayKit; +} + +declare module "../define-kit.js" { + interface Typekit extends TypekitExtension {} +} + +defineKit({ + array: { + is(type) { + return ( + type.kind === "Model" && isArrayModelType(this.program, type) && type.properties.size === 0 + ); + }, + getElementType(type) { + if (!this.array.is(type)) { + throw new Error("Type is not an array."); + } + return type.indexer!.value; + }, + create(elementType) { + return this.model.create({ + name: "Array", + properties: {}, + indexer: { + key: this.builtin.integer, + value: elementType, + }, + }); + }, + }, +}); diff --git a/packages/compiler/src/experimental/typekit/kits/builtin.ts b/packages/compiler/src/experimental/typekit/kits/builtin.ts new file mode 100644 index 0000000000..a9869c71e7 --- /dev/null +++ b/packages/compiler/src/experimental/typekit/kits/builtin.ts @@ -0,0 +1,222 @@ +import type { Scalar } from "../../../core/types.js"; +import { defineKit } from "../define-kit.js"; + +/** + * A kit of built-in types. + * @experimental + */ +export interface BuiltinKit { + /** + * Accessor for the string builtin type. + */ + get string(): Scalar; + + /** + * Accessor for the boolean builtin type. + */ + get boolean(): Scalar; + + /** + * Accessor for the bytes builtin type, representing binary data. + */ + get bytes(): Scalar; + + /** + * Accessor for the decimal builtin type for high-precision decimal values. + */ + get decimal(): Scalar; + + /** + * Accessor for the decimal128 builtin type, a 128-bit decimal value. + */ + get decimal128(): Scalar; + + /** + * Accessor for the duration builtin type, representing a span of time. + */ + get duration(): Scalar; + + /** + * Accessor for the float builtin type, representing a double-precision floating-point number. + */ + get float(): Scalar; + + /** + * Accessor for the float32 builtin type, representing a single-precision floating-point number. + */ + get float32(): Scalar; + + /** + * Accessor for the float64 builtin type, representing a double-precision floating-point number. + */ + get float64(): Scalar; + + /** + * Accessor for the int8 builtin type, representing an 8-bit signed integer. + */ + get int8(): Scalar; + + /** + * Accessor for the int16 builtin type, representing a 16-bit signed integer. + */ + get int16(): Scalar; + + /** + * Accessor for the int32 builtin type, representing a 32-bit signed integer. + */ + get int32(): Scalar; + + /** + * Accessor for the int64 builtin type, representing a 64-bit signed integer. + */ + get int64(): Scalar; + + /** + * Accessor for the integer builtin type, representing an arbitrary-precision integer. + */ + get integer(): Scalar; + + /** + * Accessor for the offsetDateTime builtin type, representing a date and time with an offset. + */ + get offsetDateTime(): Scalar; + + /** + * Accessor for the plainDate builtin type, representing a date without time or offset. + */ + get plainDate(): Scalar; + + /** + * Accessor for the plainTime builtin type, representing a time without date or offset. + */ + get plainTime(): Scalar; + + /** + * Accessor for the safeInt builtin type, representing an integer within the safe range for JavaScript. + */ + get safeInt(): Scalar; + + /** + * Accessor for the uint8 builtin type, representing an 8-bit unsigned integer. + */ + get uint8(): Scalar; + + /** + * Accessor for the uint16 builtin type, representing a 16-bit unsigned integer. + */ + get uint16(): Scalar; + + /** + * Accessor for the uint32 builtin type, representing a 32-bit unsigned integer. + */ + get uint32(): Scalar; + + /** + * Accessor for the uint64 builtin type, representing a 64-bit unsigned integer. + */ + get uint64(): Scalar; + + /** + * Accessor for the url builtin type, representing a valid URL string. + */ + get url(): Scalar; + + /** + * Accessor for the utcDateTime builtin type, representing a date and time in UTC. + */ + get utcDateTime(): Scalar; + + /** + * Accessor for the numeric builtin type, representing a numeric value. + */ + get numeric(): Scalar; +} + +interface TypekitExtension { + /** @experimental */ + builtin: BuiltinKit; +} + +declare module "../define-kit.js" { + interface Typekit extends TypekitExtension {} +} + +defineKit({ + builtin: { + get string(): Scalar { + return this.program.checker.getStdType("string"); + }, + get boolean(): Scalar { + return this.program.checker.getStdType("boolean"); + }, + get bytes(): Scalar { + return this.program.checker.getStdType("bytes"); + }, + get decimal(): Scalar { + return this.program.checker.getStdType("decimal"); + }, + get decimal128(): Scalar { + return this.program.checker.getStdType("decimal128"); + }, + get duration(): Scalar { + return this.program.checker.getStdType("duration"); + }, + get float(): Scalar { + return this.program.checker.getStdType("float"); + }, + get float32(): Scalar { + return this.program.checker.getStdType("float32"); + }, + get float64(): Scalar { + return this.program.checker.getStdType("float64"); + }, + get int8(): Scalar { + return this.program.checker.getStdType("int8"); + }, + get int16(): Scalar { + return this.program.checker.getStdType("int16"); + }, + get int32(): Scalar { + return this.program.checker.getStdType("int32"); + }, + get int64(): Scalar { + return this.program.checker.getStdType("int64"); + }, + get integer(): Scalar { + return this.program.checker.getStdType("integer"); + }, + get offsetDateTime(): Scalar { + return this.program.checker.getStdType("offsetDateTime"); + }, + get plainDate(): Scalar { + return this.program.checker.getStdType("plainDate"); + }, + get plainTime(): Scalar { + return this.program.checker.getStdType("plainTime"); + }, + get safeInt(): Scalar { + return this.program.checker.getStdType("safeint"); + }, + get uint8(): Scalar { + return this.program.checker.getStdType("uint8"); + }, + get uint16(): Scalar { + return this.program.checker.getStdType("uint16"); + }, + get uint32(): Scalar { + return this.program.checker.getStdType("uint32"); + }, + get uint64(): Scalar { + return this.program.checker.getStdType("uint64"); + }, + get url(): Scalar { + return this.program.checker.getStdType("url"); + }, + get utcDateTime(): Scalar { + return this.program.checker.getStdType("utcDateTime"); + }, + get numeric(): Scalar { + return this.program.checker.getStdType("numeric"); + }, + }, +}); diff --git a/packages/compiler/src/experimental/typekit/kits/enum-member.ts b/packages/compiler/src/experimental/typekit/kits/enum-member.ts new file mode 100644 index 0000000000..ff86a4bf6e --- /dev/null +++ b/packages/compiler/src/experimental/typekit/kits/enum-member.ts @@ -0,0 +1,79 @@ +import type { Enum, EnumMember, Type } from "../../../core/types.js"; + +import { defineKit } from "../define-kit.js"; +import { decoratorApplication, DecoratorArgs } from "../utils.js"; + +/** + * A descriptor for creating an enum member. + * @experimental + */ +export interface EnumMemberDescriptor { + /** + * The name of the enum member. + */ + name: string; + /** + * Decorators to apply to the enum member. + */ + decorators?: DecoratorArgs[]; + + /** + * The value of the enum member. If not supplied, the value will be the same + * as the name. + */ + value?: string | number; + + /** + * The enum that the member belongs to. If not provided here, it is assumed + * that it will be set in `enum.build`. + */ + enum?: Enum; +} + +/** + * A kit for working with enum members. + * @experimental + */ +export interface EnumMemberKit { + /** + * Create an enum member. The enum member will be finished (i.e. decorators are run). + */ + create(desc: EnumMemberDescriptor): EnumMember; + + /** + * Check if `type` is an enum member type. + * + * @param type the type to check. + */ + is(type: Type): type is EnumMember; +} + +interface TypekitExtension { + /** @experimental */ + enumMember: EnumMemberKit; +} + +declare module "../define-kit.js" { + interface Typekit extends TypekitExtension {} +} + +defineKit({ + enumMember: { + create(desc) { + const member: EnumMember = this.program.checker.createType({ + kind: "EnumMember", + name: desc.name, + value: desc.value, + decorators: decoratorApplication(this, desc.decorators), + node: undefined as any, + enum: desc.enum as any, // initialized in enum.build if not provided here + }); + this.program.checker.finishType(member); + return member; + }, + + is(type) { + return type.kind === "EnumMember"; + }, + }, +}); diff --git a/packages/compiler/src/experimental/typekit/kits/enum.ts b/packages/compiler/src/experimental/typekit/kits/enum.ts new file mode 100644 index 0000000000..b7aaafbad3 --- /dev/null +++ b/packages/compiler/src/experimental/typekit/kits/enum.ts @@ -0,0 +1,115 @@ +import type { Enum, EnumMember, Type, Union } from "../../../core/types.js"; +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { type UnionKit } from "./union.js"; + +import { createRekeyableMap } from "../../../utils/misc.js"; +import { defineKit } from "../define-kit.js"; +import { decoratorApplication, DecoratorArgs } from "../utils.js"; + +/** + * Describes an enum type for creation. + * @experimental + */ +interface EnumDescriptor { + /** + * The name of the enum declaration. + */ + name: string; + + /** + * Decorators to apply to the enum. + */ + decorators?: DecoratorArgs[]; + + /** + * The members of the enum. If a member is an object, each property will be + * converted to an EnumMember with the same name and value. + */ + members?: Record | EnumMember[]; +} + +/** + * A kit for working with enum types. + * @experimental + */ +export interface EnumKit { + /** + * Build an enum type. The enum type will be finished (i.e. decorators are + * run). + */ + create(desc: EnumDescriptor): Enum; + + /** + * Build an equivalent enum from the given union. Union variants which are + * not valid enum members are skipped. You can check if a union is a valid + * enum with {@link UnionKit.union}'s `isEnumValue`. + */ + createFromUnion(type: Union): Enum; + + /** + * Check if `type` is an enum type. + * + * @param type the type to check. + */ + is(type: Type): type is Enum; +} + +interface TypekitExtension { + /** @experimental */ + enum: EnumKit; +} + +declare module "../define-kit.js" { + interface Typekit extends TypekitExtension {} +} + +defineKit({ + enum: { + create(desc) { + const en: Enum = this.program.checker.createType({ + kind: "Enum", + name: desc.name, + decorators: decoratorApplication(this, desc.decorators), + members: createRekeyableMap(), + node: undefined as any, + }); + + if (Array.isArray(desc.members)) { + for (const member of desc.members) { + member.enum = en; + en.members.set(member.name, member); + } + } else { + for (const [name, member] of Object.entries(desc.members ?? {})) { + en.members.set(name, this.enumMember.create({ name, value: member, enum: en })); + } + } + + this.program.checker.finishType(en); + return en; + }, + + is(type) { + return type.kind === "Enum"; + }, + + createFromUnion(type) { + if (!type.name) { + throw new Error("Cannot create an enum from an anonymous union."); + } + + const enumMembers: EnumMember[] = []; + for (const variant of type.variants.values()) { + if ( + (variant.name && typeof variant.name === "symbol") || + (!this.literal.isString(variant.type) && !this.literal.isNumeric(variant.type)) + ) { + continue; + } + enumMembers.push(this.enumMember.create({ name: variant.name, value: variant.type.value })); + } + + return this.enum.create({ name: type.name, members: enumMembers }); + }, + }, +}); diff --git a/packages/compiler/src/experimental/typekit/kits/index.ts b/packages/compiler/src/experimental/typekit/kits/index.ts index 7398353213..50b6201423 100644 --- a/packages/compiler/src/experimental/typekit/kits/index.ts +++ b/packages/compiler/src/experimental/typekit/kits/index.ts @@ -1,7 +1,14 @@ +export * from "./array.js"; +export * from "./builtin.js"; +export * from "./enum-member.js"; +export * from "./enum.js"; export * from "./literal.js"; export * from "./model-property.js"; export * from "./model.js"; +export * from "./operation.js"; +export * from "./record.js"; export * from "./scalar.js"; export * from "./type.js"; export * from "./union-variant.js"; export * from "./union.js"; +export * from "./value.js"; diff --git a/packages/compiler/src/experimental/typekit/kits/model-property.ts b/packages/compiler/src/experimental/typekit/kits/model-property.ts index a8f941b367..d22ce96ba6 100644 --- a/packages/compiler/src/experimental/typekit/kits/model-property.ts +++ b/packages/compiler/src/experimental/typekit/kits/model-property.ts @@ -1,18 +1,49 @@ -import type { Enum, EnumMember, ModelProperty, Scalar, Type } from "../../../core/types.js"; +import type { Enum, EnumMember, ModelProperty, Scalar, Type, Value } from "../../../core/types.js"; import { getVisibilityForClass } from "../../../core/visibility/core.js"; import { EncodeData, getEncode, getFormat } from "../../../lib/decorators.js"; import { defineKit } from "../define-kit.js"; /** + * A descriptor for a model property. * @experimental + */ +export interface ModelPropertyDescriptor { + /** + * The name of the model property. + */ + name: string; + + /** + * The type of the model property. + */ + type: Type; + + /** + * Whether the model property is optional. + */ + optional?: boolean; + + /** + * Default value + */ + defaultValue?: Value | undefined; +} + +/** * Utilities for working with model properties. * * For many reflection operations, the metadata being asked for may be found * on the model property or the type of the model property. In such cases, * these operations will return the metadata from the model property if it * exists, or the type of the model property if it exists. + * @experimental */ export interface ModelPropertyKit { + /** + * Creates a modelProperty type. + * @param desc The descriptor of the model property. + */ + create(desc: ModelPropertyDescriptor): ModelProperty; /** * Check if the given `type` is a model property. * @@ -50,6 +81,7 @@ interface TypekitExtension { * on the model property or the type of the model property. In such cases, * these operations will return the metadata from the model property if it * exists, or the type of the model property if it exists. + * @experimental */ modelProperty: ModelPropertyKit; } @@ -75,5 +107,16 @@ defineKit({ getVisibilityForClass(property, visibilityClass) { return getVisibilityForClass(this.program, property, visibilityClass); }, + create(desc) { + return this.program.checker.createType({ + kind: "ModelProperty", + name: desc.name, + node: undefined as any, + type: desc.type, + optional: desc.optional ?? false, + decorators: [], + defaultValue: desc.defaultValue, + }); + }, }, }); diff --git a/packages/compiler/src/experimental/typekit/kits/model.ts b/packages/compiler/src/experimental/typekit/kits/model.ts index 990e5c3ff5..b210c0d960 100644 --- a/packages/compiler/src/experimental/typekit/kits/model.ts +++ b/packages/compiler/src/experimental/typekit/kits/model.ts @@ -1,11 +1,21 @@ import { getEffectiveModelType } from "../../../core/checker.js"; -import type { Model, ModelProperty, SourceModel, Type } from "../../../core/types.js"; +import type { + Model, + ModelIndexer, + ModelProperty, + RekeyableMap, + SourceModel, + Type, +} from "../../../core/types.js"; import { createRekeyableMap } from "../../../utils/misc.js"; import { defineKit } from "../define-kit.js"; -import { decoratorApplication, DecoratorArgs } from "../utils.js"; +import { copyMap, decoratorApplication, DecoratorArgs } from "../utils.js"; -/** @experimental */ -interface ModelDescriptor { +/** + * A descriptor for creating a model. + * @experimental + */ +export interface ModelDescriptor { /** * The name of the Model. If name is provided, it is a Model declaration. * Otherwise, it is a Model expression. @@ -31,6 +41,10 @@ interface ModelDescriptor { * Models that this model extends. */ sourceModels?: SourceModel[]; + /** + * The indexer property of the model. + */ + indexer?: ModelIndexer; } /** @@ -52,6 +66,14 @@ export interface ModelKit { */ is(type: Type): type is Model; + /** + * Check this is an anonyous model. Specifically, this checks if the + * model has a name. + * + * @param type The model to check. + */ + isExpresion(type: Model): boolean; + /** * If the input is anonymous (or the provided filter removes properties) * and there exists a named model with the same set of properties @@ -69,6 +91,20 @@ export interface ModelKit { * properties. */ getEffectiveModel(model: Model, filter?: (property: ModelProperty) => boolean): Model; + + /** + * Given a model, return the type that is spread + * @returns the type that is spread or undefined if no spread + */ + getSpreadType: (model: Model) => Type | undefined; + /** + * Gets all properties from a model, explicitly defined and implicitly defined. + * @param model model to get the properties from + */ + getProperties( + model: Model, + options?: { includeExtended?: boolean }, + ): RekeyableMap; } interface TypekitExtension { @@ -83,7 +119,8 @@ declare module "../define-kit.js" { interface Typekit extends TypekitExtension {} } -export const ModelKit = defineKit({ +const spreadCache = new Map(); +defineKit({ model: { create(desc) { const properties = createRekeyableMap(Array.from(Object.entries(desc.properties))); @@ -96,6 +133,7 @@ export const ModelKit = defineKit({ node: undefined as any, derivedModels: desc.derivedModels ?? [], sourceModels: desc.sourceModels ?? [], + indexer: desc.indexer, }); this.program.checker.finishType(model); @@ -105,8 +143,64 @@ export const ModelKit = defineKit({ is(type) { return type.kind === "Model"; }, + + isExpresion(type) { + return type.name === ""; + }, getEffectiveModel(model, filter?: (property: ModelProperty) => boolean) { return getEffectiveModelType(this.program, model, filter); }, + getSpreadType(model) { + if (spreadCache.has(model)) { + return spreadCache.get(model); + } + + if (!model.indexer) { + return undefined; + } + + if (model.indexer.key.name === "string") { + const record = this.record.create(model.indexer.value); + spreadCache.set(model, record); + return record; + } + + if (model.indexer.key.name === "integer") { + const array = this.array.create(model.indexer.value); + spreadCache.set(model, array); + return array; + } + + return model.indexer.value; + }, + getProperties(model, options = {}) { + // Add explicitly defined properties + const properties = copyMap(model.properties); + + // Add discriminator property if it exists + const discriminator = this.type.getDiscriminator(model); + if (discriminator) { + const discriminatorName = discriminator.propertyName; + properties.set( + discriminatorName, + this.modelProperty.create({ name: discriminatorName, type: this.builtin.string }), + ); + } + + if (options.includeExtended) { + let base = model.baseModel; + while (base) { + for (const [key, value] of base.properties) { + if (!properties.has(key)) { + properties.set(key, value); + } + } + base = base.baseModel; + } + } + + // TODO: Add Spread? + return properties; + }, }, }); diff --git a/packages/compiler/src/experimental/typekit/kits/operation.ts b/packages/compiler/src/experimental/typekit/kits/operation.ts new file mode 100644 index 0000000000..009f753e71 --- /dev/null +++ b/packages/compiler/src/experimental/typekit/kits/operation.ts @@ -0,0 +1,83 @@ +import { ModelProperty, Operation, Type } from "../../../core/types.js"; +import { defineKit } from "../define-kit.js"; + +/** + * A descriptor for an operation. + * @experimental + */ +export interface OperationDescriptor { + /** + * The name of the model property. + */ + name: string; + + /** + * The parameters to the model + */ + parameters: ModelProperty[]; + + /** + * The return type of the model + */ + returnType: Type; +} + +/** + * Utilities for working with operation properties. + * @experimental + */ +export interface OperationKit { + /** + * Create an operation type. + * + * @param desc The descriptor of the operation. + */ + create(desc: OperationDescriptor): Operation; + /** + * Check if the type is an operation. + * @param type type to check + */ + is(type: Type): type is Operation; +} + +interface TypekitExtension { + /** + * Utilities for working with operation properties. + * @experimental + */ + operation: OperationKit; +} + +declare module "../define-kit.js" { + interface Typekit extends TypekitExtension {} +} + +defineKit({ + operation: { + is(type: Type) { + return type.kind === "Operation"; + }, + create(desc) { + const parametersModel = this.model.create({ + name: `${desc.name}Parameters`, + properties: desc.parameters.reduce( + (acc, property) => { + acc[property.name] = property; + return acc; + }, + {} as Record, + ), + }); + const operation: Operation = this.program.checker.createType({ + kind: "Operation", + name: desc.name, + decorators: [], + parameters: parametersModel, + returnType: desc.returnType, + node: undefined as any, + }); + this.program.checker.finishType(operation); + return operation; + }, + }, +}); diff --git a/packages/compiler/src/experimental/typekit/kits/record.ts b/packages/compiler/src/experimental/typekit/kits/record.ts new file mode 100644 index 0000000000..b30c7e8598 --- /dev/null +++ b/packages/compiler/src/experimental/typekit/kits/record.ts @@ -0,0 +1,61 @@ +import { isRecordModelType } from "../../../core/type-utils.js"; +import { Model, Type } from "../../../core/types.js"; +import { defineKit } from "../define-kit.js"; + +/** + * RecordKit provides utilities for working with Record Model types. + * @experimental + */ +export interface RecordKit { + /** + * Check if the given `type` is a Record. + * + * @param type The type to check. + */ + is(type: Type): boolean; + /** + * Get the element type of a Record + * @param type a Record Model type + */ + getElementType(type: Model): Type; + /** + * Create a Record Model type + * @param elementType The type of the elements in the record + */ + create(elementType: Type): Model; +} + +interface TypekitExtension { + /** @experimental */ + record: RecordKit; +} + +declare module "../define-kit.js" { + interface Typekit extends TypekitExtension {} +} + +defineKit({ + record: { + is(type) { + return ( + type.kind === "Model" && isRecordModelType(this.program, type) && type.properties.size === 0 + ); + }, + getElementType(type) { + if (!this.record.is(type)) { + throw new Error("Type is not a record."); + } + return type.indexer!.value; + }, + create(elementType) { + return this.model.create({ + name: "Record", + properties: {}, + indexer: { + key: this.builtin.string, + value: elementType, + }, + }); + }, + }, +}); diff --git a/packages/compiler/src/experimental/typekit/kits/type.ts b/packages/compiler/src/experimental/typekit/kits/type.ts index ec640c1497..81e4ba7f90 100644 --- a/packages/compiler/src/experimental/typekit/kits/type.ts +++ b/packages/compiler/src/experimental/typekit/kits/type.ts @@ -1,6 +1,24 @@ -import { type Type } from "../../../core/types.js"; +import { getDiscriminatedUnion } from "../../../core/helpers/discriminator-utils.js"; +import { getLocationContext } from "../../../core/index.js"; +import { + Discriminator, + getDiscriminator, + getMaxItems, + getMaxLength, + getMaxValue, + getMaxValueExclusive, + getMinItems, + getMinLength, + getMinValue, + getMinValueExclusive, +} from "../../../core/intrinsic-type-state.js"; +import { isErrorType, isNeverType } from "../../../core/type-utils.js"; +import { Enum, Model, Scalar, Union, type Type } from "../../../core/types.js"; +import { getDoc, getSummary } from "../../../lib/decorators.js"; +import { resolveEncodedName } from "../../../lib/encoded-names.js"; import { defineKit } from "../define-kit.js"; import { copyMap } from "../utils.js"; +import { getPlausibleName } from "../utils/get-plausible-name.js"; /** @experimental */ export interface TypeTypekit { @@ -13,6 +31,100 @@ export interface TypeTypekit { * Finishes a type, applying all the decorators. */ finishType(type: Type): void; + /** + * Checks if a type is decorated with @error + * @param type The type to check. + */ + isError(type: Type): boolean; + /** + * Get the name of this type in the specified encoding. + */ + getEncodedName(type: Type & { name: string }, encoding: string): string; + + /** + * Get the summary of this type as specified by the `@summary` decorator. + * + * @param type The type to get the summary for. + */ + getSummary(type: Type): string | undefined; + + /** + * Get the documentation of this type as specified by the `@doc` decorator or + * the JSDoc comment. + * + * @param type The type to get the documentation for. + */ + getDoc(type: Type): string | undefined; + /** + * Get the plausible name of a type. If the type has a name, it will use it otherwise it will try generate a name based on the context. + * If the type can't get a name, it will return an empty string. + * If the type is a TemplateInstance, it will prefix the name with the template arguments. + * @param type The scalar to get the name of.z + */ + getPlausibleName(type: Model | Union | Enum | Scalar): string; + /** + * Resolves a discriminated union for the given model or union. + * @param type Model or Union to resolve the discriminated union for. + */ + getDiscriminatedUnion(type: Model | Union): Union | undefined; + /** + * Resolves the discriminator for a discriminated union. Returns undefined if the type is not a discriminated union. + * @param type + */ + getDiscriminator(type: Model | Union): Discriminator | undefined; + /** + * Gets the maximum value for a numeric or model property type. + * @param type type to get the maximum value for + */ + maxValue(type: Type): number | undefined; + /** + * Gets the minimum value for a numeric or model property type. + * @param type type to get the minimum value for + */ + minValue(type: Type): number | undefined; + + /** + * Gets the maximum value this numeric type should be, exclusive of the given value. + * @param type + */ + maxValueExclusive(type: Type): number | undefined; + + /** + * Gets the minimum value this numeric type should be, exclusive of the given value. + * @param type type to get the minimum value for + */ + minValueExclusive(type: Type): number | undefined; + + /** + * Gets the maximum length for a string type. + * @param type type to get the maximum length for + */ + maxLength(type: Type): number | undefined; + /** + * Gets the minimum length for a string type. + * @param type type to get the minimum length for + */ + minLength(type: Type): number | undefined; + /** + * Gets the maximum number of items for an array type. + * @param type type to get the maximum number of items for + */ + maxItems(type: Type): number | undefined; + /** + * Gets the minimum number of items for an array type. + * @param type type to get the minimum number of items for + */ + minItems(type: Type): number | undefined; + /** + * Checks if the given type is a never type. + */ + isNever(type: Type): boolean; + /** + * Checks if the given type is a user defined type. Non-user defined types are defined in the compiler or other libraries imported by the spec. + * @param type The type to check. + * @returns True if the type is a user defined type, false otherwise. + */ + isUserDefined(type: Type): boolean; } interface TypekitExtension { @@ -107,5 +219,69 @@ defineKit({ this.realm.addType(clone); return clone; }, + isError(type) { + return isErrorType(type); + }, + getEncodedName(type, encoding) { + return resolveEncodedName(this.program, type, encoding); + }, + getSummary(type) { + return getSummary(this.program, type); + }, + getDoc(type) { + return getDoc(this.program, type); + }, + getPlausibleName(type) { + return getPlausibleName(type); + }, + getDiscriminator(type) { + return getDiscriminator(this.program, type); + }, + getDiscriminatedUnion(type) { + const discriminator = getDiscriminator(this.program, type); + + if (!discriminator) { + return undefined; + } + + const [union] = getDiscriminatedUnion(type, discriminator); + const variants = Array.from(union.variants.entries()).map(([k, v]) => + this.unionVariant.create({ name: k, type: v }), + ); + return this.union.create({ + name: union.propertyName, + variants, + }); + }, + maxValue(type) { + return getMaxValue(this.program, type); + }, + minValue(type) { + return getMinValue(this.program, type); + }, + maxLength(type) { + return getMaxLength(this.program, type); + }, + minLength(type) { + return getMinLength(this.program, type); + }, + maxItems(type) { + return getMaxItems(this.program, type); + }, + maxValueExclusive(type) { + return getMaxValueExclusive(this.program, type); + }, + minValueExclusive(type) { + return getMinValueExclusive(this.program, type); + }, + minItems(type) { + return getMinItems(this.program, type); + }, + isNever(type) { + return isNeverType(type); + }, + isUserDefined(type) { + return getLocationContext(this.program, type).type === "project"; + }, }, }); diff --git a/packages/compiler/src/experimental/typekit/kits/union-variant.ts b/packages/compiler/src/experimental/typekit/kits/union-variant.ts index e814045c09..5e5b32b103 100644 --- a/packages/compiler/src/experimental/typekit/kits/union-variant.ts +++ b/packages/compiler/src/experimental/typekit/kits/union-variant.ts @@ -2,7 +2,11 @@ import type { Type, Union, UnionVariant } from "../../../core/types.js"; import { defineKit } from "../define-kit.js"; import { decoratorApplication, DecoratorArgs } from "../utils.js"; -interface UnionVariantDescriptor { +/** + * A descriptor for a union variant. + * @experimental + */ +export interface UnionVariantDescriptor { /** * The name of the union variant. */ diff --git a/packages/compiler/src/experimental/typekit/kits/union.ts b/packages/compiler/src/experimental/typekit/kits/union.ts index e50dfb72d4..7be4bd4b02 100644 --- a/packages/compiler/src/experimental/typekit/kits/union.ts +++ b/packages/compiler/src/experimental/typekit/kits/union.ts @@ -4,7 +4,11 @@ import { createRekeyableMap } from "../../../utils/misc.js"; import { defineKit } from "../define-kit.js"; import { decoratorApplication, DecoratorArgs } from "../utils.js"; -interface UnionDescriptor { +/** + * A descriptor for a union type. + * @experimental + */ +export interface UnionDescriptor { /** * The name of the union. If name is provided, it is a union declaration. * Otherwise, it is a union expression. @@ -28,6 +32,11 @@ interface UnionDescriptor { * @experimental */ export interface UnionKit { + /** + * Creates a union type with filtered variants. + * @param filterFn Function to filter the union variants + */ + filter(union: Union, filterFn: (variant: UnionVariant) => boolean): Union; /** * Create a union type. * @@ -58,6 +67,12 @@ export interface UnionKit { * @param type The union to check. */ isExtensible(type: Union): boolean; + + /** + * Checks if an union is an expression (anonymous) or declared. + * @param type Uniton to check if it is an expression + */ + isExpression(type: Union): boolean; } interface TypekitExtension { @@ -74,6 +89,10 @@ declare module "../define-kit.js" { export const UnionKit = defineKit({ union: { + filter(union, filterFn) { + const variants = Array.from(union.variants.values()).filter(filterFn); + return this.union.create({ variants }); + }, create(desc) { const union: Union = this.program.checker.createType({ kind: "Union", @@ -149,5 +168,9 @@ export const UnionKit = defineKit({ return false; }, + + isExpression(type) { + return type.name === undefined || type.name === ""; + }, }, }); diff --git a/packages/compiler/src/experimental/typekit/kits/value.ts b/packages/compiler/src/experimental/typekit/kits/value.ts new file mode 100644 index 0000000000..3683d3cdd7 --- /dev/null +++ b/packages/compiler/src/experimental/typekit/kits/value.ts @@ -0,0 +1,189 @@ +import { Numeric } from "../../../core/numeric.js"; +import type { + ArrayValue, + BooleanValue, + EnumValue, + NullValue, + NumericValue, + ObjectValue, + ScalarValue, + StringValue, + Value, +} from "../../../core/types.js"; +import { defineKit } from "../define-kit.js"; + +/** @experimental */ +export interface ValueKit { + /** + * Create a Value type from a JavaScript value. + * + * @param value The JavaScript value to turn into a TypeSpec Value type. + */ + create(value: string | number | boolean): Value; + + /** + * Create a string Value type from a JavaScript string value. + * + * @param value The string value. + */ + createString(value: string): StringValue; + + /** + * Create a numeric Value type from a JavaScript number value. + * + * @param value The numeric value. + */ + createNumeric(value: number): NumericValue; + + /** + * Create a boolean Value type from a JavaScript boolean value. + * + * @param value The boolean value. + */ + createBoolean(value: boolean): BooleanValue; + + /** + * Check if `type` is a string Value type. + * + * @param type The type to check. + */ + isString(type: Value): type is StringValue; + + /** + * Check if `type` is a numeric Value type. + * + * @param type The type to check. + */ + isNumeric(type: Value): type is NumericValue; + + /** + * Check if `type` is a scalar value type + * @param type The type to check. + */ + isScalar(type: Value): type is ScalarValue; + + /** + * Check if `type` is an object value type + * @param type The type to check. + */ + isObject(type: Value): type is ObjectValue; + + /** + * Check if `type` is an array value type + * @param type The type to check. + */ + isArray(type: Value): type is ArrayValue; + + /** + * Check if `type` is an enum value type + * @param type The type to check. + */ + isEnum(type: Value): type is EnumValue; + + /** + * Check if `type` is a null value Type. + * @param type The type to check. + */ + isNull(type: Value): type is NullValue; + + /** + * Check if `type` is a boolean Value type. + * + * @param type The type to check. + */ + isBoolean(type: Value): type is BooleanValue; + + is(type: { valueKind: string }): type is Value; +} + +interface TypekitExtension { + /** @experimental */ + value: ValueKit; +} + +declare module "../define-kit.js" { + interface Typekit extends TypekitExtension {} +} + +defineKit({ + value: { + is(value) { + const type = value as any; + return ( + this.value.isString(type) || + this.value.isNumeric(type) || + this.value.isBoolean(type) || + this.value.isArray(type) || + this.value.isObject(type) || + this.value.isEnum(type) || + this.value.isNull(type) || + this.value.isScalar(type) + ); + }, + create(value) { + if (typeof value === "string") { + return this.value.createString(value); + } else if (typeof value === "number") { + return this.value.createNumeric(value); + } else { + return this.value.createBoolean(value); + } + }, + createString(value) { + return { + entityKind: "Value", + value: value, + valueKind: "StringValue", + type: this.literal.createString(value), + scalar: undefined, + } as StringValue; + }, + + createNumeric(value) { + const valueAsString = String(value); + + return { + entityKind: "Value", + value: Numeric(valueAsString), + valueKind: "NumericValue", + type: this.literal.createNumeric(value), + scalar: undefined, + } as NumericValue; + }, + + createBoolean(value) { + return { + entityKind: "Value", + value: value, + valueKind: "BooleanValue", + type: this.literal.createBoolean(value), + scalar: undefined, + } as BooleanValue; + }, + + isBoolean(type) { + return type.valueKind === "BooleanValue"; + }, + isString(type) { + return type.valueKind === "StringValue"; + }, + isNumeric(type) { + return type.valueKind === "NumericValue"; + }, + isArray(type) { + return type.valueKind === "ArrayValue"; + }, + isObject(type) { + return type.valueKind === "ObjectValue"; + }, + isEnum(type) { + return type.valueKind === "EnumValue"; + }, + isNull(type) { + return type.valueKind === "NullValue"; + }, + isScalar(type) { + return type.valueKind === "ScalarValue"; + }, + }, +}); diff --git a/packages/compiler/src/experimental/typekit/utils/get-plausible-name.ts b/packages/compiler/src/experimental/typekit/utils/get-plausible-name.ts new file mode 100644 index 0000000000..ab73402cc1 --- /dev/null +++ b/packages/compiler/src/experimental/typekit/utils/get-plausible-name.ts @@ -0,0 +1,21 @@ +import { isTemplateInstance } from "../../../core/type-utils.js"; +import { Enum, Interface, Model, Scalar, Union } from "../../../core/types.js"; + +/** + * Get a plausible name for the given type. + * @experimental + */ +export function getPlausibleName(type: Model | Union | Enum | Scalar | Interface) { + let name = type.name; + + if (!name) { + name = "TypeExpression"; // TODO: Implement automatic name generation based on the type context + } + + if (isTemplateInstance(type)) { + const namePrefix = type.templateMapper.args.map((a) => ("name" in a && a.name) || "").join("_"); + name = `${namePrefix}${name}`; + } + + return name; +} diff --git a/packages/compiler/src/experimental/typekit/utils/index.ts b/packages/compiler/src/experimental/typekit/utils/index.ts new file mode 100644 index 0000000000..621919dff5 --- /dev/null +++ b/packages/compiler/src/experimental/typekit/utils/index.ts @@ -0,0 +1 @@ +export * from "./get-plausible-name.js"; diff --git a/packages/compiler/test/experimental/typekit/builtin.test.ts b/packages/compiler/test/experimental/typekit/builtin.test.ts new file mode 100644 index 0000000000..4b59ab9a10 --- /dev/null +++ b/packages/compiler/test/experimental/typekit/builtin.test.ts @@ -0,0 +1,157 @@ +import { beforeAll, expect, it } from "vitest"; +import { $ } from "../../../src/experimental/typekit/index.js"; +import { createContextMock } from "./utils.js"; +beforeAll(async () => { + // need the side effect of creating the program. + await createContextMock(); +}); + +it("can get the builtin string type", async () => { + const stringType = $.builtin.string; + expect(stringType).toBeDefined(); + expect(stringType.name).toBe("string"); +}); + +it("can get the builtin boolean type", async () => { + const booleanType = $.builtin.boolean; + expect(booleanType).toBeDefined(); + expect(booleanType.name).toBe("boolean"); +}); + +it("can get the builtin numeric type", async () => { + const numericType = $.builtin.numeric; + expect(numericType).toBeDefined(); + expect(numericType.name).toBe("numeric"); +}); + +it("can get the builtin int32 type", async () => { + const int32Type = $.builtin.int32; + expect(int32Type).toBeDefined(); + expect(int32Type.name).toBe("int32"); +}); + +it("can get the builtin int64 type", async () => { + const int64Type = $.builtin.int64; + expect(int64Type).toBeDefined(); + expect(int64Type.name).toBe("int64"); +}); + +it("can get the builtin float32 type", async () => { + const float32Type = $.builtin.float32; + expect(float32Type).toBeDefined(); + expect(float32Type.name).toBe("float32"); +}); + +it("can get the builtin float64 type", async () => { + const float64Type = $.builtin.float64; + expect(float64Type).toBeDefined(); + expect(float64Type.name).toBe("float64"); +}); + +it("can get the builtin bytes type", async () => { + const bytesType = $.builtin.bytes; + expect(bytesType).toBeDefined(); + expect(bytesType.name).toBe("bytes"); +}); + +it("can get the builtin decimal type", async () => { + const decimalType = $.builtin.decimal; + expect(decimalType).toBeDefined(); + expect(decimalType.name).toBe("decimal"); +}); + +it("can get the builtin decimal128 type", async () => { + const decimal128Type = $.builtin.decimal128; + expect(decimal128Type).toBeDefined(); + expect(decimal128Type.name).toBe("decimal128"); +}); + +it("can get the builtin duration type", async () => { + const durationType = $.builtin.duration; + expect(durationType).toBeDefined(); + expect(durationType.name).toBe("duration"); +}); + +it("can get the builtin float type", async () => { + const floatType = $.builtin.float; + expect(floatType).toBeDefined(); + expect(floatType.name).toBe("float"); +}); + +it("can get the builtin int8 type", async () => { + const int8Type = $.builtin.int8; + expect(int8Type).toBeDefined(); + expect(int8Type.name).toBe("int8"); +}); + +it("can get the builtin int16 type", async () => { + const int16Type = $.builtin.int16; + expect(int16Type).toBeDefined(); + expect(int16Type.name).toBe("int16"); +}); + +it("can get the builtin integer type", async () => { + const integerType = $.builtin.integer; + expect(integerType).toBeDefined(); + expect(integerType.name).toBe("integer"); +}); + +it("can get the builtin offsetDateTime type", async () => { + const offsetDateTimeType = $.builtin.offsetDateTime; + expect(offsetDateTimeType).toBeDefined(); + expect(offsetDateTimeType.name).toBe("offsetDateTime"); +}); + +it("can get the builtin plainDate type", async () => { + const plainDateType = $.builtin.plainDate; + expect(plainDateType).toBeDefined(); + expect(plainDateType.name).toBe("plainDate"); +}); + +it("can get the builtin plainTime type", async () => { + const plainTimeType = $.builtin.plainTime; + expect(plainTimeType).toBeDefined(); + expect(plainTimeType.name).toBe("plainTime"); +}); + +it("can get the builtin safeInt type", async () => { + const safeIntType = $.builtin.safeInt; + expect(safeIntType).toBeDefined(); + expect(safeIntType.name).toBe("safeint"); +}); + +it("can get the builtin uint8 type", async () => { + const uint8Type = $.builtin.uint8; + expect(uint8Type).toBeDefined(); + expect(uint8Type.name).toBe("uint8"); +}); + +it("can get the builtin uint16 type", async () => { + const uint16Type = $.builtin.uint16; + expect(uint16Type).toBeDefined(); + expect(uint16Type.name).toBe("uint16"); +}); + +it("can get the builtin uint32 type", async () => { + const uint32Type = $.builtin.uint32; + expect(uint32Type).toBeDefined(); + expect(uint32Type.name).toBe("uint32"); +}); + +it("can get the builtin uint64 type", async () => { + const uint64Type = $.builtin.uint64; + expect(uint64Type).toBeDefined(); + expect(uint64Type.name).toBe("uint64"); +}); + +it("can get the builtin url type", async () => { + const urlType = $.builtin.url; + expect(urlType).toBeDefined(); + expect(urlType.name).toBe("url"); +}); + +it("can get the builtin utcDateTime type", async () => { + const utcDateTimeType = $.builtin.utcDateTime; + expect(utcDateTimeType).toBeDefined(); + expect(utcDateTimeType.name).toBe("utcDateTime"); +}); diff --git a/packages/compiler/test/experimental/typekit/enum.test.ts b/packages/compiler/test/experimental/typekit/enum.test.ts new file mode 100644 index 0000000000..e9aa1588bf --- /dev/null +++ b/packages/compiler/test/experimental/typekit/enum.test.ts @@ -0,0 +1,24 @@ +import { beforeAll, expect, it } from "vitest"; +import { $ } from "../../../src/experimental/typekit/index.js"; +import { createContextMock } from "./utils.js"; +beforeAll(async () => { + // need the side effect of creating the program. + await createContextMock(); +}); + +it("can build enums from unions", () => { + const union = $.union.create({ + name: "Foo", + variants: { + a: 1, + b: 2, + c: 3, + }, + }); + + expect($.union.isValidEnum(union)).toBe(true); + const en = $.enum.createFromUnion(union); + + expect(en.members.size).toBe(3); + expect(en.members.get("a")!.value).toBe(1); +}); diff --git a/packages/compiler/test/experimental/typekit/model-property.test.ts b/packages/compiler/test/experimental/typekit/model-property.test.ts new file mode 100644 index 0000000000..210f566791 --- /dev/null +++ b/packages/compiler/test/experimental/typekit/model-property.test.ts @@ -0,0 +1,23 @@ +import { beforeAll, expect, it } from "vitest"; +import { $ } from "../../../src/experimental/typekit/index.js"; +import { createContextMock } from "./utils.js"; +beforeAll(async () => { + // need the side effect of creating the program. + await createContextMock(); +}); + +it("can build enums from unions", () => { + const modelProperty = $.modelProperty.create({ + name: "Foo", + type: $.union.create({ + name: "Foo", + variants: { + a: 1, + b: 2, + c: 3, + }, + }), + }); + + expect($.modelProperty.is(modelProperty)).toBe(true); +}); diff --git a/packages/compiler/test/experimental/typekit/type.test.ts b/packages/compiler/test/experimental/typekit/type.test.ts index 6cc7f282b2..6bde26307c 100644 --- a/packages/compiler/test/experimental/typekit/type.test.ts +++ b/packages/compiler/test/experimental/typekit/type.test.ts @@ -1,6 +1,7 @@ -import { it } from "vitest"; -import { Model } from "../../../src/core/types.js"; +import { describe, expect, it } from "vitest"; +import { Enum, Model, Scalar, Union } from "../../../src/core/types.js"; import { $ } from "../../../src/experimental/typekit/index.js"; +import { isTemplateInstance } from "../../../src/index.js"; import { getTypes } from "./utils.js"; it("should clone a model", async () => { @@ -16,3 +17,210 @@ it("should clone a model", async () => { const clone = $.type.clone(Foo) as Model; clone.properties.get("props")!.name = "props"; }); + +describe("getPlausibleName", () => { + it("returns the original name if exists", async () => { + const { Foo, Bar, Baz, Qux } = await getTypes( + ` + model Foo { + props: string; + } + + union Bar { + "hi"; + "bye"; + } + + enum Baz { + Baz: "baz"; + }; + + scalar Qux extends string; + `, + ["Foo", "Bar", "Baz", "Qux"], + ); + + expect($.type.getPlausibleName(Foo as Model)).toBe("Foo"); + expect($.type.getPlausibleName(Bar as Union)).toBe("Bar"); + expect($.type.getPlausibleName(Baz as Enum)).toBe("Baz"); + expect($.type.getPlausibleName(Qux as Scalar)).toBe("Qux"); + }); + + it("returns a generated name for anonymous model", async () => { + const { Bar, Test } = await getTypes( + ` + model Foo {t: T, k: K}; + + @test model Bar { + foo: Foo + } + @test model Test { + foo: Foo + } + model Baz {} + model Qux {} + `, + ["Bar", "Test"], + ); + + const Foo = (Bar as Model).properties.get("foo")!.type as Model; + const Foo2 = (Test as Model).properties.get("foo")!.type as Model; + + expect(isTemplateInstance(Foo)).toBe(true); + expect($.type.getPlausibleName(Foo)).toBe("Baz_QuxFoo"); + expect(isTemplateInstance(Foo2)).toBe(true); + expect($.type.getPlausibleName(Foo2)).toBe("Qux_BazFoo"); + }); +}); + +describe("minValue and maxValue", () => { + it("can get the min and max values from number", async () => { + const { myNumber } = await getTypes( + ` + @minValue(1) + @maxValue(10) + scalar myNumber extends numeric; + `, + ["myNumber"], + ); + + const max = $.type.maxValue(myNumber); + const min = $.type.minValue(myNumber); + + expect(max).toBe(10); + expect(min).toBe(1); + }); + + it("can get the min and max values from modelProperty", async () => { + const { A } = await getTypes( + ` + model A { + @minValue(15) + @maxValue(55) + foo: int32; + } + `, + ["A"], + ); + + const max = $.type.maxValue((A as Model).properties.get("foo")!); + const min = $.type.minValue((A as Model).properties.get("foo")!); + + expect(max).toBe(55); + expect(min).toBe(15); + }); +}); + +describe("minLength and maxLength", () => { + it("can get the min and max length from string", async () => { + const { myString } = await getTypes( + ` + @minLength(1) + @maxLength(10) + scalar myString extends string; + `, + ["myString"], + ); + + const max = $.type.maxLength(myString); + const min = $.type.minLength(myString); + + expect(max).toBe(10); + expect(min).toBe(1); + }); + + it("can get the min and max length from modelProperty", async () => { + const { A } = await getTypes( + ` + model A { + @minLength(15) + @maxLength(55) + foo: string; + } + `, + ["A"], + ); + + const max = $.type.maxLength((A as Model).properties.get("foo")!); + const min = $.type.minLength((A as Model).properties.get("foo")!); + + expect(max).toBe(55); + expect(min).toBe(15); + }); +}); + +describe("minItems and maxItems", () => { + it("can get the min and max items from array", async () => { + const { myArray } = await getTypes( + ` + @minItems(1) + @maxItems(10) + model myArray is Array; + `, + ["myArray"], + ); + + const max = $.type.maxItems(myArray); + const min = $.type.minItems(myArray); + + expect(max).toBe(10); + expect(min).toBe(1); + }); + + it("can get the min and max items from modelProperty", async () => { + const { A } = await getTypes( + ` + model A { + @minItems(15) + @maxItems(55) + foo: string[]; + } + `, + ["A"], + ); + + const max = $.type.maxItems((A as Model).properties.get("foo")!); + const min = $.type.minItems((A as Model).properties.get("foo")!); + + expect(max).toBe(55); + expect(min).toBe(15); + }); +}); + +describe("minValueExclusive and maxValueExclusive", () => { + it("can get the min and max values from number", async () => { + const { myNumber } = await getTypes( + ` + @minValueExclusive(1) + @maxValueExclusive(10) + scalar myNumber extends numeric; + `, + ["myNumber"], + ); + + const max = $.type.maxValueExclusive(myNumber); + const min = $.type.minValueExclusive(myNumber); + + expect(max).toBe(10); + expect(min).toBe(1); + }); + + it("can get the min and max values from modelProperty", async () => { + const { A } = await getTypes( + ` + model A { + @minValueExclusive(15) + @maxValueExclusive(55) + foo: int32; + } + `, + ["A"], + ); + + const max = $.type.maxValueExclusive((A as Model).properties.get("foo")!); + const min = $.type.minValueExclusive((A as Model).properties.get("foo")!); + + expect(max).toBe(55); + expect(min).toBe(15); + }); +}); diff --git a/packages/emitter-framework/babel.config.js b/packages/emitter-framework/babel.config.js new file mode 100644 index 0000000000..1c30974b36 --- /dev/null +++ b/packages/emitter-framework/babel.config.js @@ -0,0 +1,4 @@ +export default { + sourceMaps: true, + presets: ["@babel/preset-typescript", "@alloy-js/babel-preset"], +}; diff --git a/packages/emitter-framework/package.json b/packages/emitter-framework/package.json new file mode 100644 index 0000000000..d639917a80 --- /dev/null +++ b/packages/emitter-framework/package.json @@ -0,0 +1,58 @@ +{ + "name": "@typespec/emitter-framework", + "version": "0.1.0", + "type": "module", + "main": "dist/index.js", + "scripts": { + "build-src": "babel src -d dist/src --extensions .ts,.tsx", + "build": "tsc -p . && npm run build-src", + "watch-src": "babel src -d dist/src --extensions .ts,.tsx --watch", + "watch-tsc": "tsc -p . --watch", + "watch": "concurrently --kill-others \"npm run watch-tsc\" \"npm run watch-src\"", + "test": "vitest run", + "lint": "eslint . --max-warnings=0", + "lint:fix": "eslint . --fix" + }, + "exports": { + ".": { + "import": "./dist/src/core/index.js" + }, + "./typescript": { + "import": "./dist/src/typescript/index.js" + }, + "./testing": { + "import": "./dist/src/testing/index.js" + } + }, + "keywords": [], + "author": "", + "license": "MIT", + "description": "", + "peerDependencies": { + "@typespec/compiler": "workspace:~", + "@typespec/rest": "workspace:~", + "@typespec/http": "workspace:~" + }, + "dependencies": { + "@alloy-js/core": "^0.5.0", + "@alloy-js/typescript": "^0.5.0" + }, + "devDependencies": { + "@babel/cli": "^7.24.8", + "@babel/core": "^7.26.0", + "@rollup/plugin-babel": "^6.0.4", + "@alloy-js/babel-preset": "^0.1.1", + "concurrently": "^9.1.2", + "typescript": "~5.7.3", + "vitest": "^3.0.5", + "tree-sitter": "^0.21.1", + "tree-sitter-c-sharp": "^0.23.0", + "tree-sitter-java": "^0.23.2", + "tree-sitter-javascript": "^0.23.0", + "tree-sitter-python": "^0.23.2", + "tree-sitter-typescript": "^0.23.0", + "prettier": "~3.4.2", + "minimist": "^1.2.8", + "@types/minimist": "^1.2.5" + } +} diff --git a/packages/emitter-framework/src/core/context/index.ts b/packages/emitter-framework/src/core/context/index.ts new file mode 100644 index 0000000000..9fc9fbffd1 --- /dev/null +++ b/packages/emitter-framework/src/core/context/index.ts @@ -0,0 +1 @@ +export * from "./name-policy-context.js"; diff --git a/packages/emitter-framework/src/core/context/name-policy-context.ts b/packages/emitter-framework/src/core/context/name-policy-context.ts new file mode 100644 index 0000000000..0092b3ede9 --- /dev/null +++ b/packages/emitter-framework/src/core/context/name-policy-context.ts @@ -0,0 +1,16 @@ +import { ComponentContext, createNamedContext, useContext } from "@alloy-js/core"; +import { TransformNamePolicy } from "../transport-name-policy.js"; + +export const TransformNamePolicyContext: ComponentContext = + createNamedContext("TransfromNamePolicy", { + getApplicationName(type) { + return typeof type.name === "string" ? type.name : ""; + }, + getTransportName(type) { + return typeof type.name === "string" ? type.name : ""; + }, + }); + +export function useTransformNamePolicy() { + return useContext(TransformNamePolicyContext)!; +} diff --git a/packages/emitter-framework/src/core/index.ts b/packages/emitter-framework/src/core/index.ts new file mode 100644 index 0000000000..d7327f5082 --- /dev/null +++ b/packages/emitter-framework/src/core/index.ts @@ -0,0 +1,3 @@ +export * from "./context/index.js"; +export * from "./transport-name-policy.js"; +export * from "./write-output.js"; diff --git a/packages/emitter-framework/src/core/transport-name-policy.ts b/packages/emitter-framework/src/core/transport-name-policy.ts new file mode 100644 index 0000000000..6118d0a8a7 --- /dev/null +++ b/packages/emitter-framework/src/core/transport-name-policy.ts @@ -0,0 +1,67 @@ +import { Type } from "@typespec/compiler"; + +/** + * A type that extends `Type` and includes a `name` property that can either be a `string` or `symbol`. + * This type is used to represent objects that have a `name` of type `string` or `symbol`. + * + * @template T - The type that extends `Type` and includes a `name` property of type `string | symbol`. + */ +export type HasName = T & { name: string | symbol }; + +/** + * Interface defining the transformation policy for names. + * It contains methods for obtaining transport and application names based on the input `Type` objects. + */ +export interface TransformNamePolicy { + /** + * Transforms the name of the given `type` into a transport name. + * The `type` must have a `name` property that is a `string | symbol`. + * + * @param type - The object that has a `name` property of type `string | symbol`. + * @returns A string representing the transformed transport name. + */ + getTransportName>(type: T): string; + + /** + * Transforms the name of the given `type` into an application name. + * The `type` must have a `name` property that is a `string | symbol`. + * + * @param type - The object that has a `name` property of type `string | symbol`. + * @returns A string representing the transformed application name. + */ + getApplicationName>(type: T): string; +} + +/** + * Factory function to create a `TransformNamePolicy` instance, which contains the logic for transforming + * transport and application names. The `namer` functions are used to generate the transport and application names. + * + * @param namer - An object with two functions: `transportNamer` and `applicationNamer`, each accepting a `Type` and returning a string. + * @returns A `TransformNamePolicy` object with the implemented methods for transforming names. + */ +export function createTransformNamePolicy(namer: { + transportNamer: >(type: T) => string; + applicationNamer: >(type: T) => string; +}): TransformNamePolicy { + return { + /** + * Transforms the transport name based on the provided `transportNamer` function. + * + * @param type - The object that has a `name` property of type `string | symbol`. + * @returns The transformed transport name as a string. + */ + getTransportName(type) { + return namer.transportNamer(type); + }, + + /** + * Transforms the application name based on the provided `applicationNamer` function. + * + * @param type - The object that has a `name` property of type `string | symbol`. + * @returns The transformed application name as a string. + */ + getApplicationName(type) { + return namer.applicationNamer(type); + }, + }; +} diff --git a/packages/emitter-framework/src/core/write-output.ts b/packages/emitter-framework/src/core/write-output.ts new file mode 100644 index 0000000000..5847da2425 --- /dev/null +++ b/packages/emitter-framework/src/core/write-output.ts @@ -0,0 +1,21 @@ +import { Children, OutputDirectory, render } from "@alloy-js/core"; +import { emitFile, joinPaths } from "@typespec/compiler"; +import { unsafe_$ as $ } from "@typespec/compiler/experimental"; + +export async function writeOutput(rootComponent: Children, emitterOutputDir: string) { + const tree = render(rootComponent); + await writeOutputDirectory(tree, emitterOutputDir); +} + +async function writeOutputDirectory(dir: OutputDirectory, emitterOutputDir: string) { + for (const sub of dir.contents) { + if (Array.isArray(sub.contents)) { + await writeOutputDirectory(sub as OutputDirectory, emitterOutputDir); + } else { + await emitFile($.program, { + content: sub.contents as string, + path: joinPaths(emitterOutputDir, sub.path), + }); + } + } +} diff --git a/packages/emitter-framework/src/lib.ts b/packages/emitter-framework/src/lib.ts new file mode 100644 index 0000000000..7c8e3aa3cc --- /dev/null +++ b/packages/emitter-framework/src/lib.ts @@ -0,0 +1,16 @@ +import { createTypeSpecLibrary } from "@typespec/compiler"; + +export const $lib = createTypeSpecLibrary({ + name: "emitter-framework", + diagnostics: { + "type-declaration-missing-name": { + messages: { + default: "Can't declare a type without a name", + }, + severity: "error", + description: "A type declaration must have a name", + }, + }, +}); + +export const { reportDiagnostic, createDiagnostic } = $lib; diff --git a/packages/emitter-framework/src/testing/index.ts b/packages/emitter-framework/src/testing/index.ts new file mode 100644 index 0000000000..5359fc2283 --- /dev/null +++ b/packages/emitter-framework/src/testing/index.ts @@ -0,0 +1,10 @@ +import { resolvePath } from "@typespec/compiler"; +import { createTestLibrary, TypeSpecTestLibrary } from "@typespec/compiler/testing"; +import { fileURLToPath } from "url"; + +export const EmitterFrameworkTestLibrary: TypeSpecTestLibrary = createTestLibrary({ + name: "@typespec/emitter-framework", + packageRoot: resolvePath(fileURLToPath(import.meta.url), "../../../"), +}); + +export * from "./scenario-test/index.js"; diff --git a/packages/emitter-framework/src/testing/scenario-test/harness.ts b/packages/emitter-framework/src/testing/scenario-test/harness.ts new file mode 100644 index 0000000000..059d22f343 --- /dev/null +++ b/packages/emitter-framework/src/testing/scenario-test/harness.ts @@ -0,0 +1,398 @@ +import { TypeSpecTestLibrary } from "@typespec/compiler/testing"; +import { readdirSync, readFileSync, statSync, writeFileSync } from "fs"; +import minimist from "minimist"; +import path from "path"; +import { format } from "prettier"; +import { afterAll, describe, expect, it } from "vitest"; +import { LanguageConfiguration, SnippetExtractor } from "./snippet-extractor.js"; +import { emitWithDiagnostics } from "./test-host.js"; + +const rawArgs = process.env.TEST_ARGS ? process.env.TEST_ARGS.split(" ") : []; + +// Parse command-line arguments with minimist +const args = minimist(rawArgs, { + alias: { + filter: "f", // Short alias for `--filter` + }, + default: { + filter: undefined, // Default to undefined if no filter is provided + }, +}); + +// Extract the filter paths from the parsed arguments +const filterPaths = args.filter + ? Array.isArray(args.filter) // Handle single or multiple file paths + ? args.filter + : [args.filter] + : undefined; + +const SCENARIOS_UPDATE = process.env["SCENARIOS_UPDATE"] === "true"; + +type EmitterFunction = (tsp: string, namedArgs: Record) => Promise; + +async function assertGetEmittedFile( + testLibrary: TypeSpecTestLibrary, + emitterOutputDir: string, + file: string, + code: string, +) { + const [emittedFiles, diagnostics] = await emitWithDiagnostics( + testLibrary, + emitterOutputDir, + code, + ); + + const errors = diagnostics.filter((d) => d.severity === "error"); + const warnings = diagnostics.filter((d) => d.severity === "warning"); + if (warnings.length > 0) { + // eslint-disable-next-line no-console + console.warn(`Warning compiling code:\n ${warnings.map((x) => x.message).join("\n")}`); + } + if (errors.length > 0) { + throw new Error(`Error compiling code:\n ${errors.map((x) => x.message).join("\n")}`); + } + + const sourceFile = emittedFiles.find((x) => x.path === file); + + if (!sourceFile) { + throw new Error( + `File ${file} not found in emitted files:\n ${emittedFiles.map((f) => f.path).join("\n")}`, + ); + } + return sourceFile; +} + +/** + * Mapping of different snapshot types to how to get them. + * Snapshot types can take single-word string arguments templated in curly braces {} and are otherwise regex + */ +function getCodeBlockTypes( + testLibrary: TypeSpecTestLibrary, + languageConfiguration: LanguageConfiguration, + emitterOutputDir: string, + snippetExtractor: SnippetExtractor, +): Record { + const languageTags = languageConfiguration.codeBlockTypes.join("|"); + return { + // Snapshot of a particular interface named {name} in the models file + [`(${languageTags}) {file} interface {name}`]: async (code, { file, name }) => { + const sourceFile = await assertGetEmittedFile(testLibrary, emitterOutputDir, file, code); + const snippet = snippetExtractor.getInterface(sourceFile.content, name); + + if (!snippet) { + throw new Error(`Interface ${name} not found in ${file}`); + } + + return snippet; + }, + + [`(${languageTags}) {file} type {name}`]: async (code, { file, name }) => { + const sourceFile = await assertGetEmittedFile(testLibrary, emitterOutputDir, file, code); + const snippet = snippetExtractor.getTypeAlias(sourceFile.content, name); + + if (!snippet) { + throw new Error(`Type alias ${name} not found in ${file}`); + } + + return snippet; + }, + + // Snapshot of a particular function named {name} in the models file + [`(${languageTags}) {file} function {name}`]: async (code, { file, name }) => { + const sourceFile = await assertGetEmittedFile(testLibrary, emitterOutputDir, file, code); + const snippet = snippetExtractor.getFunction(sourceFile.content, name); + + if (!snippet) { + throw new Error(`Function ${name} not found in ${file}`); + } + + return snippet; + }, + + // Snapshot of a particular class named {name} in the models file + [`(${languageTags}) {file} class {name}`]: async (code, { file, name }) => { + const sourceFile = await assertGetEmittedFile(testLibrary, emitterOutputDir, file, code); + const snippet = snippetExtractor.getClass(sourceFile.content, name); + + if (!snippet) { + throw new Error(`Class ${name} not found in ${file}`); + } + + return snippet; + }, + + // Snapshot of the entire file + [`(${languageTags}) {file}`]: async (code, { file }) => { + const sourceFile = await assertGetEmittedFile(testLibrary, emitterOutputDir, file, code); + return sourceFile.content; + }, + }; +} + +export async function executeScenarios( + testLibrary: TypeSpecTestLibrary, + languageConfiguration: LanguageConfiguration, + scenariosLocation: string, + emitterOutputDir: string, + snippetExtractor: SnippetExtractor, +) { + const scenarioList = filterPaths ?? []; + // eslint-disable-next-line no-console + scenarioList.length && console.log("Filtering scenarios: ", scenarioList); + + if (!scenarioList.length) { + // Add all scenarios. + discoverAllScenarios(scenariosLocation, scenarioList); + } + + describeScenarios( + scenarioList, + testLibrary, + languageConfiguration, + emitterOutputDir, + snippetExtractor, + ); +} + +function discoverAllScenarios(location: string, scenarios: string[]) { + const children = readdirSync(location); + for (const child of children) { + const fullPath = path.join(location, child); + const stat = statSync(fullPath); + if (stat.isDirectory()) { + discoverAllScenarios(fullPath, scenarios); + } else { + scenarios.push(fullPath); + } + } + + return scenarios; +} +interface Scenario { + // The title of the scenario delimited by H1 + title: string; + // The content of the scenario + content: ScenarioContents; +} + +interface ScenarioContents { + lines: Array; + specBlock: SpecCodeBlock; + testBlocks: TestCodeBlock[]; +} + +interface SpecCodeBlock { + kind: "spec" | "test"; + content: string[]; +} + +interface TestCodeBlock { + kind: "test"; + heading: string; + content: string[]; + matchedTemplate: { + template: string; + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type + fn: Function; + namedArgs: Record | null; + }; +} + +type ScenarioCodeBlock = SpecCodeBlock | TestCodeBlock; + +interface ScenarioFile { + path: string; + scenarios: Scenario[]; +} + +function parseFile( + path: string, + testLibrary: TypeSpecTestLibrary, + languageConfiguration: LanguageConfiguration, + emitterOutputDir: string, + snippetExtractor: SnippetExtractor, +): ScenarioFile { + // Read the whole file + const rawContent = readFileSync(path, { encoding: "utf-8" }); + + // Split the content by H1 + const sections = splitByH1(rawContent); + + const scenarioFile: ScenarioFile = { + path, + scenarios: [], + }; + + for (const section of sections) { + const scenarioContent = parseScenario( + section.content, + testLibrary, + languageConfiguration, + emitterOutputDir, + snippetExtractor, + ); + const scenario: Scenario = { + title: section.title, + content: scenarioContent, + }; + + scenarioFile.scenarios.push(scenario); + } + + return scenarioFile; +} + +function isTestCodeBlock(codeBlock: ScenarioCodeBlock): codeBlock is TestCodeBlock { + return codeBlock.kind === "test"; +} + +function parseScenario( + content: string, + testLibrary: TypeSpecTestLibrary, + languageConfiguration: LanguageConfiguration, + emitterOutputDir: string, + snippetExtractor: SnippetExtractor, +): ScenarioContents { + const rawLines = content.split("\n"); + const scenario: ScenarioContents = { + lines: [], + specBlock: { kind: "spec", content: [] }, + testBlocks: [], + }; + + let currentCodeBlock: ScenarioCodeBlock | null = null; + + // Precompute output code block types once + const outputCodeBlockTypes = getCodeBlockTypes( + testLibrary, + languageConfiguration, + emitterOutputDir, + snippetExtractor, + ); + + for (const line of rawLines) { + if (line.startsWith("```")) { + if (currentCodeBlock) { + // Close the code block + scenario.lines.push(currentCodeBlock); + if (!isTestCodeBlock(currentCodeBlock)) { + scenario.specBlock.content = currentCodeBlock.content; + } else { + for (const [template, fn] of Object.entries(outputCodeBlockTypes)) { + const templateRegex = new RegExp( + "^" + template.replace(/\{(\w+)\}/g, "(?<$1>[^\\s]+)") + "$", + ); + + const match = currentCodeBlock.heading.match(templateRegex); + if (match) { + currentCodeBlock.matchedTemplate = { + template, + fn, + namedArgs: match.groups ?? null, + }; + break; + } + } + scenario.testBlocks.push(currentCodeBlock); + } + currentCodeBlock = null; + } else { + const codeBlockKind = line.includes("tsp") || line.includes("typespec") ? "spec" : "test"; + // Start a new code block + currentCodeBlock = { kind: codeBlockKind, heading: line.substring(3), content: [] }; + } + } else if (currentCodeBlock) { + // Append to code block content + currentCodeBlock.content.push(line); + } else { + // Add regular line + scenario.lines.push(line); + } + } + + return scenario; +} + +function describeScenarios( + scenarioFiles: string[], + testLibrary: TypeSpecTestLibrary, + languageConfiguration: LanguageConfiguration, + emitterOutputDir: string, + snippetExtractor: SnippetExtractor, +) { + const scenarios = scenarioFiles.map((f) => + parseFile(f, testLibrary, languageConfiguration, emitterOutputDir, snippetExtractor), + ); + + for (const scenarioFile of scenarios) { + describe(`Scenario File: ${scenarioFile.path}`, () => { + for (const scenario of scenarioFile.scenarios) { + const isOnly = scenario.title.includes("only:"); + const isSkip = scenario.title.includes("skip:"); + + const describeFn = isSkip ? describe.skip : isOnly ? describe.only : describe; + + describeFn(`Scenario: ${scenario.title}`, () => { + for (const testBlock of scenario.content.testBlocks) { + it(`Test: ${testBlock.heading}`, async () => { + const { fn, namedArgs } = testBlock.matchedTemplate; + const result = await fn( + scenario.content.specBlock.content.join("\n"), + namedArgs ?? {}, + ); + + if (SCENARIOS_UPDATE) { + testBlock.content = (await languageConfiguration.format(result)).split("\n"); + } else { + const expected = await languageConfiguration.format(testBlock.content.join("\n")); + const actual = await languageConfiguration.format(result); + expect(actual).toBe(expected); + } + }); + } + }); + } + + // Update after all the tests in the scenario if write mode was enabled + afterAll(async function () { + if (SCENARIOS_UPDATE) { + await updateFile(scenarioFile); + } + }); + }); + } +} + +async function updateFile(scenarioFile: ScenarioFile) { + const newContent: string[] = []; + + for (const scenario of scenarioFile.scenarios) { + newContent.push(`# ${scenario.title}`); + for (const line of scenario.content.lines) { + if (typeof line === "string") { + newContent.push(line); + } else { + const heading = isTestCodeBlock(line) ? line.heading : "tsp"; + newContent.push("```" + heading); + newContent.push(...line.content); + newContent.push("```"); + } + } + } + + const formattedContent = await format(newContent.join("\n"), { parser: "markdown" }); + writeFileSync(scenarioFile.path, formattedContent, { encoding: "utf-8" }); +} + +function splitByH1(content: string): { title: string; content: string }[] { + const sections = content.split(/\n(?=# )/).map((section) => { + const lines = section.split("\n"); + const title = lines.shift()!.replace(/^#+\s+/, ""); + return { + title, + content: lines.join("\n"), + }; + }); + + return sections; +} diff --git a/packages/emitter-framework/src/testing/scenario-test/index.ts b/packages/emitter-framework/src/testing/scenario-test/index.ts new file mode 100644 index 0000000000..464804cb27 --- /dev/null +++ b/packages/emitter-framework/src/testing/scenario-test/index.ts @@ -0,0 +1,3 @@ +export * from "./harness.js"; +export * from "./snippet-extractor.js"; +export * from "./test-host.js"; diff --git a/packages/emitter-framework/src/testing/scenario-test/snippet-extractor.ts b/packages/emitter-framework/src/testing/scenario-test/snippet-extractor.ts new file mode 100644 index 0000000000..2d7250ba41 --- /dev/null +++ b/packages/emitter-framework/src/testing/scenario-test/snippet-extractor.ts @@ -0,0 +1,200 @@ +import { format } from "prettier"; +import Parser from "tree-sitter"; +import CSharpLanguage from "tree-sitter-c-sharp"; +import JavaLanguage from "tree-sitter-java"; +import PythonLanguage from "tree-sitter-python"; +import TypeScriptLanguage from "tree-sitter-typescript"; + +// Interface for SnippetExtractor +export interface SnippetExtractor { + getClass(fileContent: string, name: string): string | null; + getFunction(fileContent: string, name: string): string | null; + getInterface(fileContent: string, name: string): string | null; + getTypeAlias(fileContent: string, name: string): string | null; + getEnum(fileContent: string, name: string): string | null; +} + +export function createCSharpExtractorConfig(): LanguageConfiguration { + return { + codeBlockTypes: ["cs", "csharp"], + format: async (content: string) => content, + language: CSharpLanguage, + nodeKindMapping: { + classNodeType: "class_declaration", + functionNodeType: "local_function_statement", + interfaceNodeType: "interface_declaration", + enumNodeType: "enum_declaration", + }, + }; +} + +export function createJavaExtractorConfig(): LanguageConfiguration { + return { + codeBlockTypes: ["java"], + format: async (content: string) => content, + language: JavaLanguage, + nodeKindMapping: { + classNodeType: "class_declaration", + functionNodeType: "method_declaration", + interfaceNodeType: "interface_declaration", + enumNodeType: "enum_declaration", + }, + }; +} + +export function createPythonExtractorConfig(): LanguageConfiguration { + return { + codeBlockTypes: ["py", "python"], + format: async (content: string) => content, + language: PythonLanguage, + nodeKindMapping: { + classNodeType: "class_definition", + functionNodeType: "function_definition", + }, + }; +} + +export function createTypeScriptExtractorConfig(): LanguageConfiguration { + return { + codeBlockTypes: ["ts", "typescript"], + language: TypeScriptLanguage.typescript, + format: async (content: string) => format(content, { parser: "typescript" }), + nodeKindMapping: { + classNodeType: "class_declaration", + functionNodeType: "function_declaration", + interfaceNodeType: "interface_declaration", + typeAliasNodeType: "type_alias_declaration", + enumNodeType: "enum_declaration", + }, + }; +} + +export type Language = { + name: string; + language: unknown; + nodeTypeInfo: unknown[]; +}; + +export interface LanguageConfiguration { + language: Language; + format: (content: string) => Promise; + codeBlockTypes: string[]; + nodeKindMapping: { + classNodeType?: string; + functionNodeType?: string; + interfaceNodeType?: string; + typeAliasNodeType?: string; + enumNodeType?: string; + }; +} + +export function createSnipperExtractor( + languageConfiguration: LanguageConfiguration, +): SnippetExtractor { + return new SnippetExtractorImpl(languageConfiguration); +} + +class SnippetExtractorImpl implements SnippetExtractor { + private readonly languageConfiguration: LanguageConfiguration; + private parser: Parser; + + constructor(languageConfiguration: LanguageConfiguration) { + this.parser = new Parser(); + this.parser.setLanguage(languageConfiguration.language); + this.languageConfiguration = languageConfiguration; + } + + getClass(fileContent: string, name: string): string | null { + const classNodeType = this.languageConfiguration.nodeKindMapping.classNodeType; + if (!classNodeType) { + throw new Error("Class node type is not defined in the language configuration"); + } + const classNode = this.findNodeByTypeAndName(fileContent, classNodeType, name); + return classNode ? this.getCodeFromNode(classNode) : null; + } + + getFunction(fileContent: string, name: string): string | null { + const functionNodeType = this.languageConfiguration.nodeKindMapping.functionNodeType; + if (!functionNodeType) { + throw new Error("Function node type is not defined in the language configuration"); + } + const classNode = this.findNodeByTypeAndName(fileContent, functionNodeType, name); + return classNode ? this.getCodeFromNode(classNode) : null; + } + + getInterface(fileContent: string, name: string): string | null { + const interfaceNodeType = this.languageConfiguration.nodeKindMapping.interfaceNodeType; + if (!interfaceNodeType) { + throw new Error("Interface node type is not defined in the language configuration"); + } + const classNode = this.findNodeByTypeAndName(fileContent, interfaceNodeType, name); + return classNode ? this.getCodeFromNode(classNode) : null; + } + + getTypeAlias(fileContent: string, name: string): string | null { + const typeAliasNodeType = this.languageConfiguration.nodeKindMapping.typeAliasNodeType; + if (!typeAliasNodeType) { + throw new Error("Type Alias node type is not defined in the language configuration"); + } + const typeAliasNode = this.findNodeByTypeAndName(fileContent, typeAliasNodeType, name); + return typeAliasNode ? this.getCodeFromNode(typeAliasNode) : null; + } + + getEnum(fileContent: string, name: string): string | null { + const enumNodeType = this.languageConfiguration.nodeKindMapping.enumNodeType; + if (!enumNodeType) { + throw new Error("Enum node type is not defined in the language configuration"); + } + const enumNode = this.findNodeByTypeAndName(fileContent, enumNodeType, name); + return enumNode ? this.getCodeFromNode(enumNode) : null; + } + + // Helper function to extract code from a node + private getCodeFromNode(node: Parser.SyntaxNode): string { + // Walk backward to include preceding nodes like 'export', 'public', etc. + let startIndex = node.startIndex; + let current = node.previousSibling; + + // Check for any modifiers (like 'export') that appear before the node + while ( + current && + (current.type === "export" || current.type === "modifier" || current.type === "annotation") + ) { + startIndex = current.startIndex; + current = current.previousSibling; + } + + // Extract the full text from the adjusted start to the end of the node + const code = node.tree.rootNode.text.slice(startIndex, node.endIndex); + return code; + } + + // Helper function to find a node by type and name in AST + private findNodeByTypeAndName( + fileContent: string, + type: string, + name: string, + ): Parser.SyntaxNode | null { + const tree = this.parser.parse(fileContent); + const rootNode = tree.rootNode; // Start from the root node + + const traverse = (node: Parser.SyntaxNode): Parser.SyntaxNode | null => { + if (node.type === type && node.childForFieldName("name")?.text === name) { + return node; + } + + for (let i = 0; i < node.childCount; i++) { + const childNode = node.child(i); + if (childNode) { + // Ensure the childNode is not null + const found = traverse(childNode); + if (found) return found; + } + } + + return null; + }; + + return traverse(rootNode); // Start traversal from the root node + } +} diff --git a/packages/emitter-framework/src/testing/scenario-test/test-host.ts b/packages/emitter-framework/src/testing/scenario-test/test-host.ts new file mode 100644 index 0000000000..ff59797e2f --- /dev/null +++ b/packages/emitter-framework/src/testing/scenario-test/test-host.ts @@ -0,0 +1,83 @@ +import { CompilerOptions, Diagnostic } from "@typespec/compiler"; +import { + BasicTestRunner, + createTestHost, + createTestWrapper, + TestHostConfig, + TypeSpecTestLibrary, +} from "@typespec/compiler/testing"; +import { HttpTestLibrary } from "@typespec/http/testing"; +import { RestTestLibrary } from "@typespec/rest/testing"; +import { join, relative } from "path"; + +export interface EmittedFile { + path: string; + content: string; +} + +async function createEmitterTestRunner( + testLibrary: TypeSpecTestLibrary, + options: { + testHostConfig?: TestHostConfig; + autoImports?: string[]; + autoUsings?: string[]; + compilerOptions?: CompilerOptions; + } = {}, +) { + const libraries = options.testHostConfig?.libraries ?? [ + testLibrary, + HttpTestLibrary, + RestTestLibrary, + ]; + const host = await createTestHost({ libraries }); + + return createTestWrapper(host, { + autoImports: options.autoImports ?? ["@typespec/http", "@typespec/rest"], + autoUsings: options.autoUsings ?? ["TypeSpec.Http", "TypeSpec.Rest"], + compilerOptions: options.compilerOptions ?? { + noEmit: false, + emit: [testLibrary.name], + }, + }); +} + +export async function emitWithDiagnostics( + testLibrary: TypeSpecTestLibrary, + emitterOutputDir: string, + code: string, +): Promise<[EmittedFile[], readonly Diagnostic[]]> { + const runner = await createEmitterTestRunner(testLibrary); + await runner.compileAndDiagnose(code, { + outputDir: "tsp-output", + }); + const result = await readFilesRecursively(emitterOutputDir, emitterOutputDir, runner); + return [result, runner.program.diagnostics]; +} + +async function readFilesRecursively( + currentDir: string, + emitterOutputDir: string, + runner: BasicTestRunner, +): Promise { + const entries = await runner.program.host.readDir(currentDir); + const result: EmittedFile[] = []; + + for (const entry of entries) { + const fullPath = join(currentDir, entry); + const stat = await runner.program.host.stat(fullPath); + + if (stat.isDirectory()) { + // Recursively read files in the directory + const nestedFiles = await readFilesRecursively(fullPath, emitterOutputDir, runner); + result.push(...nestedFiles); + } else if (stat.isFile()) { + // Read the file + // Read the file and store it with a relative path + const relativePath = relative(emitterOutputDir, fullPath); + const fileContent = await runner.program.host.readFile(fullPath); + result.push({ path: relativePath, content: fileContent.text }); + } + } + + return result; +} diff --git a/packages/emitter-framework/src/typescript/components/array-expression.tsx b/packages/emitter-framework/src/typescript/components/array-expression.tsx new file mode 100644 index 0000000000..23c3694373 --- /dev/null +++ b/packages/emitter-framework/src/typescript/components/array-expression.tsx @@ -0,0 +1,12 @@ +import { Type } from "@typespec/compiler"; +import { TypeExpression } from "./type-expression.js"; + +export interface ArrayExpressionProps { + elementType: Type; +} + +export function ArrayExpression({ elementType }: ArrayExpressionProps) { + return <> + Array{"<"}{">"} + ; +} diff --git a/packages/emitter-framework/src/typescript/components/class-method.tsx b/packages/emitter-framework/src/typescript/components/class-method.tsx new file mode 100644 index 0000000000..191825a2c3 --- /dev/null +++ b/packages/emitter-framework/src/typescript/components/class-method.tsx @@ -0,0 +1,39 @@ +import { refkey as getRefkey } from "@alloy-js/core"; +import * as ts from "@alloy-js/typescript"; +import { Operation } from "@typespec/compiler"; +import { buildParameterDescriptors, getReturnType } from "../utils/operation.js"; +import { TypeExpression } from "./type-expression.jsx"; + +export interface ClassMethodPropsWithType extends Omit { + type: Operation; + name?: string; + parametersMode?: "prepend" | "append" | "replace"; +} + +export type ClassMethodProps = ClassMethodPropsWithType | ts.ClassMethodProps; + +export function ClassMethod(props: ClassMethodProps) { + if (!isTypedMethodDeclarationProps(props)) { + return ; + } + + const refkey = props.refkey ?? getRefkey(props.type, "method"); + + const name = props.name ? props.name : ts.useTSNamePolicy().getName(props.type.name, "function"); + const returnType = + props.returnType === null ? undefined : ; + + return + {props.children} + ; +} + +function isTypedMethodDeclarationProps(props: ClassMethodProps): props is ClassMethodPropsWithType { + return (props as ClassMethodPropsWithType).type !== undefined; +} diff --git a/packages/emitter-framework/src/typescript/components/enum-declaration.tsx b/packages/emitter-framework/src/typescript/components/enum-declaration.tsx new file mode 100644 index 0000000000..af5d648884 --- /dev/null +++ b/packages/emitter-framework/src/typescript/components/enum-declaration.tsx @@ -0,0 +1,63 @@ +import { mapJoin, Refkey, refkey } from "@alloy-js/core"; +import * as ts from "@alloy-js/typescript"; +import { Enum, EnumMember as TspEnumMember, Union } from "@typespec/compiler"; +import { $ } from "@typespec/compiler/experimental/typekit"; +import { reportDiagnostic } from "../../lib.js"; + +export interface EnumDeclarationProps extends Omit { + name?: string; + type: Union | Enum; +} + +export function EnumDeclaration(props: EnumDeclarationProps) { + let type: Enum; + if ($.union.is(props.type)) { + if (!$.union.isValidEnum(props.type)) { + throw new Error("The provided union type cannot be represented as an enum"); + } + type = $.enum.createFromUnion(props.type); + } else { + type = props.type; + } + + const members = mapJoin( + type.members, + (key, value) => { + return ; + }, + { joiner: ",\n" }, + ); + + if (!props.type.name || props.type.name === "") { + reportDiagnostic($.program, { code: "type-declaration-missing-name", target: props.type }); + } + + const name = props.name ?? ts.useTSNamePolicy().getName(props.type.name!, "enum"); + + return + { members } + ; +} + +export interface EnumMemberProps { + type: TspEnumMember; + refkey?: Refkey; +} + +export function EnumMember(props: EnumMemberProps) { + return ; +} diff --git a/packages/emitter-framework/src/typescript/components/function-declaration.tsx b/packages/emitter-framework/src/typescript/components/function-declaration.tsx new file mode 100644 index 0000000000..6398ae4b3f --- /dev/null +++ b/packages/emitter-framework/src/typescript/components/function-declaration.tsx @@ -0,0 +1,128 @@ +import { refkey as getRefkey } from "@alloy-js/core"; +import * as ts from "@alloy-js/typescript"; +import { Model, Operation } from "@typespec/compiler"; +import { buildParameterDescriptors, getReturnType } from "../utils/operation.js"; +import { TypeExpression } from "./type-expression.js"; + +export interface FunctionDeclarationPropsWithType + extends Omit { + type: Operation; + name?: string; + parametersMode?: "prepend" | "append" | "replace"; +} + +export type FunctionDeclarationProps = + | FunctionDeclarationPropsWithType + | ts.FunctionDeclarationProps; + +export function FunctionDeclaration(props: FunctionDeclarationProps) { + if (!isTypedFunctionDeclarationProps(props)) { + if (!props.name) { + } + return ; + } + + const refkey = props.refkey ?? getRefkey(props.type); + + let name = props.name ? props.name : ts.useTSNamePolicy().getName(props.type.name, "function"); + + // TODO: This should probably be a broader check in alloy to guard\ + // any identifier. + if (reservedFunctionKeywords.has(name)) { + name = `${name}_`; + } + + const returnType = props.returnType ?? ; + const allParameters = buildParameterDescriptors(props.type.parameters, { + params: props.parameters, + mode: props.parametersMode, + }); + return + + {props.children} + ; +} + +export interface TypedFunctionParametersProps extends Omit { + type: Model; + name?: string; +} + +export type FunctionParametersProps = TypedFunctionParametersProps | ts.FunctionParametersProps; + +FunctionDeclaration.Parameters = function Parameters(props: FunctionParametersProps) { + if (!isTypedFunctionParametersProps(props)) { + return ; + } + + const parameterDescriptors = buildParameterDescriptors(props.type); + return + {props.children} + ; +}; + +function isTypedFunctionDeclarationProps( + props: FunctionDeclarationProps, +): props is FunctionDeclarationPropsWithType { + return "type" in props; +} + +function isTypedFunctionParametersProps( + props: FunctionParametersProps, +): props is TypedFunctionParametersProps { + return "type" in props; +} + +const reservedFunctionKeywords = new Set([ + "break", + "case", + "catch", + "class", + "const", + "continue", + "debugger", + "default", + "delete", + "do", + "else", + "enum", + "export", + "extends", + "finally", + "for", + "function", + "if", + "import", + "in", + "instanceof", + "new", + "return", + "super", + "switch", + "this", + "throw", + "try", + "typeof", + "var", + "void", + "while", + "with", + "yield", + "let", + "static", + "implements", + "interface", + "package", + "private", + "protected", + "public", + "await", +]); diff --git a/packages/emitter-framework/src/typescript/components/index.ts b/packages/emitter-framework/src/typescript/components/index.ts new file mode 100644 index 0000000000..46d6034651 --- /dev/null +++ b/packages/emitter-framework/src/typescript/components/index.ts @@ -0,0 +1,10 @@ +export * from "./class-method.js"; +export * from "./function-declaration.js"; +export * from "./interface-declaration.js"; +export * from "./interface-member.js"; +export * from "./static-serializers.js"; +export * from "./type-declaration.js"; +export * from "./type-expression.js"; +export * from "./type-transform.js"; +export * from "./union-declaration.js"; +export * from "./union-expression.js"; diff --git a/packages/emitter-framework/src/typescript/components/interface-declaration.tsx b/packages/emitter-framework/src/typescript/components/interface-declaration.tsx new file mode 100644 index 0000000000..8e8aa2f7d2 --- /dev/null +++ b/packages/emitter-framework/src/typescript/components/interface-declaration.tsx @@ -0,0 +1,137 @@ +import { Children, code, refkey as getRefkey, mapJoin } from "@alloy-js/core"; +import * as ts from "@alloy-js/typescript"; +import { Interface, Model, ModelProperty, Operation, RekeyableMap } from "@typespec/compiler"; +import { $ } from "@typespec/compiler/experimental/typekit"; +import { createRekeyableMap } from "@typespec/compiler/utils"; +import { reportDiagnostic } from "../../lib.js"; +import { InterfaceMember } from "./interface-member.js"; +import { TypeExpression } from "./type-expression.jsx"; +export interface TypedInterfaceDeclarationProps extends Omit { + type: Model | Interface; + name?: string; +} + +export type InterfaceDeclarationProps = + | TypedInterfaceDeclarationProps + | ts.InterfaceDeclarationProps; + +export function InterfaceDeclaration(props: InterfaceDeclarationProps) { + if (!isTypedInterfaceDeclarationProps(props)) { + return ; + } + + const namePolicy = ts.useTSNamePolicy(); + + let name = props.name ?? props.type.name; + + if (!name || name === "") { + reportDiagnostic($.program, { code: "type-declaration-missing-name", target: props.type }); + } + + name = namePolicy.getName(name, "interface"); + + const refkey = props.refkey ?? getRefkey(props.type); + + const extendsType = props.extends ?? getExtendsType(props.type); + + const members = props.type ? membersFromType(props.type) : []; + + const children = [...members]; + + if (Array.isArray(props.children)) { + children.push(...props.children); + } else if (props.children) { + children.push(props.children); + } + + return + {children} + ; +} + +function isTypedInterfaceDeclarationProps( + props: InterfaceDeclarationProps, +): props is TypedInterfaceDeclarationProps { + return "type" in props; +} + +export interface InterfaceExpressionProps extends ts.InterfaceExpressionProps { + type: Model | Interface; +} + +export function InterfaceExpression({ type, children }: InterfaceExpressionProps) { + const members = type ? membersFromType(type) : []; + + return <> + {"{"} + {members} + {children} + {"}"} + ; +} + +function getExtendsType(type: Model | Interface): Children | undefined { + if (!$.model.is(type)) { + return undefined; + } + + const extending: Children[] = []; + + const recordExtends = code`Record`; + + if (type.baseModel) { + if ($.array.is(type.baseModel)) { + extending.push(); + } else if ($.record.is(type.baseModel)) { + extending.push(recordExtends); + // When extending a record we need to override the element type to be unknown to avoid type errors + } else { + extending.push(getRefkey(type.baseModel)); + } + } + + const spreadType = $.model.getSpreadType(type); + if (spreadType) { + // When extending a record we need to override the element type to be unknown to avoid type errors + if ($.record.is(spreadType)) { + // Here we are in the additional properties land. + // Instead of extending we need to create an envelope property + // do nothing here. + } else { + extending.push(); + } + } + + if (extending.length === 0) { + return undefined; + } + + return mapJoin(extending, (ext) => ext, { joiner: "," }); +} + +function membersFromType(type: Model | Interface) { + let typeMembers: RekeyableMap | undefined; + if ($.model.is(type)) { + typeMembers = $.model.getProperties(type); + const spread = $.model.getSpreadType(type); + if (spread && $.model.is(spread) && $.record.is(spread)) { + typeMembers.set( + "additionalProperties", + $.modelProperty.create({ name: "additionalProperties", optional: true, type: spread }), + ); + } + } else { + typeMembers = createRekeyableMap(type.operations); + } + + return mapJoin(typeMembers, (_, prop) => , { + joiner: "\n", + }); +} diff --git a/packages/emitter-framework/src/typescript/components/interface-member.tsx b/packages/emitter-framework/src/typescript/components/interface-member.tsx new file mode 100644 index 0000000000..7c2b74ba76 --- /dev/null +++ b/packages/emitter-framework/src/typescript/components/interface-member.tsx @@ -0,0 +1,42 @@ +import { useTSNamePolicy } from "@alloy-js/typescript"; +import { isNeverType, ModelProperty, Operation } from "@typespec/compiler"; +import { $ } from "@typespec/compiler/experimental/typekit"; +import { getHttpPart } from "@typespec/http"; +import { FunctionDeclaration } from "./function-declaration.js"; +import { TypeExpression } from "./type-expression.js"; + +export interface InterfaceMemberProps { + type: ModelProperty | Operation; + optional?: boolean; +} + +export function InterfaceMember({ type, optional }: InterfaceMemberProps) { + const namer = useTSNamePolicy(); + const name = namer.getName(type.name, "object-member-getter"); + + if ($.modelProperty.is(type)) { + const optionality = (type.optional ?? optional) ? "?" : ""; + + if (isNeverType(type.type)) { + return null; + } + + let unpackedType = type.type; + const part = getHttpPart($.program, type.type); + if (part) { + unpackedType = part.type; + } + + return <> + "{name}"{optionality}: ; + ; + } + + if ($.operation.is(type)) { + const returnType = ; + const params = ; + return <> + {name}({params}): {returnType}; + ; + } +} diff --git a/packages/emitter-framework/src/typescript/components/record-expression.tsx b/packages/emitter-framework/src/typescript/components/record-expression.tsx new file mode 100644 index 0000000000..0a93888277 --- /dev/null +++ b/packages/emitter-framework/src/typescript/components/record-expression.tsx @@ -0,0 +1,13 @@ +import { code } from "@alloy-js/core"; +import { Type } from "@typespec/compiler"; +import { TypeExpression } from "./type-expression.js"; + +export interface RecordExpressionProps { + elementType: Type; +} + +export function RecordExpression({ elementType }: RecordExpressionProps) { + return code` + Record}> + `; +} diff --git a/packages/emitter-framework/src/typescript/components/static-serializers.tsx b/packages/emitter-framework/src/typescript/components/static-serializers.tsx new file mode 100644 index 0000000000..c383f2195a --- /dev/null +++ b/packages/emitter-framework/src/typescript/components/static-serializers.tsx @@ -0,0 +1,136 @@ +import { code, refkey } from "@alloy-js/core"; +import * as ts from "@alloy-js/typescript"; + +export const DateRfc3339SerializerRefkey = refkey(); +export function DateRfc3339Serializer() { + return + date?: Date + {code` + if (!date) { + return undefined; + } + + return date.toISOString(); + `} + ; +} + +export const DateRfc7231SerializerRefkey = refkey(); +export function DateRfc7231Serializer() { + return + date?: Date + {code` + if (!date) { + return undefined; + } + + return date.toUTCString(); + `} + ; +} + +export const DateDeserializerRefkey = refkey(); +export function DateDeserializer() { + return + date?: string + {code` + if (!date) { + return undefined; + } + + return new Date(date); + `} + ; +} + +export const DateUnixTimestampDeserializerRefkey = refkey(); +export function DateUnixTimestampDeserializer() { + return + date?: number + {code` + if (!date) { + return undefined; + } + + return new Date(date * 1000); + `} + ; +} + +export const DateRfc7231DeserializerRefkey = refkey(); +export function DateRfc7231Deserializer() { + return + date?: string + {code` + if (!date) { + return undefined; + } + + return new Date(date); + `} + ; +} + +export const DateUnixTimestampSerializerRefkey = refkey(); +export function DateUnixTimestampSerializer() { + return + date?: Date + {code` + if (!date) { + return undefined; + } + + return Math.floor(date.getTime() / 1000); + `} + ; +} + +export const RecordSerializerRefkey = refkey(); +export function RecordSerializer() { + const recordType = `Record | undefined`; + const convertFnType = `(item: any) => any`; + return + record?: {recordType}, convertFn?: {convertFnType} + {code` + if (!record) { + return undefined; + } + const output: Record = {}; + + for (const key in record) { + if (Object.prototype.hasOwnProperty.call(record, key)) { + const item = record[key]; + output[key] = convertFn ? convertFn(item) : item; + } + } + + return output; + `} + ; +} + +export const ArraySerializerRefkey = refkey(); +export function ArraySerializer() { + const arrayType = `any[] | undefined`; + const convertFnType = `(item: any) => any`; + return + items?: {arrayType}, convertFn?: {convertFnType} + {code` + if (!items) { + return undefined; + } + + const output: any[] = []; + + for (const item of items) { + if(convertFn) { + output.push(convertFn(item)); + } else { + output.push(item); + } + } + + return output; + `} + ; +} diff --git a/packages/emitter-framework/src/typescript/components/type-alias-declaration.tsx b/packages/emitter-framework/src/typescript/components/type-alias-declaration.tsx new file mode 100644 index 0000000000..71b438c5c5 --- /dev/null +++ b/packages/emitter-framework/src/typescript/components/type-alias-declaration.tsx @@ -0,0 +1,37 @@ +import { refkey as getRefkey } from "@alloy-js/core"; +import * as ts from "@alloy-js/typescript"; +import { Scalar } from "@typespec/compiler"; +import { $ } from "@typespec/compiler/experimental/typekit"; +import { reportDiagnostic } from "../../lib.js"; +import { TypeExpression } from "./type-expression.jsx"; + +export interface TypedAliasDeclarationProps extends Omit { + type: Scalar; + name?: string; +} + +export type TypeAliasDeclarationProps = TypedAliasDeclarationProps | ts.TypeDeclarationProps; + +export function TypeAliasDeclaration(props: TypeAliasDeclarationProps) { + if (!isTypedAliasDeclarationProps(props)) { + return {props.children}; + } + + const originalName = props.name ?? props.type.name; + + if (!originalName || originalName === "") { + reportDiagnostic($.program, { code: "type-declaration-missing-name", target: props.type }); + } + + const name = ts.useTSNamePolicy().getName(originalName, "type"); + return + + {props.children} + ; +} + +function isTypedAliasDeclarationProps( + props: TypeAliasDeclarationProps, +): props is TypedAliasDeclarationProps { + return "type" in props; +} diff --git a/packages/emitter-framework/src/typescript/components/type-declaration.tsx b/packages/emitter-framework/src/typescript/components/type-declaration.tsx new file mode 100644 index 0000000000..55474d1aa5 --- /dev/null +++ b/packages/emitter-framework/src/typescript/components/type-declaration.tsx @@ -0,0 +1,31 @@ +import * as ts from "@alloy-js/typescript"; +import { Type } from "@typespec/compiler"; +import { EnumDeclaration } from "./enum-declaration.js"; +import { InterfaceDeclaration } from "./interface-declaration.jsx"; +import { TypeAliasDeclaration } from "./type-alias-declaration.jsx"; +import { UnionDeclaration } from "./union-declaration.jsx"; + +export interface TypeDeclarationProps extends Omit { + name?: string; + type?: Type; +} + +export type WithRequired = T & { [P in K]-?: T[P] }; + +export function TypeDeclaration(props: TypeDeclarationProps) { + if (!props.type) { + return } />; + } + + const { type, ...restProps } = props; + switch (type.kind) { + case "Model": + return ; + case "Union": + return ; + case "Enum": + return ; + case "Scalar": + return ; + } +} diff --git a/packages/emitter-framework/src/typescript/components/type-expression.tsx b/packages/emitter-framework/src/typescript/components/type-expression.tsx new file mode 100644 index 0000000000..fab78661b2 --- /dev/null +++ b/packages/emitter-framework/src/typescript/components/type-expression.tsx @@ -0,0 +1,166 @@ +import { refkey } from "@alloy-js/core"; +import { Reference, ValueExpression } from "@alloy-js/typescript"; +import { IntrinsicType, Model, Scalar, Type } from "@typespec/compiler"; +import { $ } from "@typespec/compiler/experimental/typekit"; +import "@typespec/http/experimental/typekit"; +import { reportTypescriptDiagnostic } from "../../typescript/lib.js"; +import { ArrayExpression } from "./array-expression.js"; +import { InterfaceExpression } from "./interface-declaration.js"; +import { RecordExpression } from "./record-expression.js"; +import { UnionExpression } from "./union-expression.js"; + +export interface TypeExpressionProps { + type: Type; +} + +export function TypeExpression(props: TypeExpressionProps) { + const type = $.httpPart.unpack(props.type); + if (isDeclaration(type)) { + // todo: probably need abstraction around deciding what's a declaration in the output + // (it may not correspond to things which are declarations in TypeSpec?) + return ; + //throw new Error("Reference not implemented"); + } + + // TODO: Make sure this is an exhaustive switch, including EnumMember and such + switch (type.kind) { + case "Scalar": + case "Intrinsic": + return <>{getScalarIntrinsicExpression(type)}; + case "Boolean": + case "Number": + case "String": + return ; + case "Union": + return ; + case "UnionVariant": + return ; + case "Tuple": + return <> + [{type.values.map((element) => ( + <> + , + + ))}] + ; + case "ModelProperty": + return ; + case "Model": + if ($.array.is(type)) { + const elementType = type.indexer!.value; + return ; + } + + if ($.record.is(type)) { + const elementType = (type as Model).indexer!.value; + return ; + } + + if ($.httpPart.is(type)) { + const partType = $.httpPart.unpack(type); + return ; + } + + return ; + + default: + reportTypescriptDiagnostic($.program, { code: "typescript-unsupported-type", target: type }); + return "any"; + } +} + +const intrinsicNameToTSType = new Map([ + // Core types + ["unknown", "unknown"], // Matches TypeScript's `unknown` + ["string", "string"], // Matches TypeScript's `string` + ["boolean", "boolean"], // Matches TypeScript's `boolean` + ["null", "null"], // Matches TypeScript's `null` + ["void", "void"], // Matches TypeScript's `void` + ["never", "never"], // Matches TypeScript's `never` + ["bytes", "Uint8Array"], // Matches TypeScript's `Uint8Array` + + // Numeric types + ["numeric", "number"], // Parent type for all numeric types + ["integer", "number"], // Broad integer category, maps to `number` + ["float", "number"], // Broad float category, maps to `number` + ["decimal", "number"], // Broad decimal category, maps to `number` + ["decimal128", "number"], // May use libraries for precision + ["int64", "bigint"], // Use `bigint` to handle large 64-bit integers + ["int32", "number"], // 32-bit integer fits in JavaScript's `number` + ["int16", "number"], // 16-bit integer + ["int8", "number"], // 8-bit integer + ["safeint", "number"], // Safe integer fits within JavaScript limits + ["uint64", "bigint"], // Use `bigint` for unsigned 64-bit integers + ["uint32", "number"], // 32-bit unsigned integer + ["uint16", "number"], // 16-bit unsigned integer + ["uint8", "number"], // 8-bit unsigned integer + ["float32", "number"], // Maps to JavaScript's `number` + ["float64", "number"], // Maps to JavaScript's `number` + + // Date and time types + ["plainDate", "string"], // Use `string` for plain calendar dates + ["plainTime", "string"], // Use `string` for plain clock times + ["utcDateTime", "Date"], // Use `Date` for UTC date-times + ["offsetDateTime", "string"], // Use `string` for timezone-specific date-times + ["duration", "string"], // Duration as an ISO 8601 string or custom format + + // String types + ["url", "string"], // Matches TypeScript's `string` +]); + +function getScalarIntrinsicExpression(type: Scalar | IntrinsicType): string | null { + let intrinsicName: string; + if ($.scalar.is(type)) { + if ($.scalar.isUtcDateTime(type) || $.scalar.extendsUtcDateTime(type)) { + const encoding = $.scalar.getEncoding(type); + let emittedType = "Date"; + switch (encoding?.encoding) { + case "unixTimestamp": + case "rfc7231": + case "rfc3339": + default: + emittedType = `Date`; + break; + } + + return emittedType; + } + + intrinsicName = $.scalar.getStdBase(type)?.name ?? ""; + } else { + intrinsicName = type.name; + } + + const tsType = intrinsicNameToTSType.get(intrinsicName); + + if (!tsType) { + reportTypescriptDiagnostic($.program, { code: "typescript-unsupported-scalar", target: type }); + return "any"; + } + + return tsType; +} + +function isDeclaration(type: Type): boolean { + switch (type.kind) { + case "Namespace": + case "Interface": + case "Enum": + case "Operation": + case "EnumMember": + return true; + case "UnionVariant": + return false; + + case "Model": + if ($.array.is(type) || $.record.is(type)) { + return false; + } + + return Boolean(type.name); + case "Union": + return Boolean(type.name); + default: + return false; + } +} diff --git a/packages/emitter-framework/src/typescript/components/type-transform.tsx b/packages/emitter-framework/src/typescript/components/type-transform.tsx new file mode 100644 index 0000000000..3b1ac44e6f --- /dev/null +++ b/packages/emitter-framework/src/typescript/components/type-transform.tsx @@ -0,0 +1,378 @@ +import { Children, code, mapJoin, Refkey, refkey } from "@alloy-js/core"; +import * as ts from "@alloy-js/typescript"; +import { + Discriminator, + Model, + ModelProperty, + RekeyableMap, + Scalar, + Type, + Union, +} from "@typespec/compiler"; +import { $ } from "@typespec/compiler/experimental/typekit"; +import { createRekeyableMap } from "@typespec/compiler/utils"; +import { reportDiagnostic } from "../../lib.js"; +import { reportTypescriptDiagnostic } from "../../typescript/lib.js"; +import { + ArraySerializerRefkey, + DateDeserializerRefkey, + DateRfc3339SerializerRefkey, + RecordSerializerRefkey, +} from "./static-serializers.jsx"; + +export interface TypeTransformProps { + name?: string; + type: Type; + target: "application" | "transport"; + refkey?: Refkey; +} + +export interface UnionTransformProps { + name?: string; + type: Union; + target: "application" | "transport"; +} +function UnionTransformExpression(props: UnionTransformProps) { + const discriminator = $.type.getDiscriminator(props.type); + + if (!discriminator) { + // TODO: Handle non-discriminated unions + reportTypescriptDiagnostic($.program, { + code: "typescript-unsupported-nondiscriminated-union", + target: props.type, + }); + return null; + } + + return ; +} + +interface DiscriminateExpressionProps { + type: Union | Model; + discriminator: Discriminator; + target: "application" | "transport"; +} + +function DiscriminateExpression(props: DiscriminateExpressionProps) { + const discriminatedUnion = $.type.getDiscriminatedUnion(props.type)!; + + const discriminatorRef = `item.${props.discriminator.propertyName}`; + + const unhandledVariant = ` + \n\nconsole.warn(\`Received unknown snake kind: \${${discriminatorRef}}\`); + return item as any; + `; + + return mapJoin( + discriminatedUnion.variants, + (name, variant) => { + return code` + if( ${discriminatorRef} === ${JSON.stringify(name)}) { + return ${} + } + `; + }, + { joiner: "\n\n", ender: unhandledVariant }, + ); +} + +/** + * Component that represents a function declaration that transforms a model to a transport or application model. + */ +export function TypeTransformDeclaration(props: TypeTransformProps) { + const namePolicy = ts.useTSNamePolicy(); + + // Record and array have their general serializers + if ($.array.is(props.type) || $.record.is(props.type)) { + return null; + } + + // TODO: Handle other type of declarations + if (!$.model.is(props.type) && !$.union.is(props.type)) { + return null; + } + + const originalName = props.name ?? props.type.name; + + if (!originalName || originalName === "") { + reportDiagnostic($.program, { + code: "type-declaration-missing-name", + target: props.type, + }); + } + + const baseName = namePolicy.getName(originalName!, "function"); + const functionSuffix = props.target === "application" ? "ToApplication" : "ToTransport"; + const functionName = props.name ? props.name : `${baseName}${functionSuffix}`; + const itemType = + props.target === "application" ? "any" : ; + + let transformExpression: Children; + if ($.model.is(props.type)) { + const discriminator = $.type.getDiscriminator(props.type); + + transformExpression = discriminator ? ( + + ) : ( + <>return ; + ); + } else if ($.union.is(props.type)) { + transformExpression = ; + } else { + reportTypescriptDiagnostic($.program, { + code: "typescript-unsupported-type-transform", + target: props.type, + }); + } + + const returnType = props.target === "application" ? refkey(props.type) : "any"; + + const ref = props.refkey ?? getTypeTransformerRefkey(props.type, props.target); + + return + {transformExpression} + ; +} + +/** + * Gets a refkey for a TypeTransformer function + * @param type type to be transformed + * @param target target of the transformation "application" or "transport" + * @returns the refkey for the TypeTransformer function + */ +export function getTypeTransformerRefkey(type: Type, target: "application" | "transport") { + return refkey(type, target); +} + +export interface ModelTransformExpressionProps { + type: Model; + itemPath?: string[]; + target: "application" | "transport"; + optionsBagName?: string; +} + +/** + * Component that represents an object expression that transforms a model to a transport or application model. + */ +export function ModelTransformExpression(props: ModelTransformExpressionProps) { + if (props.type.baseModel) { + reportTypescriptDiagnostic($.program, { + code: "typescript-extended-model-transform-nyi", + target: props.type, + }); + } + + if ($.model.getSpreadType(props.type)) { + reportTypescriptDiagnostic($.program, { + code: "typescript-spread-model-transformation-nyi", + target: props.type, + }); + } + const namePolicy = ts.useTSNamePolicy(); + const modelProperties: RekeyableMap = createRekeyableMap(); + + $.model.getProperties(props.type).forEach((property) => { + if ($.type.isNever(property.type)) { + return; + } + + modelProperties.set(property.name, property); + }); + + let baseModelTransform: Children = null; + if (props.type.baseModel) { + baseModelTransform = code`...${},\n`; + } + + return + {baseModelTransform} + {mapJoin( + modelProperties, + (_, property) => { + const unpackedType = $.httpPart.unpack(property.type) ?? property.type; + let targetPropertyName = property.name; + let sourcePropertyName = namePolicy.getName(property.name, "interface-member"); + + if (props.target === "application") { + const temp = targetPropertyName; + targetPropertyName = sourcePropertyName; + sourcePropertyName = temp; + } + + const itemPath = [...(props.itemPath ?? []), sourcePropertyName]; + if(property.optional && props.optionsBagName) { + itemPath.unshift(`${props.optionsBagName}?`); + } + + let value = + + if(property.optional && needsTransform(unpackedType)) { + value = <>{itemPath.join(".")} ? : {itemPath.join(".")} + } + + return ; + }, + { joiner: ",\n" } + )} + ; +} + +interface TransformReferenceProps { + type: Type; + target: "application" | "transport"; +} + +/** + * Given a type and target, gets the reference to the transform function + */ +function TransformReference(props: TransformReferenceProps) { + if ($.scalar.is(props.type)) { + return ; + } + + if ($.model.is(props.type) && $.array.is(props.type)) { + return code` + (i: any) => ${]} />} + `; + } + + if ($.model.is(props.type) && $.record.is(props.type)) { + return code` + (i: any) => ${]} />} + `; + } + + if ($.model.is(props.type)) { + return ; + } +} + +interface TransformScalarReferenceProps { + type: Scalar; + target: "application" | "transport"; +} + +/** + * Handles scalar transformations + */ +function TransformScalarReference(props: TransformScalarReferenceProps) { + let reference: Refkey | undefined; + if ($.scalar.isUtcDateTime(props.type)) { + // TODO: Handle encoding, likely to need access to parents to avoid passing the modelProperty + reference = + props.target === "application" ? DateDeserializerRefkey : DateRfc3339SerializerRefkey; + } + + if (reference) { + return ; + } else { + return null; + } +} + +export interface TypeTransformCallProps { + /** + * TypeSpec type to be transformed + */ + type: Type; + /** + * + */ + castInput?: boolean; + /** + * Transformation target + */ + target: "application" | "transport"; + /** + * When type is a model with a single property, collapses the model to the property. + */ + collapse?: boolean; + /** + * Path of the item to be transformed + */ + itemPath?: string[]; + /** + * Name of the options bag to be used when transforming optional properties + */ + optionsBagName?: string; +} + +function needsTransform(type: Type): boolean { + return $.model.is(type) || $.scalar.isUtcDateTime(type); +} + +/** + * This component represents a function call to transform a type + */ +export function TypeTransformCall(props: TypeTransformCallProps) { + const collapsedProperty = getCollapsedProperty(props.type, props.collapse ?? false); + const itemPath = collapsedProperty + ? [...(props.itemPath ?? []), collapsedProperty.name] + : (props.itemPath ?? []); + if (collapsedProperty?.optional) { + itemPath.unshift(`${props.optionsBagName}?`); + } + let itemName: Children = itemPath.join("."); + if (props.castInput) { + itemName = code`${itemName} as ${refkey(props.type)}`; + } + const transformType = collapsedProperty?.type ?? props.type; + if ($.model.is(transformType) && $.array.is(transformType)) { + const unpackedElement = + $.httpPart.unpack($.array.getElementType(transformType)) ?? + $.array.getElementType(transformType); + return , + ]} + />; + } + + if ($.model.is(transformType) && $.record.is(transformType)) { + const unpackedElement = + $.httpPart.unpack($.record.getElementType(transformType)) ?? + $.record.getElementType(transformType); + return , + ]} + />; + } + + if ($.scalar.isUtcDateTime(transformType)) { + return ; + } + + if ($.model.is(transformType)) { + if ($.model.isExpresion(transformType)) { + const effectiveModel = $.model.getEffectiveModel(transformType); + + return ; + } + return ; + } + + return itemName; +} + +function getCollapsedProperty(model: Type, collapse: boolean): ModelProperty | undefined { + if (!$.model.is(model)) { + return undefined; + } + + const modelProperties = $.model.getProperties(model); + if (collapse && modelProperties.size === 1) { + return Array.from(modelProperties.values())[0]; + } + return undefined; +} diff --git a/packages/emitter-framework/src/typescript/components/union-declaration.tsx b/packages/emitter-framework/src/typescript/components/union-declaration.tsx new file mode 100644 index 0000000000..c9fbe105b6 --- /dev/null +++ b/packages/emitter-framework/src/typescript/components/union-declaration.tsx @@ -0,0 +1,40 @@ +import { refkey as getRefkey } from "@alloy-js/core"; +import * as ts from "@alloy-js/typescript"; +import { Enum, Union } from "@typespec/compiler"; +import { $ } from "@typespec/compiler/experimental/typekit"; +import { reportDiagnostic } from "../../lib.js"; +import { UnionExpression } from "./union-expression.js"; + +export interface TypedUnionDeclarationProps extends Omit { + type: Union | Enum; + name?: string; +} + +export type UnionDeclarationProps = TypedUnionDeclarationProps | ts.TypeDeclarationProps; + +export function UnionDeclaration(props: UnionDeclarationProps) { + if (!isTypedUnionDeclarationProps(props)) { + return {props.children}; + } + + const { type, ...coreProps } = props; + const refkey = coreProps.refkey ?? getRefkey(type); + + const originalName = coreProps.name ?? type.name; + + if (!originalName || originalName === "") { + reportDiagnostic($.program, { code: "type-declaration-missing-name", target: type }); + } + + const name = ts.useTSNamePolicy().getName(originalName!, "type"); + + return + {coreProps.children} + ; +} + +function isTypedUnionDeclarationProps( + props: UnionDeclarationProps, +): props is TypedUnionDeclarationProps { + return "type" in props; +} diff --git a/packages/emitter-framework/src/typescript/components/union-expression.tsx b/packages/emitter-framework/src/typescript/components/union-expression.tsx new file mode 100644 index 0000000000..c7f8b8dc87 --- /dev/null +++ b/packages/emitter-framework/src/typescript/components/union-expression.tsx @@ -0,0 +1,37 @@ +import { Children, mapJoin } from "@alloy-js/core"; +import * as ts from "@alloy-js/typescript"; +import { Enum, Union } from "@typespec/compiler"; +import { TypeExpression } from "./type-expression.js"; + +export interface UnionExpressionProps { + type: Union | Enum; + children?: Children; +} + +export function UnionExpression({ type, children }: UnionExpressionProps) { + let variants: any[]; + + if (type.kind === "Enum") { + variants = mapJoin( + type.members, + (_, value) => { + return ; + }, + { joiner: " | " }, + ); + } else { + variants = mapJoin( + type.variants, + (_, variant) => { + return ; + }, + { joiner: " | " }, + ); + } + + if (children || (Array.isArray(children) && children.length)) { + return <>{variants} {` | ${children}`}; + } + + return variants; +} diff --git a/packages/emitter-framework/src/typescript/index.ts b/packages/emitter-framework/src/typescript/index.ts new file mode 100644 index 0000000000..be95a933d2 --- /dev/null +++ b/packages/emitter-framework/src/typescript/index.ts @@ -0,0 +1,2 @@ +export * from "./components/index.js"; +export * from "./utils/index.js"; diff --git a/packages/emitter-framework/src/typescript/lib.ts b/packages/emitter-framework/src/typescript/lib.ts new file mode 100644 index 0000000000..5210ecddd2 --- /dev/null +++ b/packages/emitter-framework/src/typescript/lib.ts @@ -0,0 +1,61 @@ +import { createTypeSpecLibrary } from "@typespec/compiler"; + +export const $typescriptLib = createTypeSpecLibrary({ + name: "emitter-framework", + diagnostics: { + "typescript-unsupported-scalar": { + severity: "warning", + messages: { + default: "Unsupported scalar type, falling back to any", + }, + }, + "typescript-unsupported-type": { + severity: "error", // TODO: Warning for release and error for debug + messages: { + default: "Unsupported type, falling back to any", + }, + description: "This type is not supported by the emitter", + }, + "typescript-unsupported-model-discriminator": { + severity: "error", // TODO: Warning for release and error for debug + messages: { + default: + "Unsupported model discriminator, falling back to not discriminating on serialization/deserialization", + }, + description: "Discriminators at the model are not supported", + }, + "typescript-unsupported-type-transform": { + severity: "error", // TODO: Warning for release and error for debug + messages: { + default: "Unsupported type for transformation, falling back to not transforming this type", + }, + description: "Discriminators at the model are not supported", + }, + "typescript-unsupported-nondiscriminated-union": { + severity: "error", // TODO: Warning for release and error for debug + messages: { + default: "Unsupported non-discriminated union, falling back to not transforming this type", + }, + description: "Non-discriminated unions are not supported", + }, + "typescript-extended-model-transform-nyi": { + severity: "warning", // TODO: Warning for release and error for debug + messages: { + default: "Extended model transformation is not yet implemented", + }, + description: "Extended model transformation is not yet implemented", + }, + "typescript-spread-model-transformation-nyi": { + severity: "warning", // TODO: Warning for release and error for debug + messages: { + default: "Spread model transformation is not yet implemented", + }, + description: "Spread model transformation is not yet implemented", + }, + }, +}); + +export const { + reportDiagnostic: reportTypescriptDiagnostic, + createDiagnostic: CreateTypescriptDiagnostic, +} = $typescriptLib; diff --git a/packages/emitter-framework/src/typescript/utils/index.ts b/packages/emitter-framework/src/typescript/utils/index.ts new file mode 100644 index 0000000000..7825f3ae94 --- /dev/null +++ b/packages/emitter-framework/src/typescript/utils/index.ts @@ -0,0 +1 @@ +export * from "./operation.js"; diff --git a/packages/emitter-framework/src/typescript/utils/operation.ts b/packages/emitter-framework/src/typescript/utils/operation.ts new file mode 100644 index 0000000000..28550218c3 --- /dev/null +++ b/packages/emitter-framework/src/typescript/utils/operation.ts @@ -0,0 +1,61 @@ +import { Children, refkey as getRefkey } from "@alloy-js/core"; +import * as ts from "@alloy-js/typescript"; +import { Model, ModelProperty, Operation, Type } from "@typespec/compiler"; +import { $ } from "@typespec/compiler/experimental/typekit"; +import { TypeExpression } from "../components/type-expression.jsx"; + +export function getReturnType( + type: Operation, + options: { skipErrorFiltering: boolean } = { skipErrorFiltering: false }, +): Type { + let returnType = type.returnType; + + if (!options.skipErrorFiltering && type.returnType.kind === "Union") { + returnType = $.union.filter(type.returnType, (variant) => !$.type.isError(variant.type)); + } + + return returnType; +} + +export interface BuildParameterDescriptorsOptions { + params?: Record; + mode?: "prepend" | "append" | "replace"; +} + +export function buildParameterDescriptors( + type: Model, + options: BuildParameterDescriptorsOptions = {}, +) { + if (options.mode === "replace") { + return options.params; + } + const operationParams: Record = {}; + + const modelProperties = $.model.getProperties(type); + for (const prop of modelProperties.values()) { + const [paramName, paramDescriptor] = buildParameterDescriptor(prop); + operationParams[paramName] = paramDescriptor; + } + + // Merge parameters based on location + const allParams = + options.mode === "append" + ? { ...operationParams, ...options.params } + : { ...options.params, ...operationParams }; + + return allParams; +} + +export function buildParameterDescriptor( + modelProperty: ModelProperty, +): [string, ts.ParameterDescriptor] { + const namePolicy = ts.useTSNamePolicy(); + const paramName = namePolicy.getName(modelProperty.name, "parameter"); + const paramDescriptor: ts.ParameterDescriptor = { + refkey: getRefkey(modelProperty), + optional: modelProperty.optional, + type: TypeExpression({ type: modelProperty.type }), + }; + + return [paramName, paramDescriptor]; +} diff --git a/packages/emitter-framework/test/testing/snippet-extractor-csharp.test.ts b/packages/emitter-framework/test/testing/snippet-extractor-csharp.test.ts new file mode 100644 index 0000000000..434fa9aa59 --- /dev/null +++ b/packages/emitter-framework/test/testing/snippet-extractor-csharp.test.ts @@ -0,0 +1,113 @@ +import { d } from "@alloy-js/core/testing"; +import { beforeEach, describe, expect, it } from "vitest"; +import { + createCSharpExtractorConfig, + createSnipperExtractor, + SnippetExtractor, +} from "../../src/testing/index.js"; +describe("C# Snippet Extractor", () => { + let extractor: SnippetExtractor; + + beforeEach(() => { + extractor = createSnipperExtractor(createCSharpExtractorConfig()); + }); + + it("should extract a class", () => { + const content = d` + public class Foo { + public Foo() { + Console.WriteLine("Hello"); + } + } + `; + + const snippet = extractor.getClass(content, "Foo"); + expect(snippet).toBe(d` + public class Foo { + public Foo() { + Console.WriteLine("Hello"); + } + } + `); + }); + + it("should extract an interface", () => { + const content = d` + public interface IMyInterface { + string Foo(); + } + `; + + const snippet = extractor.getInterface(content, "IMyInterface"); + expect(snippet).toBe(d` + public interface IMyInterface { + string Foo(); + } + `); + }); + + it("should extract a function", () => { + const content = d` + public string Greet(string name) { + return "Hello " + name; + } + `; + + const snippet = extractor.getFunction(content, "Greet"); + expect(snippet).toBe(d` + public string Greet(string name) { + return "Hello " + name; + } + `); + }); +}); + +describe("C# Snippet Extractor - Enums", () => { + let extractor: SnippetExtractor; + + beforeEach(() => { + extractor = createSnipperExtractor(createCSharpExtractorConfig()); + }); + + it("should extract a basic enum", async () => { + const content = d` + public enum Direction + { + Up, + Down, + Left, + Right + } + `; + + const snippet = extractor.getEnum(content, "Direction"); + expect(snippet).toBe(d` + public enum Direction + { + Up, + Down, + Left, + Right + } + `); + }); + + it("should extract an enum with values", async () => { + const content = d` + public enum Status + { + Active = 1, + Inactive = 2 + } + `; + + const snippet = extractor.getEnum(content, "Status"); + expect(snippet).toBe(d` + public enum Status + { + Active = 1, + Inactive = 2 + } + `); + }); +}); diff --git a/packages/emitter-framework/test/testing/snippet-extractor-java.test.ts b/packages/emitter-framework/test/testing/snippet-extractor-java.test.ts new file mode 100644 index 0000000000..04bde11a80 --- /dev/null +++ b/packages/emitter-framework/test/testing/snippet-extractor-java.test.ts @@ -0,0 +1,122 @@ +import { d } from "@alloy-js/core/testing"; +import { beforeEach, describe, expect, it } from "vitest"; +import { + createJavaExtractorConfig, + createSnipperExtractor, + SnippetExtractor, +} from "../../src/testing/index.js"; + +describe("Java Snippet Extractor", () => { + let extractor: SnippetExtractor; + + beforeEach(() => { + extractor = createSnipperExtractor(createJavaExtractorConfig()); + }); + + it("should extract a class", () => { + const content = d` + public class Foo { + public Foo() { + System.out.println("Hello"); + } + } + `; + + const snippet = extractor.getClass(content, "Foo"); + expect(snippet).toBe(d` + public class Foo { + public Foo() { + System.out.println("Hello"); + } + } + `); + }); + + it("should extract an interface", () => { + const content = d` + public interface MyInterface { + String foo(); + } + `; + + const snippet = extractor.getInterface(content, "MyInterface"); + expect(snippet).toBe(d` + public interface MyInterface { + String foo(); + } + `); + }); + + it("should extract a function", () => { + const content = d` + public String greet(String name) { + return "Hello " + name; + } + `; + + const snippet = extractor.getFunction(content, "greet"); + expect(snippet).toBe(d` + public String greet(String name) { + return "Hello " + name; + } + `); + }); +}); + +describe("Java Snippet Extractor - Enums", () => { + let extractor: SnippetExtractor; + + beforeEach(() => { + extractor = createSnipperExtractor(createJavaExtractorConfig()); + }); + + it("should extract a basic enum", async () => { + const content = d` + public enum Direction { + UP, + DOWN, + LEFT, + RIGHT + } + `; + + const snippet = extractor.getEnum(content, "Direction"); + expect(snippet).toBe(d` + public enum Direction { + UP, + DOWN, + LEFT, + RIGHT + } + `); + }); + + it("should extract an enum with constructor values", async () => { + const content = d` + public enum Status { + ACTIVE(1), + INACTIVE(2); + + private final int value; + + Status(int value) { + this.value = value; + } + } + `; + + const snippet = extractor.getEnum(content, "Status"); + expect(snippet).toBe(d` + public enum Status { + ACTIVE(1), + INACTIVE(2); + + private final int value; + + Status(int value) { + this.value = value; + } + } + `); + }); +}); diff --git a/packages/emitter-framework/test/testing/snippet-extractor-python.test.ts b/packages/emitter-framework/test/testing/snippet-extractor-python.test.ts new file mode 100644 index 0000000000..49bcc155e5 --- /dev/null +++ b/packages/emitter-framework/test/testing/snippet-extractor-python.test.ts @@ -0,0 +1,43 @@ +import { d } from "@alloy-js/core/testing"; +import { beforeEach, describe, expect, it } from "vitest"; +import { + createPythonExtractorConfig, + createSnipperExtractor, + SnippetExtractor, +} from "../../src/testing/index.js"; + +describe("Python Snippet Extractor", () => { + let extractor: SnippetExtractor; + + beforeEach(() => { + extractor = createSnipperExtractor(createPythonExtractorConfig()); + }); + + it("should extract a class", () => { + const content = d` + class Foo: + def __init__(self): + print("Hello") + `; + + const snippet = extractor.getClass(content, "Foo"); + expect(snippet).toBe(d` + class Foo: + def __init__(self): + print("Hello") + `); + }); + + it("should extract a function", () => { + const content = d` + def greet(name: str) -> str: + return f"Hello {name}" + `; + + const snippet = extractor.getFunction(content, "greet"); + expect(snippet).toBe(d` + def greet(name: str) -> str: + return f"Hello {name}" + `); + }); +}); diff --git a/packages/emitter-framework/test/testing/snippet-extractor-typescript.test.ts b/packages/emitter-framework/test/testing/snippet-extractor-typescript.test.ts new file mode 100644 index 0000000000..3e9cdef865 --- /dev/null +++ b/packages/emitter-framework/test/testing/snippet-extractor-typescript.test.ts @@ -0,0 +1,181 @@ +import { d } from "@alloy-js/core/testing"; +import { beforeEach, describe, expect, it } from "vitest"; +import { + createSnipperExtractor, + createTypeScriptExtractorConfig, + SnippetExtractor, +} from "../../src/testing/index.js"; + +describe("TypeScript Snippet Extractor", () => { + let extractor: SnippetExtractor; + beforeEach(() => { + extractor = createSnipperExtractor(createTypeScriptExtractorConfig()); + }); + + it("should extract a class", async () => { + const content = d` + function bar(): number { + return 1; + } + class Foo { + constructor() { + console.log("Hello"); + } + } + `; + + const snippet = extractor.getClass(content, "Foo"); + expect(snippet).toBe(d` + class Foo { + constructor() { + console.log("Hello"); + } + } + `); + }); + + it("should extract an exported class", async () => { + const content = d` + function bar(): number { + return 1; + } + export class Foo { + constructor() { + console.log("Hello"); + } + } + `; + + const snippet = extractor.getClass(content, "Foo"); + expect(snippet).toBe(d` + export class Foo { + constructor() { + console.log("Hello"); + } + } + `); + }); + + it("should extract a class that extends another", async () => { + const content = d` + export class Bar { + constructor() { + console.log("Hello"); + } + } + export class Foo extends Bar { + constructor() { + console.log("Hello"); + } + } + `; + + const snippet = extractor.getClass(content, "Foo"); + expect(snippet).toBe(d` + export class Foo extends Bar { + constructor() { + console.log("Hello"); + } + } + `); + }); + + it("should extract a class that implements an interface", async () => { + const content = d` + export interface MyFoo { + bar(): number; + } + export class Foo implements MyFoo { + constructor() { + console.log("Hello"); + } + + bar() { + return 1; + } + } + `; + + const snippet = extractor.getClass(content, "Foo"); + expect(snippet).toBe(d` + export class Foo implements MyFoo { + constructor() { + console.log("Hello"); + } + + bar() { + return 1; + } + } + `); + }); + + it("should extract a generic class", async () => { + const content = d` + export interface MyFoo { + bar(): number; + } + class Box { + contents: Type; + constructor(value: Type) { + this.contents = value; + } + } + `; + + const snippet = extractor.getClass(content, "Box"); + expect(snippet).toBe(d` + class Box { + contents: Type; + constructor(value: Type) { + this.contents = value; + } + } + `); + }); +}); + +describe("TypeScript Snippet Extractor - Enums", () => { + let extractor: SnippetExtractor; + beforeEach(() => { + extractor = createSnipperExtractor(createTypeScriptExtractorConfig()); + }); + + it("should extract a basic enum", async () => { + const content = d` + enum Direction { + Up, + Down, + Left, + Right + } + `; + + const snippet = extractor.getEnum(content, "Direction"); + expect(snippet).toBe(d` + enum Direction { + Up, + Down, + Left, + Right + } + `); + }); + + it("should extract an exported enum", async () => { + const content = d` + export enum Status { + Active, + Inactive + } + `; + + const snippet = extractor.getEnum(content, "Status"); + expect(snippet).toBe(d` + export enum Status { + Active, + Inactive + } + `); + }); +}); diff --git a/packages/emitter-framework/test/typescript/components/enum-declaration.test.tsx b/packages/emitter-framework/test/typescript/components/enum-declaration.test.tsx new file mode 100644 index 0000000000..c0c6de79e4 --- /dev/null +++ b/packages/emitter-framework/test/typescript/components/enum-declaration.test.tsx @@ -0,0 +1,110 @@ +import { refkey } from "@alloy-js/core"; +import { d } from "@alloy-js/core/testing"; +import { Enum, Union } from "@typespec/compiler"; +import { describe, expect, it } from "vitest"; +import { EnumDeclaration } from "../../../src/typescript/components/enum-declaration.js"; +import { getEmitOutput } from "../../utils.js"; + +describe("Typescript Enum Declaration", () => { + it("takes an enum type parameter", async () => { + const code = ` + enum Foo { + one: 1, + two: 2, + three: 3 + } + `; + const output = await getEmitOutput(code, (program) => { + const Foo = program.resolveTypeReference("Foo")[0]! as Enum; + return ; + }); + + expect(output).toBe(d` + enum Foo { + one = 1, + two = 2, + three = 3 + } + `); + }); + + it("takes a union type parameter", async () => { + const code = ` + union Foo { + one: 1, + two: 2, + three: 3 + } + `; + const output = await getEmitOutput(code, (program) => { + const Foo = program.resolveTypeReference("Foo")[0]! as Union; + return ; + }); + + expect(output).toBe(d` + enum Foo { + one = 1, + two = 2, + three = 3 + } + `); + }); + + it("can be referenced", async () => { + const code = ` + enum Foo { + one: 1, + two: 2, + three: 3 + } + `; + + const output = await getEmitOutput(code, (program) => { + const Foo = program.resolveTypeReference("Foo")[0]! as Enum; + return <> + + {refkey(Foo)}; + {refkey(Foo.members.get("one"))}; + ; + }); + + expect(output).toBe(d` + enum Foo { + one = 1, + two = 2, + three = 3 + } + Foo; + Foo.one; + `); + }); + + it("can be referenced using union", async () => { + const code = ` + union Foo { + one: 1, + two: 2, + three: 3 + } + `; + + const output = await getEmitOutput(code, (program) => { + const Foo = program.resolveTypeReference("Foo")[0]! as Union; + return <> + + {refkey(Foo)}; + {refkey(Foo.variants.get("one"))}; + ; + }); + + expect(output).toBe(d` + enum Foo { + one = 1, + two = 2, + three = 3 + } + Foo; + Foo.one; + `); + }); +}); diff --git a/packages/emitter-framework/test/typescript/components/function-declaration.test.tsx b/packages/emitter-framework/test/typescript/components/function-declaration.test.tsx new file mode 100644 index 0000000000..12e3d28957 --- /dev/null +++ b/packages/emitter-framework/test/typescript/components/function-declaration.test.tsx @@ -0,0 +1,226 @@ +import { Output, render } from "@alloy-js/core"; +import { d } from "@alloy-js/core/testing"; +import { SourceFile } from "@alloy-js/typescript"; +import { Namespace } from "@typespec/compiler"; +import { format } from "prettier"; +import { assert, describe, expect, it } from "vitest"; +import { FunctionDeclaration } from "../../../src/typescript/components/function-declaration.js"; +import { getProgram } from "../test-host.js"; +describe("Typescript Function Declaration", () => { + describe("Function bound to Typespec Types", () => { + describe("Bound to Operation", () => { + it("creates a function", async () => { + const program = await getProgram(` + namespace DemoService; + op getName(id: string): string; + `); + + const [namespace] = program.resolveTypeReference("DemoService"); + const operation = Array.from((namespace as Namespace).operations.values())[0]; + + const res = render( + + + + + , + ); + + const testFile = res.contents.find((file) => file.path === "test.ts"); + assert(testFile, "test.ts file not rendered"); + const actualContent = await format(testFile.contents as string, { parser: "typescript" }); + const expectedContent = await format(`function getName(id: string): string{}`, { + parser: "typescript", + }); + expect(actualContent).toBe(expectedContent); + }); + + it("creates an async function", async () => { + const program = await getProgram(` + namespace DemoService; + op getName(id: string): string; + `); + + const [namespace] = program.resolveTypeReference("DemoService"); + const operation = Array.from((namespace as Namespace).operations.values())[0]; + + const res = render( + + + + + , + ); + + const testFile = res.contents.find((file) => file.path === "test.ts"); + assert(testFile, "test.ts file not rendered"); + const actualContent = await format(testFile.contents as string, { parser: "typescript" }); + const expectedContent = await format( + d`async function getName(id: string): Promise { + + + }`, + { + parser: "typescript", + }, + ); + expect(actualContent).toBe(expectedContent); + }); + + it("exports a function", async () => { + const program = await getProgram(` + namespace DemoService; + op getName(id: string): string; + `); + + const [namespace] = program.resolveTypeReference("DemoService"); + const operation = Array.from((namespace as Namespace).operations.values())[0]; + + const res = render( + + + + + , + ); + + const testFile = res.contents.find((file) => file.path === "test.ts"); + assert(testFile, "test.ts file not rendered"); + const actualContent = await format(testFile.contents as string, { parser: "typescript" }); + const expectedContent = await format(`export function getName(id: string): string{}`, { + parser: "typescript", + }); + expect(actualContent).toBe(expectedContent); + }); + + it("can override name", async () => { + const program = await getProgram(` + namespace DemoService; + op getName(id: string): string; + `); + + const [namespace] = program.resolveTypeReference("DemoService"); + const operation = Array.from((namespace as Namespace).operations.values())[0]; + + const res = render( + + + + + , + ); + + const testFile = res.contents.find((file) => file.path === "test.ts"); + assert(testFile, "test.ts file not rendered"); + const actualContent = await format(testFile.contents as string, { parser: "typescript" }); + const expectedContent = await format(`function newName(id: string): string{}`, { + parser: "typescript", + }); + expect(actualContent).toBe(expectedContent); + }); + + it("can append extra parameters with raw params provided", async () => { + const program = await getProgram(` + namespace DemoService; + op createPerson(id: string): string; + `); + + const [namespace] = program.resolveTypeReference("DemoService"); + const operation = Array.from((namespace as Namespace).operations.values())[0]; + + const res = render( + + + + + , + ); + + const testFile = res.contents.find((file) => file.path === "test.ts"); + assert(testFile, "test.ts file not rendered"); + const actualContent = await format(testFile.contents as string, { parser: "typescript" }); + const expectedContent = await format( + `function createPerson(name: string, age: number, id: string): string{}`, + { + parser: "typescript", + }, + ); + expect(actualContent).toBe(expectedContent); + }); + + it.skip("can override parameters with an array of ModelProperties", async () => { + const program = await getProgram(` + namespace DemoService; + op createPerson(id: string): string; + + model Foo { + name: string; + age: int32; + } + `); + + const [namespace] = program.resolveTypeReference("DemoService"); + const operation = Array.from((namespace as Namespace).operations.values())[0]; + const model = Array.from((namespace as Namespace).models.values())[0]; + + const res = render( + + + + + + + , + ); + + const testFile = res.contents.find((file) => file.path === "test.ts"); + assert(testFile, "test.ts file not rendered"); + const actualContent = await format(testFile.contents as string, { parser: "typescript" }); + + const expectedContent = await format( + `function createPerson(name: string, age: number): string{}`, + { parser: "typescript" }, + ); + + expect(actualContent).toBe(expectedContent); + }); + + it("can render function body", async () => { + const program = await getProgram(` + namespace DemoService; + op createPerson(id: string): string; + `); + + const [namespace] = program.resolveTypeReference("DemoService"); + const operation = Array.from((namespace as Namespace).operations.values())[0]; + + const res = render( + + + + const message = "Hello World!"; console.log(message); + + + , + ); + + const testFile = res.contents.find((file) => file.path === "test.ts"); + assert(testFile, "test.ts file not rendered"); + const actualContent = await format(testFile.contents as string, { parser: "typescript" }); + + const expectedContent = await format( + `export function createPerson(id: string): string { + const message = "Hello World!"; + console.log(message); + }`, + { parser: "typescript" }, + ); + + expect(actualContent).toBe(expectedContent); + }); + }); + }); +}); diff --git a/packages/emitter-framework/test/typescript/components/interface-declaration.test.tsx b/packages/emitter-framework/test/typescript/components/interface-declaration.test.tsx new file mode 100644 index 0000000000..2bad001982 --- /dev/null +++ b/packages/emitter-framework/test/typescript/components/interface-declaration.test.tsx @@ -0,0 +1,690 @@ +import { InterfaceDeclaration } from "../../../src/typescript/components/interface-declaration.js"; + +import { mapJoin, Output, render } from "@alloy-js/core"; +import { SourceFile } from "@alloy-js/typescript"; +import { Namespace } from "@typespec/compiler"; +import { format } from "prettier"; +import { assert, describe, expect, it } from "vitest"; +import { getProgram } from "../test-host.js"; + +describe("Typescript Interface", () => { + describe("Interface bound to Typespec Types", () => { + describe("Bound to Model", () => { + it("creates an interface that extends a model for Record spread", async () => { + const program = await getProgram(` + namespace DemoService; + + model DifferentSpreadModelRecord { + knownProp: string; + ...Record; + } + `); + + const [namespace] = program.resolveTypeReference("DemoService"); + const models = Array.from((namespace as Namespace).models.values()); + + const res = render( + + + {models.map((model) => ( + + ))} + + , + ); + + const testFile = res.contents.find((file) => file.path === "test.ts"); + assert(testFile, "test.ts file not rendered"); + const actualContent = await format(testFile.contents as string, { parser: "typescript" }); + const expectedContent = await format( + ` + export interface DifferentSpreadModelRecord { + knownProp: string; + additionalProperties?: Record; + } + `, + { + parser: "typescript", + }, + ); + + expect(actualContent).toBe(expectedContent); + }); + it("creates an interface for a model that 'is' an array ", async () => { + const program = await getProgram(` + namespace DemoService; + + model Foo is Array; + `); + + const [namespace] = program.resolveTypeReference("DemoService"); + const models = (namespace as Namespace).models; + + const res = render( + + + {mapJoin(models, (name, model) => ( + + ))} + + , + ); + + const testFile = res.contents.find((file) => file.path === "test.ts"); + assert(testFile, "test.ts file not rendered"); + const actualContent = await format(testFile.contents as string, { parser: "typescript" }); + const expectedContent = await format( + `export interface Foo extends Array { } + `, + { + parser: "typescript", + }, + ); + + expect(actualContent).toBe(expectedContent); + }); + + it("creates an interface for a model that 'is' a record ", async () => { + const program = await getProgram(` + namespace DemoService; + + model Foo is Record; + `); + + const [namespace] = program.resolveTypeReference("DemoService"); + const models = (namespace as Namespace).models; + + const res = render( + + + {mapJoin(models, (name, model) => ( + + ))} + + , + ); + + const testFile = res.contents.find((file) => file.path === "test.ts"); + assert(testFile, "test.ts file not rendered"); + const actualContent = await format(testFile.contents as string, { parser: "typescript" }); + const expectedContent = await format( + `export interface Foo { + additionalProperties?: Record; + } + `, + { + parser: "typescript", + }, + ); + + expect(actualContent).toBe(expectedContent); + }); + + it("creates an interface of a model that spreads a Record", async () => { + const program = await getProgram(` + namespace DemoService; + + model Foo { + ...Record + } + `); + + const [namespace] = program.resolveTypeReference("DemoService"); + const models = (namespace as Namespace).models; + + const res = render( + + + {mapJoin(models, (name, model) => ( + + ))} + + , + ); + + const testFile = res.contents.find((file) => file.path === "test.ts"); + assert(testFile, "test.ts file not rendered"); + const actualContent = await format(testFile.contents as string, { parser: "typescript" }); + const expectedContent = await format( + ` + export interface Foo { + additionalProperties?: Record; + } + `, + { + parser: "typescript", + }, + ); + + expect(actualContent).toBe(expectedContent); + }); + + it("creates an interface that extends an spread model", async () => { + const program = await getProgram(` + namespace DemoService; + + model ModelForRecord { + state: string; + } + + model DifferentSpreadModelRecord { + knownProp: string; + ...Record; + } + + model DifferentSpreadModelDerived extends DifferentSpreadModelRecord { + derivedProp: ModelForRecord; + } + `); + + const [namespace] = program.resolveTypeReference("DemoService"); + const models = (namespace as Namespace).models; + + const res = render( + + + {mapJoin(models, (name, model) => ( + + ))} + + , + ); + + const testFile = res.contents.find((file) => file.path === "test.ts"); + assert(testFile, "test.ts file not rendered"); + const actualContent = await format(testFile.contents as string, { parser: "typescript" }); + const expectedContent = await format( + `export interface ModelForRecord { + state: string; + } + export interface DifferentSpreadModelRecord { + knownProp: string; + additionalProperties?: Record; + } + export interface DifferentSpreadModelDerived extends DifferentSpreadModelRecord { + derivedProp: ModelForRecord; + } + `, + { + parser: "typescript", + }, + ); + + expect(actualContent).toBe(expectedContent); + }); + + it("creates an interface that has additional properties", async () => { + const program = await getProgram(` + namespace DemoService; + model Widget extends Record { + id: string; + weight: int32; + color: "blue" | "red"; + } + `); + + const [namespace] = program.resolveTypeReference("DemoService"); + const models = Array.from((namespace as Namespace).models.values()); + + const res = render( + + + {models.map((model) => ( + + ))} + + , + ); + + const testFile = res.contents.find((file) => file.path === "test.ts"); + assert(testFile, "test.ts file not rendered"); + const actualContent = await format(testFile.contents as string, { parser: "typescript" }); + const expectedContent = await format( + `export interface Widget extends Record { + id: string; + weight: number; + color: "blue" | "red"; + }`, + { + parser: "typescript", + }, + ); + + expect(actualContent).toBe(expectedContent); + }); + + it("handles a type reference to a union variant", async () => { + const program = await getProgram(` + namespace DemoService; + + union Color { + red: "RED", + blue: "BLUE" + } + + model Widget{ + id: string; + weight: int32; + color: Color.blue + } + `); + + const [namespace] = program.resolveTypeReference("DemoService"); + const models = Array.from((namespace as Namespace).models.values()); + + const res = render( + + + + + , + ); + + const testFile = res.contents.find((file) => file.path === "test.ts"); + assert(testFile, "test.ts file not rendered"); + const actualContent = await format(testFile.contents as string, { parser: "typescript" }); + const expectedContent = await format( + `interface Widget { + id: string; + weight: number; + color: "BLUE"; + }`, + { + parser: "typescript", + }, + ); + expect(actualContent).toBe(expectedContent); + }); + it("creates an interface", async () => { + const program = await getProgram(` + namespace DemoService; + + model Widget{ + id: string; + weight: int32; + color: "blue" | "red"; + } + `); + + const [namespace] = program.resolveTypeReference("DemoService"); + const models = Array.from((namespace as Namespace).models.values()); + + const res = render( + + + + + , + ); + + const testFile = res.contents.find((file) => file.path === "test.ts"); + assert(testFile, "test.ts file not rendered"); + const actualContent = await format(testFile.contents as string, { parser: "typescript" }); + const expectedContent = await format( + `interface Widget { + id: string; + weight: number; + color: "blue" | "red"; + }`, + { + parser: "typescript", + }, + ); + expect(actualContent).toBe(expectedContent); + }); + + it("can override interface name", async () => { + const program = await getProgram(` + namespace DemoService; + + model Widget{ + id: string; + weight: int32; + color: "blue" | "red"; + } + `); + + const [namespace] = program.resolveTypeReference("DemoService"); + const models = Array.from((namespace as Namespace).models.values()); + + const res = render( + + + + + , + ); + + const testFile = res.contents.find((file) => file.path === "test.ts"); + assert(testFile, "test.ts file not rendered"); + const actualContent = await format(testFile.contents as string, { parser: "typescript" }); + const expectedContent = await format( + `export interface MyOperations { + id: string; + weight: number; + color: "blue" | "red"; + }`, + { + parser: "typescript", + }, + ); + expect(actualContent).toBe(expectedContent); + }); + + it("can add a members to the interface", async () => { + const program = await getProgram(` + namespace DemoService; + + model Widget{ + id: string; + weight: int32; + color: "blue" | "red"; + } + `); + + const [namespace] = program.resolveTypeReference("DemoService"); + const models = Array.from((namespace as Namespace).models.values()); + + const res = render( + + + + customProperty: string; + customMethod(): void; + + + , + ); + + const testFile = res.contents.find((file) => file.path === "test.ts"); + assert(testFile, "test.ts file not rendered"); + const actualContent = await format(testFile.contents as string, { parser: "typescript" }); + const expectedContent = await format( + `export interface MyOperations { + id: string; + weight: number; + color: "blue" | "red"; + customProperty: string; + customMethod(): void; + }`, + { + parser: "typescript", + }, + ); + expect(actualContent).toBe(expectedContent); + }); + + it("interface name can be customized", async () => { + const program = await getProgram(` + namespace DemoService; + + model Widget{ + id: string; + weight: int32; + color: "blue" | "red"; + } + `); + + const [namespace] = program.resolveTypeReference("DemoService"); + const models = Array.from((namespace as Namespace).models.values()); + + const res = render( + + + + + , + ); + + const testFile = res.contents.find((file) => file.path === "test.ts"); + assert(testFile, "test.ts file not rendered"); + const actualContent = await format(testFile.contents as string, { parser: "typescript" }); + const expectedContent = await format( + `export interface MyModel { + id: string; + weight: number; + color: "blue" | "red"; + }`, + { + parser: "typescript", + }, + ); + expect(actualContent).toBe(expectedContent); + }); + + it("interface with extends", async () => { + const program = await getProgram(` + namespace DemoService; + + model Widget{ + id: string; + weight: int32; + color: "blue" | "red"; + } + + model ErrorWidget extends Widget { + code: int32; + message: string; + } + `); + + const [namespace] = program.resolveTypeReference("DemoService"); + const models = Array.from((namespace as Namespace).models.values()); + + const res = render( + + + {models.map((model) => ( + + ))} + + , + ); + + const testFile = res.contents.find((file) => file.path === "test.ts"); + assert(testFile, "test.ts file not rendered"); + const actualContent = await format(testFile.contents as string, { parser: "typescript" }); + const expectedContent = await format( + `export interface Widget { + id: string; + weight: number; + color: "blue" | "red"; + } + export interface ErrorWidget extends Widget { + code: number; + message: string; + }`, + { + parser: "typescript", + }, + ); + expect(actualContent).toBe(expectedContent); + }); + }); + + describe.skip("Bound to Interface", () => { + it("creates an interface", async () => { + const program = await getProgram(` + namespace DemoService; + + interface WidgetOperations { + op getName(id: string): string; + } + `); + + const [namespace] = program.resolveTypeReference("DemoService"); + const interfaces = Array.from((namespace as Namespace).interfaces.values()); + + const res = render( + + + + + , + ); + + const testFile = res.contents.find((file) => file.path === "test.ts"); + assert(testFile, "test.ts file not rendered"); + const actualContent = await format(testFile.contents as string, { parser: "typescript" }); + const expectedContent = await format( + `export interface WidgetOperations { + getName(id: string): string; + }`, + { + parser: "typescript", + }, + ); + expect(actualContent).toBe(expectedContent); + }); + + it("should handle spread and non spread model parameters", async () => { + const program = await getProgram(` + namespace DemoService; + + model Foo { + name: string + } + + interface WidgetOperations { + op getName(foo: Foo): string; + op getOtherName(...Foo): string + } + `); + + const [namespace] = program.resolveTypeReference("DemoService"); + const interfaces = Array.from((namespace as Namespace).interfaces.values()); + const models = Array.from((namespace as Namespace).models.values()); + + const res = render( + + + + + + , + ); + + const testFile = res.contents.find((file) => file.path === "test.ts"); + assert(testFile, "test.ts file not rendered"); + const actualContent = await format(testFile.contents as string, { parser: "typescript" }); + const expectedContent = await format( + `export interface WidgetOperations { + getName(foo: Foo): string; + getOtherName(name: string): string + } + export interface Foo { + name: string; + } + `, + { + parser: "typescript", + }, + ); + expect(actualContent).toBe(expectedContent); + }); + + it("creates an interface with Model references", async () => { + const program = await getProgram(` + namespace DemoService; + + interface WidgetOperations { + op getName(id: string): Widget; + } + + model Widget { + id: string; + weight: int32; + color: "blue" | "red"; + } + `); + + const [namespace] = program.resolveTypeReference("DemoService"); + const interfaces = Array.from((namespace as Namespace).interfaces.values()); + const models = Array.from((namespace as Namespace).models.values()); + + const res = render( + + + + {models.map((model) => ( + + ))} + + , + ); + + const testFile = res.contents.find((file) => file.path === "test.ts"); + assert(testFile, "test.ts file not rendered"); + const actualContent = await format(testFile.contents as string, { parser: "typescript" }); + const expectedContent = await format( + `export interface WidgetOperations { + getName(id: string): Widget; + } + export interface Widget { + id: string; + weight: number; + color: "blue" | "red"; + }`, + { + parser: "typescript", + }, + ); + expect(actualContent).toBe(expectedContent); + }); + + it("creates an interface that extends another", async () => { + const program = await getProgram(` + namespace DemoService; + + interface WidgetOperations { + op getName(id: string): Widget; + } + + interface WidgetOperationsExtended extends WidgetOperations{ + op delete(id: string): void; + } + + model Widget { + id: string; + weight: int32; + color: "blue" | "red"; + } + `); + + const [namespace] = program.resolveTypeReference("DemoService"); + const interfaces = Array.from((namespace as Namespace).interfaces.values()); + const models = Array.from((namespace as Namespace).models.values()); + + const res = render( + + + + {models.map((model) => ( + + ))} + + , + ); + + const testFile = res.contents.find((file) => file.path === "test.ts"); + assert(testFile, "test.ts file not rendered"); + const actualContent = await format(testFile.contents as string, { parser: "typescript" }); + const expectedContent = await format( + `export interface WidgetOperationsExtended { + getName(id: string): Widget; + delete(id: string): void; + } + export interface Widget { + id: string; + weight: number; + color: "blue" | "red"; + }`, + { + parser: "typescript", + }, + ); + expect(actualContent).toBe(expectedContent); + }); + }); + }); +}); diff --git a/packages/emitter-framework/test/typescript/components/member-expression.test.tsx b/packages/emitter-framework/test/typescript/components/member-expression.test.tsx new file mode 100644 index 0000000000..461f3ad333 --- /dev/null +++ b/packages/emitter-framework/test/typescript/components/member-expression.test.tsx @@ -0,0 +1,130 @@ +import { d } from "@alloy-js/core/testing"; +import { Enum, Model, Union } from "@typespec/compiler"; +import { describe, expect, it } from "vitest"; +import { EnumDeclaration } from "../../../src/typescript/components/enum-declaration.js"; +import { InterfaceDeclaration, UnionDeclaration } from "../../../src/typescript/index.js"; +import { getEmitOutput } from "../../utils.js"; + +describe("Typescript Enum Member Expression", () => { + it("Reference to a named enum member", async () => { + const code = ` + enum Foo { + one: 1, + two: 2, + three: 3 + } + + model Bar { + one: Foo.one; + } + `; + const output = await getEmitOutput(code, (program) => { + const Bar = program.resolveTypeReference("Bar")[0]! as Model; + return <> + + + ; + }); + + expect(output).toBe(d` + enum Foo { + one = 1, + two = 2, + three = 3 + } + interface Bar { + "one": Foo.one; + } + `); + }); + + it("Reference to an unamed enum member", async () => { + const code = ` + enum Foo { + one, + two, + three + } + + model Bar { + one: Foo.one; + } + `; + const output = await getEmitOutput(code, (program) => { + const Bar = program.resolveTypeReference("Bar")[0]! as Model; + return <> + + + ; + }); + + expect(output).toBe(d` + enum Foo { + one = "one", + two = "two", + three = "three" + } + interface Bar { + "one": Foo.one; + } + `); + }); +}); + +describe("Typescript Union Member Expression", () => { + it("Reference to a named enum member", async () => { + const code = ` + union Foo { + one: 1, + two: 2, + three: 3 + } + + model Bar { + one: Foo.one; + } + `; + const output = await getEmitOutput(code, (program) => { + const Bar = program.resolveTypeReference("Bar")[0]! as Model; + return <> + + + ; + }); + + expect(output).toBe(d` + type Foo = 1 | 2 | 3; + interface Bar { + "one": 1; + } + `); + }); + + it("Reference to a union variant with unamed siblings", async () => { + const code = ` + union Foo { + one: "one", + "two", + "three" + } + + model Bar { + one: Foo.one; + } + `; + const output = await getEmitOutput(code, (program) => { + const Bar = program.resolveTypeReference("Bar")[0]! as Model; + return <> + + + ; + }); + + expect(output).toBe(d` + type Foo = "one" | "two" | "three"; + interface Bar { + "one": "one"; + } + `); + }); +}); diff --git a/packages/emitter-framework/test/typescript/components/type-alias-declaration.test.tsx b/packages/emitter-framework/test/typescript/components/type-alias-declaration.test.tsx new file mode 100644 index 0000000000..ede0f18a21 --- /dev/null +++ b/packages/emitter-framework/test/typescript/components/type-alias-declaration.test.tsx @@ -0,0 +1,120 @@ +import { Output, render } from "@alloy-js/core"; +import { SourceFile } from "@alloy-js/typescript"; +import { Namespace } from "@typespec/compiler"; +import { format } from "prettier"; +import { assert, describe, expect, it } from "vitest"; +import { TypeAliasDeclaration } from "../../../src/typescript/components/type-alias-declaration.jsx"; +import { getProgram } from "../test-host.js"; + +describe("Typescript Type Alias Declaration", () => { + describe("Type Alias bound to Typespec Scalar", () => { + describe("Scalar extends utcDateTime", () => { + it("creates a type alias declaration for a utcDateTime without encoding", async () => { + const program = await getProgram(` + namespace DemoService; + scalar MyDate extends utcDateTime; + `); + + const [namespace] = program.resolveTypeReference("DemoService"); + const scalar = Array.from((namespace as Namespace).scalars.values())[0]; + + const res = render( + + + + + , + ); + + const testFile = res.contents.find((file) => file.path === "test.ts"); + assert(testFile, "test.ts file not rendered"); + const actualContent = await format(testFile.contents as string, { parser: "typescript" }); + const expectedContent = await format(`type MyDate = Date;`, { + parser: "typescript", + }); + expect(actualContent).toBe(expectedContent); + }); + + it("creates a type alias declaration for a utcDateTime with unixTimeStamp encoding", async () => { + const program = await getProgram(` + namespace DemoService; + @encode("unixTimestamp", int32) + scalar MyDate extends utcDateTime; + `); + + const [namespace] = program.resolveTypeReference("DemoService"); + const scalar = Array.from((namespace as Namespace).scalars.values())[0]; + + const res = render( + + + + + , + ); + + const testFile = res.contents.find((file) => file.path === "test.ts"); + assert(testFile, "test.ts file not rendered"); + const actualContent = await format(testFile.contents as string, { parser: "typescript" }); + const expectedContent = await format(`type MyDate = Date;`, { + parser: "typescript", + }); + expect(actualContent).toBe(expectedContent); + }); + + it("creates a type alias declaration for a utcDateTime with rfc7231 encoding", async () => { + const program = await getProgram(` + namespace DemoService; + @encode("rfc7231") + scalar MyDate extends utcDateTime; + `); + + const [namespace] = program.resolveTypeReference("DemoService"); + const scalar = Array.from((namespace as Namespace).scalars.values())[0]; + + const res = render( + + + + + , + ); + + const testFile = res.contents.find((file) => file.path === "test.ts"); + assert(testFile, "test.ts file not rendered"); + const actualContent = await format(testFile.contents as string, { parser: "typescript" }); + const expectedContent = await format(`type MyDate = Date;`, { + parser: "typescript", + }); + expect(actualContent).toBe(expectedContent); + }); + + it("creates a type alias declaration for a utcDateTime with rfc3339 encoding", async () => { + const program = await getProgram(` + namespace DemoService; + @encode("rfc3339") + scalar MyDate extends utcDateTime; + `); + + const [namespace] = program.resolveTypeReference("DemoService"); + const scalar = Array.from((namespace as Namespace).scalars.values())[0]; + + const res = render( + + + + + , + ); + + const testFile = res.contents.find((file) => file.path === "test.ts"); + assert(testFile, "test.ts file not rendered"); + const actualContent = await format(testFile.contents as string, { parser: "typescript" }); + const expectedContent = await format(`export type MyDate = Date;`, { + parser: "typescript", + }); + expect(actualContent).toBe(expectedContent); + }); + }); + }); +}); diff --git a/packages/emitter-framework/test/typescript/components/type-transform.test.tsx b/packages/emitter-framework/test/typescript/components/type-transform.test.tsx new file mode 100644 index 0000000000..be4b440517 --- /dev/null +++ b/packages/emitter-framework/test/typescript/components/type-transform.test.tsx @@ -0,0 +1,562 @@ +import { code, Output, render } from "@alloy-js/core"; +import { d } from "@alloy-js/core/testing"; +import * as ts from "@alloy-js/typescript"; +import { SourceFile } from "@alloy-js/typescript"; +import { Model } from "@typespec/compiler"; +import { BasicTestRunner } from "@typespec/compiler/testing"; +import { assert, beforeEach, describe, expect, it } from "vitest"; +import { + ArraySerializer, + DateDeserializer, + RecordSerializer, +} from "../../../src/typescript/components/static-serializers.js"; +import { + getTypeTransformerRefkey, + ModelTransformExpression, + TypeTransformCall, + TypeTransformDeclaration, +} from "../../../src/typescript/components/type-transform.js"; +import { TypeDeclaration } from "../../../src/typescript/index.js"; +import { createEmitterFrameworkTestRunner } from "../test-host.js"; + +describe.skip("Typescript Type Transform", () => { + let testRunner: BasicTestRunner; + const namePolicy = ts.createTSNamePolicy(); + beforeEach(async () => { + testRunner = await createEmitterFrameworkTestRunner(); + }); + describe("Model Transforms", () => { + describe("ModelTransformExpression", () => { + it("should render a transform expression to client", async () => { + const spec = ` + namespace DemoService; + @test model Widget { + id: string; + birth_year: int32; + color: "blue" | "red"; + } + `; + + const { Widget } = (await testRunner.compile(spec)) as { Widget: Model }; + + const res = render( + + + {code` + const wireWidget = {id: "1", birth_year: 1988, color: "blue"}; + `} + const clientWidget = + + , + ); + + const testFile = res.contents.find((file) => file.path === "test.ts"); + assert(testFile, "test.ts file not rendered"); + const actualContent = testFile.contents; + const expectedContent = d` + const wireWidget = {id: "1", birth_year: 1988, color: "blue"}; + const clientWidget = { + "id": wireWidget.id, + "birthYear": wireWidget.birth_year, + "color": wireWidget.color + }`; + expect(actualContent).toBe(expectedContent); + }); + + it("should render a transform expression to wire", async () => { + const spec = ` + namespace DemoService; + @test model Widget { + id: string; + birth_year: int32; + color: "blue" | "red"; + } + `; + + const { Widget } = (await testRunner.compile(spec)) as { Widget: Model }; + + const res = render( + + + {code` + const clientWidget = {id: "1", birthYear: 1988, color: "blue"}; + `} + const wireWidget = + + , + ); + + const testFile = res.contents.find((file) => file.path === "test.ts"); + assert(testFile, "test.ts file not rendered"); + const actualContent = testFile.contents; + const expectedContent = d` + const clientWidget = {id: "1", birthYear: 1988, color: "blue"}; + const wireWidget = { + "id": clientWidget.id, + "birth_year": clientWidget.birthYear, + "color": clientWidget.color + }`; + expect(actualContent).toBe(expectedContent); + }); + + it("should render a transform expression that contains a utcDateTime to client", async () => { + const spec = ` + namespace DemoService; + @test model Widget { + id: string; + birth_date: utcDateTime; + color: "blue" | "red"; + } + `; + + const { Widget } = (await testRunner.compile(spec)) as { Widget: Model }; + + const res = render( + + + + + + {code` + const wireWidget = {id: "1", birth_date: "1988-04-29T19:30:00Z", color: "blue"}; + `} + const clientWidget = + + , + ); + + const testFile = res.contents.find((file) => file.path === "test.ts"); + assert(testFile, "test.ts file not rendered"); + const actualContent = testFile.contents; + const expectedContent = d` + import { dateDeserializer } from "./static-serializers.js"; + + const wireWidget = {id: "1", birth_date: "1988-04-29T19:30:00Z", color: "blue"}; + const clientWidget = { + "id": wireWidget.id, + "birthDate": dateDeserializer(wireWidget.birth_date), + "color": wireWidget.color + }`; + expect(actualContent).toBe(expectedContent); + }); + }); + + describe("TypeTransformDeclaration", () => { + it("should render a transform functions for a model containing array", async () => { + const spec = ` + namespace DemoService; + @test model Widget { + id: string; + my_color: "blue" | "red"; + simple?: string[]; + complex: Widget[]; + nested: Widget[][]; + optionalString?: string; + } + `; + + const { Widget } = (await testRunner.compile(spec)) as { Widget: Model }; + + const res = render( + + + + + + + + + + , + ); + + const testFile = res.contents.find((file) => file.path === "test.ts"); + assert(testFile, "test.ts file not rendered"); + const actualContent = testFile.contents; + const expectedContent = d` + import { arraySerializer } from "./serializers.js"; + + export interface Widget { + "id": string; + "myColor": "blue" | "red"; + "simple"?: Array; + "complex": Array; + "nested": Array>; + "optionalString"?: string; + } + export function widgetToApplication(item: any) { + return { + "id": item.id, + "myColor": item.my_color, + "simple": item.simple ? arraySerializer(item.simple, ) : item.simple, + "complex": arraySerializer(item.complex, widgetToApplication), + "nested": arraySerializer(item.nested, (i: any) => arraySerializer(i, widgetToApplication)), + "optionalString": item.optionalString + }; + } + export function widgetToTransport(item: Widget) { + return { + "id": item.id, + "my_color": item.myColor, + "simple": item.simple ? arraySerializer(item.simple, ) : item.simple, + "complex": arraySerializer(item.complex, widgetToTransport), + "nested": arraySerializer(item.nested, (i: any) => arraySerializer(i, widgetToTransport)), + "optionalString": item.optionalString + }; + } + `; + expect(actualContent).toBe(expectedContent); + }); + it("should render a transform functions for a model containing record", async () => { + const spec = ` + namespace DemoService; + @test model Widget { + id: string; + my_color: "blue" | "red"; + simple: Record; + complex: Record; + nested: Record>; + } + `; + + const { Widget } = (await testRunner.compile(spec)) as { Widget: Model }; + + const res = render( + + + + + + + + + + , + ); + + const testFile = res.contents.find((file) => file.path === "test.ts"); + assert(testFile, "test.ts file not rendered"); + const actualContent = testFile.contents; + const expectedContent = d` + import { recordSerializer } from "./serializers.js"; + + export interface Widget { + "id": string; + "myColor": "blue" | "red"; + "simple": Record; + "complex": Record; + "nested": Record>; + } + export function widgetToApplication(item: any) { + return { + "id": item.id, + "myColor": item.my_color, + "simple": recordSerializer(item.simple, ), + "complex": recordSerializer(item.complex, widgetToApplication), + "nested": recordSerializer(item.nested, (i: any) => recordSerializer(i, widgetToApplication)) + }; + } + export function widgetToTransport(item: Widget) { + return { + "id": item.id, + "my_color": item.myColor, + "simple": recordSerializer(item.simple, ), + "complex": recordSerializer(item.complex, widgetToTransport), + "nested": recordSerializer(item.nested, (i: any) => recordSerializer(i, widgetToTransport)) + }; + } + `; + expect(actualContent).toBe(expectedContent); + }); + it("should render a transform functions for a model", async () => { + const spec = ` + namespace DemoService; + @test model Widget { + id: string; + my_color: "blue" | "red"; + } + `; + + const { Widget } = (await testRunner.compile(spec)) as { Widget: Model }; + + const res = render( + + + + + + + , + ); + + const testFile = res.contents.find((file) => file.path === "test.ts"); + assert(testFile, "test.ts file not rendered"); + const actualContent = testFile.contents; + const expectedContent = d` + export interface Widget { + "id": string; + "myColor": "blue" | "red"; + } + export function widgetToApplication(item: any) { + return { + "id": item.id, + "myColor": item.my_color + }; + } + export function widgetToTransport(item: Widget) { + return { + "id": item.id, + "my_color": item.myColor + }; + } + `; + expect(actualContent).toBe(expectedContent); + }); + }); + describe("Calling a model transform functions", () => { + it("should collapse a model with single property", async () => { + const spec = ` + namespace DemoService; + @test model Widget { + id: string; + } + `; + + const { Widget } = (await testRunner.compile(spec)) as { Widget: Model }; + + const res = render( + + + {code` + const clientWidget = {id: "1", my_color: "blue"}; + const wireWidget = ${} + `} + + , + ); + + const testFile = res.contents.find((file) => file.path === "test.ts"); + assert(testFile, "test.ts file not rendered"); + const actualContent = testFile.contents; + const expectedContent = d` + const clientWidget = {id: "1", my_color: "blue"}; + const wireWidget = clientWidget.id + `; + expect(actualContent).toBe(expectedContent); + }); + + it("should call transform functions for a model", async () => { + const spec = ` + namespace DemoService; + @test model Widget { + id: string; + my_color: "blue" | "red"; + } + `; + + const { Widget } = (await testRunner.compile(spec)) as { Widget: Model }; + + const res = render( + + + + + + + + {code` + const wireWidget = {id: "1", my_color: "blue"}; + const clientWidget = ${wireWidget]}/>}; + const wireWidget2 = ${}; + `} + + , + ); + + const testFile = res.contents.find((file) => file.path === "test.ts"); + assert(testFile, "test.ts file not rendered"); + const actualContent = testFile.contents; + const expectedContent = d` + import { widgetToApplication, widgetToTransport } from "./types.js"; + + const wireWidget = {id: "1", my_color: "blue"}; + const clientWidget = widgetToApplication(wireWidget); + const wireWidget2 = widgetToTransport(clientWidget); + `; + expect(actualContent).toBe(expectedContent); + }); + }); + }); + + describe("Discriminated Model Transforms", () => { + it("should handle a discriminated union", async () => { + const { Pet, Cat, Dog } = (await testRunner.compile(` + @discriminator("kind") + @test model Pet { + kind: string; + } + + @test model Cat extends Pet { + kind: "cat"; + } + + @test model Dog extends Pet { + kind: "dog"; + } + `)) as { Pet: Model; Cat: Model; Dog: Model }; + + const res = render( + + + + + + + + + + + + + , + ); + + const testFile = res.contents.find((file) => file.path === "test.ts"); + assert(testFile, "test.ts file not rendered"); + const actualContent = testFile.contents; + const expectedContent = d` + export interface Pet { + "kind": string; + } + export interface Dog extends Pet { + "kind": "dog"; + } + export interface Cat extends Pet { + "kind": "cat"; + } + export function dogToApplication(item: any): Dog { + return { + "kind": item.kind + }; + } + export function dogToTransport(item: Dog) { + return { + "kind": item.kind + }; + } + export function catToApplication(item: any) { + return { + "kind": item.kind + }; + } + export function catToTransport(item: Cat) { + return { + "kind": item.kind + }; + } + export function petToApplication(item: any) { + if(item.kind === "cat") { + return catToApplication(item) + } + if(item.kind === "dog") { + return dogToApplication(item) + } + } + export function petToTransport(item: Pet) { + if(item.kind === "cat") { + return catToTransport(item) + } + if(item.kind === "dog") { + return dogToTransport(item) + } + } + `; + expect(actualContent).toBe(expectedContent); + }); + }); + describe("Discriminated Union Transforms", () => { + it("should handle a discriminated union", async () => { + const { Pet, Cat, Dog } = (await testRunner.compile(` + @discriminator("kind") + @test union Pet { + cat: Cat; + dog: Dog; + } + + @test model Cat { + kind: "cat"; + } + + @test model Dog { + kind: "dog"; + } + `)) as { Pet: Model; Cat: Model; Dog: Model }; + + const res = render( + + + + + + + + + + + + + , + ); + + const testFile = res.contents.find((file) => file.path === "test.ts"); + assert(testFile, "test.ts file not rendered"); + const actualContent = testFile.contents; + const expectedContent = d` + export type Pet = Cat | Dog; + export interface Dog { + "kind": "dog"; + } + export interface Cat { + "kind": "cat"; + } + export function dogToApplication(item: any): Dog { + return { + "kind": item.kind + }; + } + export function dogToTransport(item: Dog) { + return { + "kind": item.kind + }; + } + export function catToApplication(item: any) { + return { + "kind": item.kind + }; + } + export function catToTransport(item: Cat) { + return { + "kind": item.kind + }; + } + export function petToApplication(item: any) { + if(item.kind === "cat") { + return catToApplication(item) + } + if(item.kind === "dog") { + return dogToApplication(item) + } + } + export function petToTransport(item: Pet) { + if(item.kind === "cat") { + return catToTransport(item) + } + if(item.kind === "dog") { + return dogToTransport(item) + } + } + `; + expect(actualContent).toBe(expectedContent); + }); + }); +}); diff --git a/packages/emitter-framework/test/typescript/components/union-declaration.test.tsx b/packages/emitter-framework/test/typescript/components/union-declaration.test.tsx new file mode 100644 index 0000000000..2478a1f5ea --- /dev/null +++ b/packages/emitter-framework/test/typescript/components/union-declaration.test.tsx @@ -0,0 +1,185 @@ +import { Output, render } from "@alloy-js/core"; +import { SourceFile } from "@alloy-js/typescript"; +import { Namespace } from "@typespec/compiler"; +import { format } from "prettier"; +import { assert, describe, expect, it } from "vitest"; +import { UnionDeclaration } from "../../../src/typescript/components/union-declaration.js"; +import { UnionExpression } from "../../../src/typescript/components/union-expression.js"; +import { getProgram } from "../test-host.js"; + +describe("Typescript Union Declaration", () => { + describe("Union not bound to Typespec Types", () => { + it("creates a union declaration", async () => { + const res = render( + + + + "red" | "blue" + + + , + ); + + const testFile = res.contents.find((file) => file.path === "test.ts"); + assert(testFile, "test.ts file not rendered"); + const actualContent = await format(testFile.contents as string, { parser: "typescript" }); + const expectedContent = await format(`type MyUnion = "red" | "blue"`, { + parser: "typescript", + }); + expect(actualContent).toBe(expectedContent); + }); + }); + + describe("Union bound to Typespec Types", () => { + describe("Bound to Union", () => { + it("creates a union declaration", async () => { + const program = await getProgram(` + namespace DemoService; + union TestUnion { + one: "one", + two: "two" + } + `); + + const [namespace] = program.resolveTypeReference("DemoService"); + const union = Array.from((namespace as Namespace).unions.values())[0]; + + const res = render( + + + + + , + ); + + const testFile = res.contents.find((file) => file.path === "test.ts"); + assert(testFile, "test.ts file not rendered"); + const actualContent = await format(testFile.contents as string, { parser: "typescript" }); + const expectedContent = await format(`type TestUnion = "one" | "two"`, { + parser: "typescript", + }); + expect(actualContent).toBe(expectedContent); + }); + + it("creates a union declaration with name override", async () => { + const program = await getProgram(` + namespace DemoService; + union TestUnion { + one: "one", + two: "two" + } + `); + + const [namespace] = program.resolveTypeReference("DemoService"); + const union = Array.from((namespace as Namespace).unions.values())[0]; + + const res = render( + + + + + , + ); + + const testFile = res.contents.find((file) => file.path === "test.ts"); + assert(testFile, "test.ts file not rendered"); + const actualContent = await format(testFile.contents as string, { parser: "typescript" }); + const expectedContent = await format(`export type MyUnion = "one" | "two"`, { + parser: "typescript", + }); + expect(actualContent).toBe(expectedContent); + }); + + it("creates a union declaration with extra children", async () => { + const program = await getProgram(` + namespace DemoService; + union TestUnion { + one: "one", + two: "two" + } + `); + + const [namespace] = program.resolveTypeReference("DemoService"); + const union = Array.from((namespace as Namespace).unions.values())[0]; + + const res = render( + + + + "three" + + + , + ); + + const testFile = res.contents.find((file) => file.path === "test.ts"); + assert(testFile, "test.ts file not rendered"); + const actualContent = await format(testFile.contents as string, { parser: "typescript" }); + const expectedContent = await format(`type TestUnion = "one" | "two" | "three"`, { + parser: "typescript", + }); + expect(actualContent).toBe(expectedContent); + }); + + it("renders an union expression", async () => { + const program = await getProgram(` + namespace DemoService; + union TestUnion { + one: "one", + two: "two" + } + `); + + const [namespace] = program.resolveTypeReference("DemoService"); + const union = Array.from((namespace as Namespace).unions.values())[0]; + + const res = render( + + + let x: = "one"; + + , + ); + + const testFile = res.contents.find((file) => file.path === "test.ts"); + assert(testFile, "test.ts file not rendered"); + const actualContent = await format(testFile.contents as string, { parser: "typescript" }); + const expectedContent = await format(`let x:"one" | "two" = "one"`, { + parser: "typescript", + }); + expect(actualContent).toBe(expectedContent); + }); + }); + + describe("Bound to Enum", () => { + it("creates a union declaration", async () => { + const program = await getProgram(` + namespace DemoService; + enum TestEnum { + one: "one", + two: "two" + } + `); + + const [namespace] = program.resolveTypeReference("DemoService"); + const union = Array.from((namespace as Namespace).enums.values())[0]; + + const res = render( + + + + + , + ); + + const testFile = res.contents.find((file) => file.path === "test.ts"); + assert(testFile, "test.ts file not rendered"); + const actualContent = await format(testFile.contents as string, { parser: "typescript" }); + const expectedContent = await format(`type TestEnum = "one" | "two"`, { + parser: "typescript", + }); + expect(actualContent).toBe(expectedContent); + }); + }); + }); +}); diff --git a/packages/emitter-framework/test/typescript/test-host.ts b/packages/emitter-framework/test/typescript/test-host.ts new file mode 100644 index 0000000000..d2ccdcdb7f --- /dev/null +++ b/packages/emitter-framework/test/typescript/test-host.ts @@ -0,0 +1,41 @@ +import { Program } from "@typespec/compiler"; +import { + createTestHost, + createTestWrapper, + expectDiagnosticEmpty, +} from "@typespec/compiler/testing"; +import { HttpTestLibrary } from "@typespec/http/testing"; + +export async function createTypespecCliTestHost( + options: { libraries: "Http"[] } = { libraries: [] }, +) { + const libraries = []; + if (options.libraries.includes("Http")) { + libraries.push(HttpTestLibrary); + } + return createTestHost({ + libraries, + }); +} + +export async function createEmitterFrameworkTestRunner(options: { autoUsings?: string[] } = {}) { + const host = await createTypespecCliTestHost(); + return createTestWrapper(host, { + autoUsings: options.autoUsings, + }); +} + +export async function getProgram( + code: string, + options: { libraries: "Http"[] } = { libraries: [] }, +): Promise { + const host = await createTypespecCliTestHost(options); + const wrapper = createTestWrapper(host, { + compilerOptions: { + noEmit: true, + }, + }); + const [_, diagnostics] = await wrapper.compileAndDiagnose(code); + expectDiagnosticEmpty(diagnostics); + return wrapper.program; +} diff --git a/packages/emitter-framework/test/utils.ts b/packages/emitter-framework/test/utils.ts new file mode 100644 index 0000000000..c37ffd93fc --- /dev/null +++ b/packages/emitter-framework/test/utils.ts @@ -0,0 +1,14 @@ +import { Children, render } from "@alloy-js/core"; +import { Output } from "@alloy-js/core/stc"; +import { SourceFile } from "@alloy-js/typescript/stc"; +import { Program } from "@typespec/compiler"; +import { getProgram } from "./typescript/test-host.js"; + +export async function getEmitOutput(tspCode: string, cb: (program: Program) => Children) { + const program = await getProgram(tspCode); + + const res = render(Output().children(SourceFile({ path: "test.ts" }).children(cb(program)))); + const testFile = res.contents.find((file) => file.path === "test.ts")!; + + return testFile.contents; +} diff --git a/packages/emitter-framework/tsconfig.json b/packages/emitter-framework/tsconfig.json new file mode 100644 index 0000000000..4823061a83 --- /dev/null +++ b/packages/emitter-framework/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "lib": ["es2023", "DOM"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "target": "es2022", + "strict": true, + "skipLibCheck": true, + "isolatedModules": true, + "declaration": true, + "sourceMap": true, + "declarationMap": true, + "jsx": "preserve", + + "emitDeclarationOnly": true, + "outDir": "dist", + "rootDir": "./" + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "test/**/*.ts", "test/**/*.tsx"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/emitter-framework/vitest.config.js b/packages/emitter-framework/vitest.config.js new file mode 100644 index 0000000000..6666bfd125 --- /dev/null +++ b/packages/emitter-framework/vitest.config.js @@ -0,0 +1,22 @@ +import { babel } from "@rollup/plugin-babel"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["test/**/*.test.ts", "test/**/*.test.tsx"], + exclude: ["test/**/*.d.ts"], + }, + esbuild: { + jsx: "preserve", + sourcemap: "both", + }, + plugins: [ + babel({ + inputSourceMap: true, + sourceMaps: "both", + babelHelpers: "bundled", + extensions: [".ts", ".tsx"], + presets: ["@babel/preset-typescript", "@alloy-js/babel-preset"], + }), + ], +}); diff --git a/packages/html-program-viewer/package.json b/packages/html-program-viewer/package.json index 7be03df667..16142be24d 100644 --- a/packages/html-program-viewer/package.json +++ b/packages/html-program-viewer/package.json @@ -77,6 +77,7 @@ "vite": "^6.0.11", "vite-plugin-checker": "^0.8.0", "vite-plugin-dts": "4.5.0", - "vitest": "^3.0.5" + "vitest": "^3.0.5", + "vite-plugin-node-polyfills": "^0.23.0" } } diff --git a/packages/html-program-viewer/src/testing/index.ts b/packages/html-program-viewer/src/testing/index.ts index 9091a56cdf..3054fa4f14 100644 --- a/packages/html-program-viewer/src/testing/index.ts +++ b/packages/html-program-viewer/src/testing/index.ts @@ -6,6 +6,6 @@ import { export const ProgramViewerTestLibrary: TypeSpecTestLibrary = createTestLibrary({ name: "@typespec/html-program-viewer", - jsFileFolder: "dist", + jsFileFolder: "dist/emitter", packageRoot: await findTestPackageRoot(import.meta.url), }); diff --git a/packages/http-client/babel.config.js b/packages/http-client/babel.config.js new file mode 100644 index 0000000000..1c30974b36 --- /dev/null +++ b/packages/http-client/babel.config.js @@ -0,0 +1,4 @@ +export default { + sourceMaps: true, + presets: ["@babel/preset-typescript", "@alloy-js/babel-preset"], +}; diff --git a/packages/http-client/package.json b/packages/http-client/package.json new file mode 100644 index 0000000000..85983d9275 --- /dev/null +++ b/packages/http-client/package.json @@ -0,0 +1,48 @@ +{ + "name": "@typespec/http-client", + "version": "0.1.0", + "type": "module", + "main": "dist/src/index.js", + "license": "MIT", + "exports": { + ".": { + "import": "./dist/src/index.js" + }, + "./testing": { + "import": "./dist/src/testing/index.js" + }, + "./typekit": { + "import": "./dist/src/typekit/index.js" + }, + "./components": { + "import": "./dist/src/components/index.js" + } + }, + "dependencies": { + "@alloy-js/core": "^0.5.0", + "@typespec/compiler": "workspace:~", + "@typespec/http": "workspace:~" + }, + "devDependencies": { + "@babel/cli": "^7.24.8", + "@babel/core": "^7.26.0", + "@rollup/plugin-babel": "^6.0.4", + "@alloy-js/babel-preset": "^0.1.1", + "@types/node": "~22.10.10", + "eslint": "^9.18.0", + "prettier": "~3.4.2", + "typescript": "~5.7.3", + "vitest": "^3.0.5" + }, + "scripts": { + "build-src": "babel src -d dist/src --extensions .ts,.tsx", + "build": "tsc -p . && npm run build-src", + "watch": "tsc --watch", + "build:tsp": "tsp compile . --no-emit", + "test": "vitest run", + "lint": "eslint src/ test/ --report-unused-disable-directives --max-warnings=0", + "lint:fix": "eslint . --report-unused-disable-directives --fix", + "format": "prettier . --write", + "format:check": "prettier --check ." + } +} diff --git a/packages/http-client/src/client-library.ts b/packages/http-client/src/client-library.ts new file mode 100644 index 0000000000..677f6848a5 --- /dev/null +++ b/packages/http-client/src/client-library.ts @@ -0,0 +1,175 @@ +import { Enum, Model, Namespace, Operation, Union } from "@typespec/compiler"; +import { unsafe_mutateSubgraph, unsafe_Mutator } from "@typespec/compiler/experimental"; +import { $ } from "@typespec/compiler/experimental/typekit"; +import { Client, ClientOperation, InternalClient } from "./interfaces.js"; +import { reportDiagnostic } from "./lib.js"; +import { collectDataTypes } from "./utils/type-collector.js"; + +export interface ClientLibrary { + topLevel: Client[]; + dataTypes: Array; +} + +export interface CreateClientLibraryOptions { + operationMutators?: unsafe_Mutator[]; +} + +function hasGlobalOperations(namespace: Namespace): boolean { + for (const operation of namespace.operations.values()) { + if ($.type.isUserDefined(operation)) { + return true; + } + } + + return false; +} + +function getUserDefinedSubClients(namespace: Namespace): InternalClient[] { + const clients: InternalClient[] = []; + + for (const subNs of namespace.namespaces.values()) { + if (!$.type.isUserDefined(subNs)) { + continue; + } + const client = getEffectiveClient(subNs); + if (client) { + clients.push(client); + } + } + + return clients; +} + +function getEffectiveClient(namespace: Namespace): InternalClient | undefined { + if (namespace.operations.size > 0 || namespace.interfaces.size > 0) { + // It has content so it should be a client + return $.client.getClient(namespace); + } + + const effectiveClients: InternalClient[] = []; + + // It has no content so we need to check its children + for (const subNs of namespace.namespaces.values()) { + const client = getEffectiveClient(subNs); + if (client) { + effectiveClients.push(client); + } + } + + if (effectiveClients.length > 1) { + // If there are more than one sub client we can't collapse so we need to create a client for this namespace + return $.client.getClient(namespace); + } + + if (effectiveClients.length === 1) { + return effectiveClients[0]; + } + + return undefined; +} + +export function createClientLibrary(options: CreateClientLibraryOptions = {}): ClientLibrary { + let topLevel: InternalClient[] = []; + const dataTypes = new Set(); + + // Need to find out if we need to create a client for the global namespace. + const globalNs = $.program.getGlobalNamespaceType(); + + const userDefinedTopLevelClients = getUserDefinedSubClients(globalNs); + if (hasGlobalOperations(globalNs)) { + // We need to start with the global namespace + const globalClient = $.client.getClient(globalNs); + topLevel = [globalClient]; + } else if (userDefinedTopLevelClients.length > 0) { + for (const client of userDefinedTopLevelClients) { + topLevel.push(client); + } + } + + const topLevelClients: Client[] = []; + + if (topLevel.length === 0) { + reportDiagnostic($.program, { code: "cant-find-client", target: globalNs }); + } + + for (const c of topLevel) { + const client = visitClient(c, dataTypes, { operationMutators: options.operationMutators }); + topLevelClients.push(client); + } + + return { + topLevel: topLevelClients, + dataTypes: Array.from(dataTypes), + }; +} + +interface VisitClientOptions { + operationMutators?: unsafe_Mutator[]; + parentClient?: Client; +} +function visitClient( + client: InternalClient, + dataTypes: Set, + options?: VisitClientOptions, +): Client { + // First create a partial `Client` object. + // We’ll fill in subClients *after* we have `c`. + const currentClient: Client = { + ...client, + operations: [], + subClients: [], + parent: options?.parentClient, + }; + + // Recurse into sub-clients, passing `currentClient` as the parent + currentClient.subClients = $.clientLibrary.listClients(client).map((childClient) => + visitClient(childClient, dataTypes, { + parentClient: currentClient, + operationMutators: options?.operationMutators, + }), + ); + + // Now store the prepared operations + currentClient.operations = $.client + .listServiceOperations(client) + .map((o) => prepareOperation(currentClient, o, { mutators: options?.operationMutators })); + + // Collect data types + for (const clientOperation of currentClient.operations) { + // Collect operation parameters + collectDataTypes(clientOperation.operation.parameters, dataTypes); + + // Collect http operation return type + const responseType = $.httpOperation.getReturnType(clientOperation.httpOperation); + collectDataTypes(responseType, dataTypes); + } + + return currentClient; +} + +interface PrepareClientOperationOptions { + mutators?: unsafe_Mutator[]; +} + +function prepareOperation( + client: Client, + operation: Operation, + options: PrepareClientOperationOptions = {}, +): ClientOperation { + let op: Operation = operation; + + // We need to get the HttpOperation before running mutators to ensure that the httpOperation has full fidelity with the spec + const httpOperation = $.httpOperation.get(op); + + if (options.mutators) { + op = unsafe_mutateSubgraph($.program, options.mutators, operation).type as Operation; + } + + return { + kind: "ClientOperation", + client, + httpOperation, + name: op.name, + operation: op, + }; +} diff --git a/packages/http-client/src/components/client-library.tsx b/packages/http-client/src/components/client-library.tsx new file mode 100644 index 0000000000..30f6c1e699 --- /dev/null +++ b/packages/http-client/src/components/client-library.tsx @@ -0,0 +1,16 @@ +import { Children } from "@alloy-js/core"; +import { unsafe_Mutator } from "@typespec/compiler/experimental"; +import { createClientLibrary } from "../client-library.js"; +import { ClientLibraryContext } from "../context/client-library-context.js"; + +export interface ClientLibraryProps { + operationMutators?: unsafe_Mutator[]; + children?: Children; +} + +export function ClientLibrary(props: ClientLibraryProps) { + const clientLibrary = createClientLibrary({ operationMutators: props.operationMutators }); + return + {props.children} + ; +} diff --git a/packages/http-client/src/components/index.ts b/packages/http-client/src/components/index.ts new file mode 100644 index 0000000000..43b7a3a100 --- /dev/null +++ b/packages/http-client/src/components/index.ts @@ -0,0 +1,2 @@ +export { ClientLibrary } from "./client-library.jsx"; +export type { ClientLibraryProps } from "./client-library.jsx"; diff --git a/packages/http-client/src/context/client-library-context.ts b/packages/http-client/src/context/client-library-context.ts new file mode 100644 index 0000000000..a01f061e50 --- /dev/null +++ b/packages/http-client/src/context/client-library-context.ts @@ -0,0 +1,18 @@ +import { ComponentContext, createNamedContext, useContext } from "@alloy-js/core"; +import { NoTarget } from "@typespec/compiler"; +import { $ } from "@typespec/compiler/experimental/typekit"; +import { ClientLibrary } from "../client-library.js"; +import { reportDiagnostic } from "../lib.js"; + +export const ClientLibraryContext: ComponentContext = + createNamedContext("ClientLibrary"); + +export function useClientLibrary() { + const context = useContext(ClientLibraryContext); + + if (!context) { + reportDiagnostic($.program, { code: "use-client-context-without-provider", target: NoTarget }); + } + + return context!; +} diff --git a/packages/http-client/src/context/index.ts b/packages/http-client/src/context/index.ts new file mode 100644 index 0000000000..7dba7b440a --- /dev/null +++ b/packages/http-client/src/context/index.ts @@ -0,0 +1 @@ +export { ClientLibraryContext, useClientLibrary } from "./client-library-context.js"; diff --git a/packages/http-client/src/index.ts b/packages/http-client/src/index.ts new file mode 100644 index 0000000000..e15a10b7fe --- /dev/null +++ b/packages/http-client/src/index.ts @@ -0,0 +1,4 @@ +export * from "./context/index.js"; +export type * from "./interfaces.js"; +export { $lib } from "./lib.js"; +export * from "./utils/index.js"; diff --git a/packages/http-client/src/interfaces.ts b/packages/http-client/src/interfaces.ts new file mode 100644 index 0000000000..81993f934e --- /dev/null +++ b/packages/http-client/src/interfaces.ts @@ -0,0 +1,29 @@ +import { Interface, Namespace, Operation } from "@typespec/compiler"; +import { HttpOperation } from "@typespec/http"; + +export interface InternalClient { + kind: "Client"; + name: string; + type: Namespace | Interface; + service: Namespace; +} + +export interface Client extends InternalClient { + operations: ClientOperation[]; + subClients: Client[]; + parent?: Client; +} + +export interface ClientOperation { + kind: "ClientOperation"; + name: string; + operation: Operation; + httpOperation: HttpOperation; + client: Client; +} + +export interface ReferencedType { + kind: "ReferencedType"; + name: string; + library: string; +} diff --git a/packages/http-client/src/lib.ts b/packages/http-client/src/lib.ts new file mode 100644 index 0000000000..f683687f0d --- /dev/null +++ b/packages/http-client/src/lib.ts @@ -0,0 +1,42 @@ +import { createTypeSpecLibrary, paramMessage } from "@typespec/compiler"; + +export const $lib = createTypeSpecLibrary({ + name: "@typespec/http-client-library", + // Define diagnostics for the library. This will provide a typed API to report diagnostic as well as a auto doc generation. + diagnostics: { + "banned-alternate-name": { + severity: "error", + messages: { + default: paramMessage`Banned alternate name "${"name"}".`, + }, + }, + "use-client-context-without-provider": { + severity: "error", + messages: { + default: "useClientLibrary used without ClientLibraryProvider.", + }, + description: + "useClientLibrary used without ClientLibraryProvider. Make sure to wrap your component tree with ClientLibraryProvider or a ClientLibrary component.", + }, + "undefined-namespace-for-client": { + severity: "error", + messages: { + default: "Undefined namespace for client.", + }, + }, + "cant-find-client": { + severity: "error", + messages: { + default: "Can't find any clients in the spec", + }, + description: + "Can't find any clients in the spec. This can happen when there are no operations defined in the spec.", + }, + }, + // Defined state keys for storing metadata in decorator. + state: { + alternateName: { description: "alternateName" }, + }, +}); + +export const { reportDiagnostic, createDiagnostic, stateKeys: StateKeys } = $lib; diff --git a/packages/http-client/src/testing/index.ts b/packages/http-client/src/testing/index.ts new file mode 100644 index 0000000000..a21748a39c --- /dev/null +++ b/packages/http-client/src/testing/index.ts @@ -0,0 +1,8 @@ +import { resolvePath } from "@typespec/compiler"; +import { createTestLibrary, TypeSpecTestLibrary } from "@typespec/compiler/testing"; +import { fileURLToPath } from "url"; + +export const TypespecHttpClientLibraryTestLibrary: TypeSpecTestLibrary = createTestLibrary({ + name: "@typespec/http-client-library", + packageRoot: resolvePath(fileURLToPath(import.meta.url), "../../../../"), +}); diff --git a/packages/http-client/src/typekit/index.ts b/packages/http-client/src/typekit/index.ts new file mode 100644 index 0000000000..e69ceea9f2 --- /dev/null +++ b/packages/http-client/src/typekit/index.ts @@ -0,0 +1 @@ +export * from "./kits/index.js"; diff --git a/packages/http-client/src/typekit/kits/client-library.ts b/packages/http-client/src/typekit/kits/client-library.ts new file mode 100644 index 0000000000..9afb6d5b23 --- /dev/null +++ b/packages/http-client/src/typekit/kits/client-library.ts @@ -0,0 +1,239 @@ +import { + Enum, + getLocationContext, + Interface, + listServices, + Model, + Namespace, + navigateType, + Operation, + Scalar, + Type, + Union, +} from "@typespec/compiler"; +import { $, defineKit } from "@typespec/compiler/experimental/typekit"; +import { isHttpFile } from "@typespec/http"; +import { InternalClient } from "../../interfaces.js"; + +/** + * ClientLibraryKit provides a set of utilities to work with the client library. + */ +export interface ClientLibraryKit { + /** + * Get the top-level namespaces that are used to generate the client library. + * + * @param namespace: If namespace param is given, we will return the children of the given namespace. + * + */ + listNamespaces(namespace?: Namespace): Namespace[]; + + /** + * List all of the clients in a given namespace. + * + * @param namespace namespace to get the clients of + */ + listClients(type: Namespace | InternalClient): InternalClient[]; + + /** + * List all of the models in a given namespace. + * + * @param namespace namespace to get the models of + */ + listModels(namespace: Namespace): Model[]; + + /** + * List all of the enums in a given namespace. + * + * @param namespace namespace to get the enums of + */ + listEnums(namespace: Namespace): Enum[]; + listDataTypes(namespace: InternalClient): Array; +} + +interface TypekitExtension { + clientLibrary: ClientLibraryKit; +} + +declare module "@typespec/compiler/experimental/typekit" { + interface Typekit extends TypekitExtension {} +} + +defineKit({ + clientLibrary: { + listNamespaces(namespace) { + if (namespace) { + return [...namespace.namespaces.values()].filter((ns) => this.type.isUserDefined(ns)); + } + return [...this.program.checker.getGlobalNamespaceType().namespaces.values()].filter( + (n) => getLocationContext(this.program, n).type === "project", + ); + }, + listClients(type) { + if (type.kind === "Namespace") { + const topLevelNamespaces = listServices(this.program) + .filter((i) => i.type === type) + .map((sn) => { + return this.client.getClient(sn.type); + }); + if (topLevelNamespaces.length !== 0) { + // if we're trying to get top-level namespaces, we should return them + return topLevelNamespaces; + } + } + const clientType = type.kind === "Client" ? type.type : type; + const clientIsNamespace = clientType.kind === "Namespace"; + if (!clientIsNamespace) { + return []; + } + const subnamespaces: (Namespace | Interface)[] = [ + ...this.clientLibrary.listNamespaces(clientType), + ...clientType.interfaces.values(), + ]; + if (type.kind === "Namespace") { + // this means we're trying to get the clients of a subnamespace, so we have to include the subnamespace itself + subnamespaces.push(clientType); + } + return subnamespaces.map((sn) => { + return this.client.getClient(sn); + }); + }, + listModels(namespace) { + const allModels = [...namespace.models.values()]; + const modelsMap: Map = new Map(); + for (const op of namespace.operations.values()) { + for (const param of op.parameters.properties.values()) { + if (param.type.kind === "Model" && allModels.includes(param.type)) { + modelsMap.set(param.type.name, param.type); + for (const prop of param.type.properties.values()) { + if ( + prop.type.kind === "Model" && + allModels.includes(prop.type) && + !modelsMap.has(prop.type.name) + ) { + modelsMap.set(prop.type.name, prop.type); + } + } + } + if ( + param.sourceProperty?.type.kind === "Model" && + allModels.includes(param.sourceProperty?.type) + ) { + modelsMap.set(param.sourceProperty?.type.name, param.sourceProperty?.type); + } + } + } + return [...modelsMap.values()]; + }, + listEnums(namespace) { + return [...namespace.enums.values()]; + }, + listDataTypes(client: InternalClient) { + return collectTypes(client, { includeTemplateDeclaration: false }).dataTypes; + }, + }, +}); + +export interface TypeCollectorOptions { + includeTemplateDeclaration?: boolean; + includeTypeSpecTypes?: boolean; +} + +export function collectTypes(client: InternalClient, options: TypeCollectorOptions = {}) { + const dataTypes = new Set(); + const operations: Operation[] = []; + $.client.flat(client).forEach((c) => { + const ops = $.client.listServiceOperations(c); + operations.push(...ops); + + const params = $.client.getConstructor(c).parameters; + collectDataType(params, dataTypes, options); + }); + + for (const operation of operations) { + collectDataType(operation, dataTypes, options); + } + + return { + dataTypes: [...dataTypes], + operations: [...operations], + }; +} + +function collectDataType(type: Type, dataTypes: Set, options: TypeCollectorOptions = {}) { + navigateType( + type, + { + model(m) { + trackType(dataTypes, m); + m.derivedModels + .filter((dm) => !dataTypes.has(dm)) + .forEach((dm) => collectDataType(dm, dataTypes, options)); + }, + modelProperty(p) { + trackType(dataTypes, p.type); + }, + scalar(s) { + if (s.namespace?.name !== "TypeSpec") { + return; + } + + trackType(dataTypes, s); + }, + enum(e) { + trackType(dataTypes, e); + }, + union(u) { + trackType(dataTypes, u); + }, + unionVariant(v) { + trackType(dataTypes, v.type); + }, + }, + { includeTemplateDeclaration: options.includeTemplateDeclaration }, + ); +} + +type DataType = Model | Union | Enum | Scalar; + +function isDataType(type: Type): type is DataType { + return ( + type.kind === "Model" || type.kind === "Union" || type.kind === "Enum" || type.kind === "Scalar" + ); +} + +function isDeclaredType(type: Type): boolean { + if (isHttpFile($.program, type)) { + return true; + } + + if ("namespace" in type && type.namespace?.name === "TypeSpec") { + return false; + } + + if (!isDataType(type)) { + return false; + } + + if (type.name === undefined || type.name === "") { + return false; + } + + return true; +} + +function trackType(types: Set, type: Type) { + if ($.httpPart.is(type)) { + collectDataType($.httpPart.unpack(type), types); + return; + } + + if (!isDataType(type)) { + return; + } + + if (!isDeclaredType(type)) { + return; + } + + types.add(type); +} diff --git a/packages/http-client/src/typekit/kits/client.ts b/packages/http-client/src/typekit/kits/client.ts new file mode 100644 index 0000000000..a482f266e3 --- /dev/null +++ b/packages/http-client/src/typekit/kits/client.ts @@ -0,0 +1,215 @@ +import { + Interface, + isTemplateDeclaration, + isTemplateDeclarationOrInstance, + Namespace, + NoTarget, + Operation, +} from "@typespec/compiler"; +import { defineKit } from "@typespec/compiler/experimental/typekit"; +import { + getHttpService, + getServers, + HttpServiceAuthentication, + resolveAuthentication, +} from "@typespec/http"; +import "@typespec/http/experimental/typekit"; +import { InternalClient } from "../../interfaces.js"; +import { reportDiagnostic } from "../../lib.js"; +import { createBaseConstructor, getConstructors } from "../../utils/client-helpers.js"; +import { NameKit } from "./utils.js"; + +interface ClientKit extends NameKit { + /** + * Get the parent of a client + * @param type The client to get the parent of + */ + getParent(type: InternalClient | Namespace | Interface): InternalClient | undefined; + /** + * Flatten a client into a list of clients. This will include the client and all its sub clients recursively. + * @param client The client to flatten + */ + flat(client: InternalClient): InternalClient[]; + /** + * Get a client from a single namespace / interface + */ + getClient(namespace: Namespace | Interface): InternalClient; + /** + * Get the constructor for a client. Will return the base intersection of all possible constructors. + * + * If you'd like to look at overloads, call `this.operation.getOverloads` on the result of this function. + * + * @param client The client we're getting constructors for + */ + getConstructor(client: InternalClient): Operation; + + /** + * Whether the client can be initialized publicly + */ + isPubliclyInitializable(client: InternalClient): boolean; + + /** + * Return the methods on the client + * + * @param client the client to get the methods for + */ + listServiceOperations(client: InternalClient): Operation[]; + + /** + * Get the url template of a client, given its constructor as well */ + getUrlTemplate(client: InternalClient, constructor: Operation): string; + /** + * Determines is both clients have the same constructor + */ + haveSameConstructor(a: InternalClient, b: InternalClient): boolean; + /** + * Resolves the authentication schemes for a client + * @param client + */ + getAuth(client: InternalClient): HttpServiceAuthentication; +} + +interface TypekitExtension { + client: ClientKit; +} + +declare module "@typespec/compiler/experimental/typekit" { + interface Typekit extends TypekitExtension {} +} + +function getClientName(name: string): string { + return name.endsWith("Client") ? name : `${name}Client`; +} + +export const clientCache = new Map(); +export const clientOperationCache = new Map(); + +defineKit({ + client: { + getParent(client) { + const type = client.kind === "Client" ? client.type : client; + if (type.namespace && type.namespace !== this.program.getGlobalNamespaceType()) { + return this.client.getClient(type.namespace); + } + + return undefined; + }, + flat(client) { + const clientStack = [client]; + const clients: InternalClient[] = []; + while (clientStack.length > 0) { + const currentClient = clientStack.pop(); + if (currentClient) { + clients.push(currentClient); + clientStack.push(...this.clientLibrary.listClients(currentClient)); + } + } + return clients; + }, + getClient(namespace) { + if (!namespace) { + reportDiagnostic(this.program, { + code: "undefined-namespace-for-client", + target: NoTarget, + }); + } + if (clientCache.has(namespace)) { + return clientCache.get(namespace)!; + } + + const client = { + kind: "Client", + name: getClientName(namespace.name), + service: namespace, + type: namespace, + } as InternalClient; + + clientCache.set(namespace, client); + return client; + }, + getConstructor(client) { + const constructors = getConstructors(client); + if (constructors.length === 1) { + return constructors[0]; + } + return createBaseConstructor(client, constructors); + }, + getName(client) { + return client.name; + }, + isPubliclyInitializable(client) { + return client.type.kind === "Namespace"; + }, + listServiceOperations(client) { + if (clientOperationCache.has(client)) { + return clientOperationCache.get(client)!; + } + + if (client.type.kind === "Interface" && isTemplateDeclaration(client.type)) { + // Skip template interface operations + return []; + } + + const operations: Operation[] = []; + + for (const clientOperation of client.type.operations.values()) { + // Skip templated operations + if (isTemplateDeclarationOrInstance(clientOperation)) { + continue; + } + + operations.push(clientOperation); + } + + clientOperationCache.set(client, operations); + + return operations; + }, + getUrlTemplate(client, constructor) { + const params = this.operation.getClientSignature(client, constructor); + const endpointParams = params + .filter( + (p) => + this.modelProperty.getName(p) === "endpoint" || this.modelProperty.isHttpPathParam(p), + ) + .map((p) => p.name) + .sort(); + if (endpointParams.length === 1) { + return "{endpoint}"; + } + // here we have multiple templated arguments to an endpoint + const servers = getServers(this.program, client.service) || []; + for (const server of servers) { + const serverParams = [...server.parameters.values()].map((p) => p.name).sort(); + if (JSON.stringify(serverParams) === JSON.stringify(endpointParams)) { + // this is the server we want + return server.url; + } + } + return "{endpoint}"; + }, + haveSameConstructor(a, b) { + const aConstructor = this.client.getConstructor(a); + const bConstructor = this.client.getConstructor(b); + + const bParams = [...bConstructor.parameters.properties.values()]; + const aParams = [...aConstructor.parameters.properties.values()]; + + if (bParams.length !== aParams.length) { + return false; + } + + for (let i = 0; i < aParams.length; i++) { + if (bParams[i].type !== aParams[i].type) { + return false; + } + } + + return true; + }, + getAuth(client) { + const [httpService] = getHttpService(this.program, client.service); + return resolveAuthentication(httpService); + }, + }, +}); diff --git a/packages/http-client/src/typekit/kits/index.ts b/packages/http-client/src/typekit/kits/index.ts new file mode 100644 index 0000000000..fdbe6d2cba --- /dev/null +++ b/packages/http-client/src/typekit/kits/index.ts @@ -0,0 +1,5 @@ +export * from "./client-library.js"; +export * from "./client.js"; +export * from "./model-property.js"; +export * from "./model.js"; +export * from "./operation.js"; diff --git a/packages/http-client/src/typekit/kits/model-property.ts b/packages/http-client/src/typekit/kits/model-property.ts new file mode 100644 index 0000000000..8e4c63453b --- /dev/null +++ b/packages/http-client/src/typekit/kits/model-property.ts @@ -0,0 +1,107 @@ +import { BaseType, ModelProperty, Value } from "@typespec/compiler"; +import { defineKit } from "@typespec/compiler/experimental/typekit"; +import { HttpAuth } from "@typespec/http"; +import { InternalClient as Client } from "../../interfaces.js"; +import { authSchemeSymbol, credentialSymbol } from "../../types/credential-symbol.js"; +import { AccessKit, getAccess, getName, NameKit } from "./utils.js"; + +export interface SdkCredential extends BaseType { + kind: "Credential"; + scheme: HttpAuth; +} + +export interface SdkModelPropertyKit extends NameKit, AccessKit { + /** + * Get credential information from the model property. Returns undefined if the credential parameter + */ + getCredentialAuth(type: ModelProperty): HttpAuth[] | undefined; + + /** + * Returns whether the property is a discriminator on the model it's on. + */ + isDiscriminator(type: ModelProperty): boolean; + /** + * Returns whether it's a credential parameter or not. + * + * @param type: model property we are checking to see if is a credential parameter + */ + isCredential(modelProperty: ModelProperty): boolean; + + /** + * Returns whether the model property is part of the client's initialization or not. + */ + isOnClient(client: Client, modelProperty: ModelProperty): boolean; + + /** + * Returns whether the model property has a client default value or not. + */ + getClientDefaultValue(client: Client, modelProperty: ModelProperty): Value | undefined; + + /** + * Get access of a property + */ + getAccess(modelProperty: ModelProperty): "public" | "internal"; +} + +interface TypeKit { + modelProperty: SdkModelPropertyKit; +} + +declare module "@typespec/compiler/experimental/typekit" { + interface ModelPropertyKit extends SdkModelPropertyKit {} +} + +defineKit({ + modelProperty: { + isCredential(modelProperty) { + return credentialSymbol in modelProperty && modelProperty[credentialSymbol] === true; + }, + isOnClient(client, modelProperty) { + const clientParams = this.operation.getClientSignature( + client, + this.client.getConstructor(client), + ); + // TODO: better comparison than name + return Boolean(clientParams.find((p) => p.name === modelProperty.name)); + }, + getClientDefaultValue(client, modelProperty) { + if (!this.modelProperty.isOnClient(client, modelProperty)) return undefined; + return modelProperty.defaultValue; + }, + getCredentialAuth(type) { + if (!this.modelProperty.isCredential(type)) { + return undefined; + } + + if (type.type.kind === "Union") { + const schemes: HttpAuth[] = []; + for (const variant of type.type.variants.values()) { + if (authSchemeSymbol in variant.type && variant.type[authSchemeSymbol] !== undefined) { + const httpAuth = variant.type[authSchemeSymbol]; + schemes.push(httpAuth); + } + } + + return schemes; + } + + if (authSchemeSymbol in type.type && type.type[authSchemeSymbol] !== undefined) { + return [type.type[authSchemeSymbol]]; + } + + return []; + }, + isDiscriminator(type) { + const sourceModel = type.model; + if (!sourceModel) return false; + const disc = this.model.getDiscriminatorProperty(sourceModel); + return disc === type; + }, + getAccess(modelProperty) { + return getAccess(modelProperty); + }, + getName(modelProperty) { + return getName(modelProperty); + }, + }, +}); diff --git a/packages/http-client/src/typekit/kits/model.ts b/packages/http-client/src/typekit/kits/model.ts new file mode 100644 index 0000000000..148288b36d --- /dev/null +++ b/packages/http-client/src/typekit/kits/model.ts @@ -0,0 +1,121 @@ +import { + getDiscriminatedUnion, + getDiscriminator, + ignoreDiagnostics, + Model, + ModelProperty, + Type, +} from "@typespec/compiler"; +import { defineKit } from "@typespec/compiler/experimental/typekit"; +import { AccessKit, getAccess, getName, getUsage, NameKit, UsageKit } from "./utils.js"; + +export interface SdkModelKit extends NameKit, AccessKit, UsageKit { + /** + * Get the properties of a model. + * + * @param model model to get the properties + */ + listProperties(model: Model): ModelProperty[]; + + /** + * Get type of additionalProperties, if there are additional properties + * + * @param model model to get the additional properties type of + */ + getAdditionalPropertiesType(model: Model): Type | undefined; + + /** + * Get discriminator of a model, if a discriminator exists + * + * @param model model to get the discriminator of + */ + getDiscriminatorProperty(model: Model): ModelProperty | undefined; + + /** + * Get value of discriminator, if a discriminator exists + * + * @param model + */ + getDiscriminatorValue(model: Model): string | undefined; + + /** + * Get the discriminator mapping of the subtypes of a model, if a discriminator exists + * + * @param model + */ + getDiscriminatedSubtypes(model: Model): Record; + + /** + * Get the base model of a model, if a base model exists + * + * @param model model to get the base model + */ + getBaseModel(model: Model): Model | undefined; +} + +interface SdkKit { + model: SdkModelKit; +} + +declare module "@typespec/compiler/experimental/typekit" { + interface ModelKit extends SdkModelKit {} +} + +defineKit({ + model: { + listProperties(model) { + return [...model.properties.values()]; + }, + getAdditionalPropertiesType(model) { + // model MyModel is Record<> {} should be model with additional properties + if (model.sourceModel?.kind === "Model" && model.sourceModel?.name === "Record") { + return model.sourceModel!.indexer!.value!; + } + // model MyModel { ...Record<>} should be model with additional properties + if (model.indexer) { + return model.indexer.value; + } + return undefined; + }, + getDiscriminatorProperty(model) { + const discriminator = getDiscriminator(this.program, model); + if (!discriminator) return undefined; + for (const property of this.model.listProperties(model)) { + if (property.name === discriminator.propertyName) { + return property; + } + } + return undefined; + }, + getDiscriminatorValue(model) { + const disc = this.model.getDiscriminatorProperty(model); + if (!disc) return undefined; + switch (disc.type.kind) { + case "String": + return disc.type.value as string; + case "EnumMember": + return disc.type.name; + default: + throw Error("Discriminator must be a string or enum member"); + } + }, + getDiscriminatedSubtypes(model) { + const disc = getDiscriminator(this.program, model); + if (!disc) return {}; + const discriminatedUnion = ignoreDiagnostics(getDiscriminatedUnion(model, disc)); + return discriminatedUnion?.variants || {}; + }, + getBaseModel(model) { + return model.baseModel; + }, + getAccess(model) { + return getAccess(model); + }, + getUsage(model) { + return getUsage(model); + }, + getName(model) { + return getName(model); + }, + }, +}); diff --git a/packages/http-client/src/typekit/kits/operation.ts b/packages/http-client/src/typekit/kits/operation.ts new file mode 100644 index 0000000000..ca083526d0 --- /dev/null +++ b/packages/http-client/src/typekit/kits/operation.ts @@ -0,0 +1,120 @@ +import { ModelProperty, Operation, Type } from "@typespec/compiler"; +import { defineKit } from "@typespec/compiler/experimental/typekit"; +import { InternalClient as Client } from "../../interfaces.js"; +import { getConstructors } from "../../utils/client-helpers.js"; +import { clientOperationCache } from "./client.js"; +import { AccessKit, getAccess, getName, NameKit } from "./utils.js"; + +export interface SdkOperationKit extends NameKit, AccessKit { + /** + * Get the overloads for an operation + * + * @param client + * @param operation + */ + getOverloads(client: Client, operation: Operation): Operation[]; + /** + * Get parameters. This will take into account any parameters listed on the client + */ + getClientSignature(client: Client, operation: Operation): ModelProperty[]; + + /** + * Get valid return types for an operation + */ + getValidReturnType(operation: Operation): Type | undefined; + + /** + * Get exception response type for an operation + */ + getExceptionReturnType(operation: Operation): Type | undefined; + /** + * Gets the client in which the operation is defined + * @param operation operation to find out which client it belongs to + */ + getClient(operation: Operation): Client | undefined; +} + +interface SdkKit { + operation: SdkOperationKit; +} + +declare module "@typespec/compiler/experimental/typekit" { + interface OperationKit extends SdkOperationKit {} +} + +defineKit({ + operation: { + getClient(operation) { + for (const [client, operations] of clientOperationCache.entries()) { + if (operations.includes(operation)) { + return client; + } + } + + return undefined; + }, + getOverloads(client, operation) { + if (operation.name === "constructor") { + const constructors = getConstructors(client); + if (constructors.length > 1) { + return constructors; + } + } + return []; + }, + getAccess(operation) { + return getAccess(operation); + }, + getName(operation) { + return getName(operation); + }, + getClientSignature(client, operation) { + // TODO: filter out client parameters + return [...operation.parameters.properties.values()]; + }, + getValidReturnType(operation) { + const returnType = operation.returnType; + if (returnType === undefined) { + return undefined; + } + if (this.union.is(returnType)) { + const validTypes = [...returnType.variants.values()].filter( + (v) => !this.type.isError(v.type), + ); + if (validTypes.length === 0) { + return undefined; + } + if (validTypes.length === 1) { + return validTypes[0].type; + } + return this.union.create({ variants: validTypes }); + } + if (!this.type.isError(returnType)) { + return returnType; + } + return undefined; + }, + getExceptionReturnType(operation) { + const returnType = operation.returnType; + if (returnType === undefined) { + return undefined; + } + if (this.union.is(returnType)) { + const errorTypes = [...returnType.variants.values()].filter((v) => + this.type.isError(v.type), + ); + if (errorTypes.length === 0) { + return undefined; + } + if (errorTypes.length === 1) { + return errorTypes[0].type; + } + return this.union.create({ variants: errorTypes }); + } + if (this.type.isError(returnType)) { + return returnType; + } + return undefined; + }, + }, +}); diff --git a/packages/http-client/src/typekit/kits/utils.ts b/packages/http-client/src/typekit/kits/utils.ts new file mode 100644 index 0000000000..f09ac01f0d --- /dev/null +++ b/packages/http-client/src/typekit/kits/utils.ts @@ -0,0 +1,47 @@ +import { Type, UsageFlags } from "@typespec/compiler"; +import { InternalClient } from "../../interfaces.js"; + +export interface AccessKit { + /** + * Gets the access of a type + */ + getAccess(type: T): "public" | "internal"; +} + +export interface UsageKit { + /** + * Gets the usage of a type + */ + getUsage(type: T): UsageFlags; +} + +export interface NameKit { + /** + * Gets the name of a type, with @clientName decorator applied + */ + getName(type: T): string; +} + +export function getAccess(type: Type): "public" | "internal" { + return "public"; +} + +export function getUsage(type: Type): UsageFlags { + return UsageFlags.Input | UsageFlags.Output; +} + +export function getName(type: Type): string { + switch (type.kind) { + case "Model": + case "ModelProperty": + case "Enum": + case "Operation": + case "Namespace": + case "Interface": + return type.name; + default: + throw new Error( + "You shouldn't add getName as a type kit to an object with no name property.", + ); + } +} diff --git a/packages/http-client/src/types/credential-symbol.ts b/packages/http-client/src/types/credential-symbol.ts new file mode 100644 index 0000000000..0b42a6f591 --- /dev/null +++ b/packages/http-client/src/types/credential-symbol.ts @@ -0,0 +1,2 @@ +export const credentialSymbol = Symbol("credential"); +export const authSchemeSymbol = Symbol("authScheme"); diff --git a/packages/http-client/src/types/typespec-augmentations.d.ts b/packages/http-client/src/types/typespec-augmentations.d.ts new file mode 100644 index 0000000000..33bf69f284 --- /dev/null +++ b/packages/http-client/src/types/typespec-augmentations.d.ts @@ -0,0 +1,12 @@ +import { HttpAuth } from "@typespec/http"; +import { authSchemeSymbol, credentialSymbol } from "./credential-symbol.ts"; + +declare module "@typespec/compiler" { + interface ModelProperty { + [credentialSymbol]?: boolean; + } + + interface StringLiteral { + [authSchemeSymbol]?: HttpAuth; + } +} diff --git a/packages/http-client/src/utils/client-helpers.ts b/packages/http-client/src/utils/client-helpers.ts new file mode 100644 index 0000000000..c0bc71d637 --- /dev/null +++ b/packages/http-client/src/utils/client-helpers.ts @@ -0,0 +1,199 @@ +import { Refkey, refkey } from "@alloy-js/core"; +import { ModelProperty, Operation, StringLiteral, Type } from "@typespec/compiler"; +import { $ } from "@typespec/compiler/experimental/typekit"; +import { getHttpService, getServers, resolveAuthentication } from "@typespec/http"; +import { InternalClient } from "../interfaces.js"; +import { authSchemeSymbol, credentialSymbol } from "../types/credential-symbol.js"; +import { getStringValue, getUniqueTypes } from "./helpers.js"; + +/** + * Returns endpoint parameters, grouped by constructor. Meaning, each constructor will have its own set of parameters. + * @param client + * @returns + */ +export function getEndpointParametersPerConstructor(client: InternalClient): ModelProperty[][] { + const servers = getServers($.program, client.service); + if (servers === undefined) { + const name = "endpoint"; + return [ + [ + $.modelProperty.create({ + name, + type: $.program.checker.getStdType("string"), + optional: false, + defaultValue: getStringValue("{endpoint}"), + }), + ], + ]; + } + const retval: ModelProperty[][] = []; + for (const server of servers) { + const overridingEndpointConstructor: ModelProperty[] = []; + // add a parameter for each server, this is where users can override and pass in the full server + overridingEndpointConstructor.push( + $.modelProperty.create({ + name: "endpoint", + type: $.builtin.string, + optional: false, + defaultValue: getStringValue(server.url), + }), + ); + retval.push(overridingEndpointConstructor); + const formattingServerUrlConstructor: ModelProperty[] = []; + for (const param of server.parameters.values()) { + formattingServerUrlConstructor.push( + $.modelProperty.create({ + name: param.name, + type: param.type, + optional: param.optional, + defaultValue: param.defaultValue, + }), + ); + } + if (formattingServerUrlConstructor.length > 0) { + retval.push(formattingServerUrlConstructor); + } + } + return retval; +} + +const credentialCache = new Map(); +export function getCredentalParameter(client: InternalClient): ModelProperty | undefined { + const [httpService] = getHttpService($.program, client.service); + + const schemes = resolveAuthentication(httpService).schemes; + if (!schemes.length) return; + const credTypes: StringLiteral[] = schemes.map((scheme) => { + const schemeLiteral = $.literal.createString(scheme.type); + schemeLiteral[authSchemeSymbol] = scheme; + return schemeLiteral; + }); + + const cacheKey = getCredRefkey(credTypes); + + if (credentialCache.has(cacheKey)) { + return credentialCache.get(cacheKey)!; + } + + let credType: Type; + if (credTypes.length === 1) { + credType = credTypes[0]; + } else { + const variants = credTypes.map((v) => $.unionVariant.create({ name: v.value, type: v })); + credType = $.union.create({ variants }); + } + const credentialParameter = $.modelProperty.create({ + name: "credential", + type: credType, + }); + + credentialParameter[credentialSymbol] = true; + + credentialCache.set(cacheKey, credentialParameter); + + return credentialParameter; +} + +function getCredRefkey(credentials: StringLiteral[]): Refkey { + return refkey(credentials.map((c) => c.value).join()); +} + +export function getConstructors(client: InternalClient): Operation[] { + const constructors: Operation[] = []; + let params: ModelProperty[] = []; + const credParam = getCredentalParameter(client); + if (credParam) { + params.push(credParam); + } + const endpointParams = getEndpointParametersPerConstructor(client); + if (endpointParams.length === 1) { + // this means we have a single constructor + params = [...endpointParams[0], ...params]; + constructors.push( + $.operation.create({ + name: "constructor", + parameters: params, + returnType: $.program.checker.voidType, + }), + ); + } else { + // this means we have one constructor with overloads, one for each group of endpoint parameter + for (const endpointParamGrouping of endpointParams) { + constructors.push( + $.operation.create({ + name: "constructor", + parameters: [...endpointParamGrouping, ...params], + returnType: $.program.checker.voidType, + }), + ); + } + } + + return constructors; +} + +export function createBaseConstructor( + client: InternalClient, + constructors: Operation[], +): Operation { + const allParams: Map = new Map(); + const combinedParams: ModelProperty[] = []; + + // Collect all parameters from all constructors + constructors.forEach((constructor) => { + constructor.parameters.properties.forEach((param) => { + if (!allParams.has(param.name)) { + allParams.set(param.name, []); + } + allParams.get(param.name)!.push(param); + }); + }); + + // Combine parameter types and determine if they should be optional + allParams.forEach((params, name) => { + // if they aren't used in every single overload, then the parameter should be optional + // otherwise, it's optional if any of the overloads have it as optional + const overrideToOptional = params.length !== constructors.length; + const uniqueTypes = getUniqueTypes(params.map((param) => param.type)); + const combinedType = + uniqueTypes.length > 1 + ? $.union.create({ variants: uniqueTypes.map((x) => $.unionVariant.create({ type: x })) }) + : uniqueTypes[0]; + combinedParams.push( + $.modelProperty.create({ + name, + type: combinedType, + optional: overrideToOptional ? true : params.some((param) => param.optional), + }), + ); + }); + // Custom sorting function + combinedParams.sort((a, b) => { + // Required parameters come before optional ones + if (a.optional !== b.optional) { + return a.optional ? 1 : -1; + } + + // "endpoint" comes before "credential" + if (a.name === "endpoint" && b.name !== "endpoint") { + return -1; + } + if (a.name !== "endpoint" && b.name === "endpoint") { + return 1; + } + if (a.name === "credential" && b.name !== "credential") { + return -1; + } + if (a.name !== "credential" && b.name === "credential") { + return 1; + } + + // Alphabetical ordering + return a.name.localeCompare(b.name); + }); + return $.operation.create({ + name: "constructor", + parameters: combinedParams, + returnType: $.program.checker.voidType, + }); +} diff --git a/packages/http-client/src/utils/helpers.ts b/packages/http-client/src/utils/helpers.ts new file mode 100644 index 0000000000..cbf5fb3725 --- /dev/null +++ b/packages/http-client/src/utils/helpers.ts @@ -0,0 +1,41 @@ +import { StringValue, Type } from "@typespec/compiler"; +import { $ } from "@typespec/compiler/experimental/typekit"; + +/** + * Create a StringValue from a string value. Used for `.defaultValue` in ModelProperty + * + * @param value Value that will be used to create a StringValue + * @returns + */ +export function getStringValue(value: string): StringValue { + return { + value: value, + type: $.literal.create(value), + valueKind: "StringValue", + entityKind: "Value", + scalar: undefined, + } as StringValue; +} + +/** + * Get all of the unique types in a list of types. Filters out the duplicates and returns the resultant list of unique types. + * @param types + */ +export function getUniqueTypes(types: Type[]): Type[] { + if (types.length === 1) { + return types; + } + // we're going to keep it more simple right now. We're assuming it can be either literals or strings + + // Filter out the string scalar types + const stringScalarTypes = types.filter((t) => t.kind === "Scalar" && t.name === "string"); + + // Ensure only one string scalar type is included + const uniqueStringScalarType = stringScalarTypes.length > 0 ? [$.builtin.string] : []; + + // Filter out the string scalar types from the original list of types. Assume these are all literals + const literalScaleTypes = types.filter((t) => !(t.kind === "Scalar" && t.name === "string")); + + // Combine the unique string scalar type with the list of literals + return [...uniqueStringScalarType, ...literalScaleTypes]; +} diff --git a/packages/http-client/src/utils/index.ts b/packages/http-client/src/utils/index.ts new file mode 100644 index 0000000000..00d93e3659 --- /dev/null +++ b/packages/http-client/src/utils/index.ts @@ -0,0 +1 @@ +export * from "./client-helpers.js"; diff --git a/packages/http-client/src/utils/type-collector.ts b/packages/http-client/src/utils/type-collector.ts new file mode 100644 index 0000000000..9c846b237b --- /dev/null +++ b/packages/http-client/src/utils/type-collector.ts @@ -0,0 +1,47 @@ +import { Enum, Model, navigateType, Scalar, Type, Union } from "@typespec/compiler"; +import { $ } from "@typespec/compiler/experimental/typekit"; + +export function collectDataTypes(type: Type, dataTypes: Set) { + navigateType( + type, + { + model(model) { + if ($.httpPart.is(model)) { + const partType = $.httpPart.unpack(model); + // Need recursive call to collect data types from the part type as the semantic walker + // doesn't do it automatically. + collectDataTypes(partType, dataTypes); + return; + } + + if (!model.name) { + return; + } + + dataTypes.add(model); + }, + union(union) { + if (!union.name) { + return; + } + + dataTypes.add(union); + }, + enum(enum_) { + if (!enum_.name) { + return; + } + + dataTypes.add(enum_); + }, + scalar(scalar) { + if (!scalar.name) { + return; + } + + dataTypes.add(scalar); + }, + }, + { includeTemplateDeclaration: false, visitDerivedTypes: true }, + ); +} diff --git a/packages/http-client/test/test-host.ts b/packages/http-client/test/test-host.ts new file mode 100644 index 0000000000..34916cb1d1 --- /dev/null +++ b/packages/http-client/test/test-host.ts @@ -0,0 +1,16 @@ +import { createTestHost, createTestWrapper } from "@typespec/compiler/testing"; +import { HttpTestLibrary } from "@typespec/http/testing"; + +export async function createTypespecHttpClientLibraryTestHost() { + return createTestHost({ + libraries: [HttpTestLibrary], + }); +} + +export async function createTypespecHttpClientLibraryTestRunner() { + const host = await createTypespecHttpClientLibraryTestHost(); + + return createTestWrapper(host, { + autoUsings: ["TypeSpec.Http"], + }); +} diff --git a/packages/http-client/test/typekit/client-library.test.ts b/packages/http-client/test/typekit/client-library.test.ts new file mode 100644 index 0000000000..91c0083ca9 --- /dev/null +++ b/packages/http-client/test/typekit/client-library.test.ts @@ -0,0 +1,96 @@ +import { Namespace } from "@typespec/compiler"; +import { $ } from "@typespec/compiler/experimental/typekit"; +import { BasicTestRunner } from "@typespec/compiler/testing"; +import { beforeEach, describe, expect, it } from "vitest"; +import "../../src/typekit/index.js"; +import { createTypespecHttpClientLibraryTestRunner } from "../test-host.js"; + +let runner: BasicTestRunner; + +beforeEach(async () => { + runner = await createTypespecHttpClientLibraryTestRunner(); +}); + +describe("listNamespaces", () => { + it("basic", async () => { + await runner.compile(` + @service({ + title: "Widget Service", + }) + namespace DemoService; + `); + expect($.clientLibrary.listNamespaces()).toHaveLength(1); + expect($.clientLibrary.listNamespaces()[0].name).toEqual("DemoService"); + }); + + it("nested", async () => { + // we only want to return the top level namespaces + await runner.compile(` + @service({ + title: "Widget Service", + }) + namespace DemoService { + namespace NestedService { + namespace NestedNestedService { + } + } + } + `); + expect($.clientLibrary.listNamespaces()).toHaveLength(1); + expect($.clientLibrary.listNamespaces()[0].name).toEqual("DemoService"); + + const subNamespaces = $.clientLibrary.listNamespaces($.clientLibrary.listNamespaces()[0]); + expect(subNamespaces).toHaveLength(1); + expect(subNamespaces[0].name).toEqual("NestedService"); + + const subSubNamespaces = $.clientLibrary.listNamespaces(subNamespaces[0]); + expect(subSubNamespaces).toHaveLength(1); + expect(subSubNamespaces[0].name).toEqual("NestedNestedService"); + }); +}); + +describe("listClients", () => { + it("should only get clients for defined namespaces in the spec", async () => { + await runner.compile(` + op foo(): void; + `); + + const namespace = $.program.getGlobalNamespaceType(); + const client = $.client.getClient(namespace); + + const clients = $.clientLibrary.listClients(client); + + expect(clients).toHaveLength(0); + }); + + it("should get the client", async () => { + const { DemoService } = (await runner.compile(` + @service({ + title: "Widget Service", + }) + @test namespace DemoService; + `)) as { DemoService: Namespace }; + + const responses = $.clientLibrary.listClients(DemoService); + expect(responses).toHaveLength(1); + expect(responses[0].name).toEqual("DemoServiceClient"); + }); + it("get subclients", async () => { + const { DemoService } = (await runner.compile(` + @service({ + title: "Widget Service", + }) + @test namespace DemoService { + namespace NestedService {}; + } + `)) as { DemoService: Namespace }; + + const responses = $.clientLibrary.listClients(DemoService); + expect(responses).toHaveLength(1); + expect(responses[0].name).toEqual("DemoServiceClient"); + + const subClients = $.clientLibrary.listClients(responses[0]); + expect(subClients).toHaveLength(1); + expect(subClients[0].name).toEqual("NestedServiceClient"); + }); +}); diff --git a/packages/http-client/test/typekit/client.test.ts b/packages/http-client/test/typekit/client.test.ts new file mode 100644 index 0000000000..9f4f370072 --- /dev/null +++ b/packages/http-client/test/typekit/client.test.ts @@ -0,0 +1,446 @@ +import { Interface, Namespace } from "@typespec/compiler"; +import { $ } from "@typespec/compiler/experimental/typekit"; +import { BasicTestRunner } from "@typespec/compiler/testing"; +import { ok } from "assert"; +import { beforeEach, describe, expect, it } from "vitest"; +import "../../src/typekit/index.js"; +import { createTypespecHttpClientLibraryTestRunner } from "../test-host.js"; + +let runner: BasicTestRunner; + +beforeEach(async () => { + runner = await createTypespecHttpClientLibraryTestRunner(); +}); + +describe("isSameConstructor", () => { + it("should return true for the same client", async () => { + const { DemoService } = (await runner.compile(` + @service({ + title: "Widget Service", + }) + @test namespace DemoService; + `)) as { DemoService: Namespace }; + + const client = $.client.getClient(DemoService); + + expect($.client.haveSameConstructor(client, client)).toBeTruthy(); + }); + + it("should return false for the clients with different constructors", async () => { + const { DemoService, SubClient } = (await runner.compile(` + @service({ + title: "Widget Service", + }) + @test namespace DemoService { + @useAuth(ApiKeyAuth) + @test namespace SubClient { + } + } + `)) as { DemoService: Namespace; SubClient: Namespace }; + + const client = $.client.getClient(DemoService); + const subClient = $.client.getClient(SubClient); + + expect($.client.haveSameConstructor(client, subClient)).toBeFalsy(); + }); + + it.skip("should return true when subclient doesn't override the client params", async () => { + const { DemoService, SubClient } = (await runner.compile(` + @service({ + title: "Widget Service", + }) + @useAuth(ApiKeyAuth) + @test namespace DemoService { + @test namespace SubClient { + } + } + `)) as { DemoService: Namespace; SubClient: Namespace }; + + const demoClient = $.client.getClient(DemoService); + const subClient = $.client.getClient(SubClient); + + expect($.client.haveSameConstructor(demoClient, subClient)).toBeTruthy(); + }); + + it("should return false for the clients with different constructor", async () => { + const { DemoService, SubClient } = (await runner.compile(` + @service({ + title: "Widget Service", + }) + @test namespace DemoService { + @test namespace SubClient { + } + } + `)) as { DemoService: Namespace; SubClient: Namespace }; + + const client = $.client.getClient(DemoService); + const subClient = $.client.getClient(SubClient); + + expect($.client.haveSameConstructor(client, subClient)).toBeTruthy(); + }); +}); + +describe("getClient", () => { + it("should get a client from the globalNamespace", async () => { + (await runner.compile(` + op foo(): void; + `)) as { DemoService: Namespace }; + + const namespace = $.program.getGlobalNamespaceType(); + const client = $.client.getClient(namespace); + + expect(client.name).toEqual("Client"); + }); + + it("should get the client", async () => { + const { DemoService } = (await runner.compile(` + @service({ + title: "Widget Service", + }) + @test namespace DemoService; + `)) as { DemoService: Namespace }; + + const client = $.client.getClient(DemoService); + + expect(client.name).toEqual("DemoServiceClient"); + expect(client.service).toEqual(DemoService); + expect(client.type).toEqual(DemoService); + }); + + it("should preserve client object identity", async () => { + const { DemoService } = (await runner.compile(` + @service({ + title: "Widget Service", + }) + @test namespace DemoService; + `)) as { DemoService: Namespace }; + + const client1 = $.client.getClient(DemoService); + const client2 = $.client.getClient(DemoService); + expect(client1).toBe(client2); + }); + + it("should get a flattened list of clients", async () => { + const { DemoService, BarBaz } = (await runner.compile(` + @service({ + title: "Widget Service", + }) + @test namespace DemoService { + @test namespace Foo { + @test namespace FooBaz {} + } + + @test namespace Bar { + @test interface BarBaz {} + } + } + `)) as { DemoService: Namespace; BarBaz: Interface }; + + const client = $.client.getClient(DemoService); + const flatClients = $.client.flat(client); + const barBaz = $.client.getClient(BarBaz); + expect(flatClients).toHaveLength(5); + const barBazClient = flatClients.find((c) => c.type === barBaz.type); + expect(barBazClient).toBeDefined(); + }); +}); + +describe("getConstructor", () => { + describe("credential parameter", () => { + it("none", async () => { + const { DemoService } = (await runner.compile(` + @service({ + title: "Widget Service", + }) + @test namespace DemoService; + `)) as { DemoService: Namespace }; + + const client = $.clientLibrary.listClients(DemoService)[0]; + const constructor = $.client.getConstructor(client); + // no overloads, should just be one + expect($.operation.getOverloads(client, constructor)).toHaveLength(0); + const params = $.operation.getClientSignature(client, constructor); + expect(params).toHaveLength(1); + expect(params[0].name).toEqual("endpoint"); + expect($.scalar.isString(params[0].type)).toBeTruthy(); + }); + it("apikey", async () => { + const { DemoService } = (await runner.compile(` + @service({ + title: "Widget Service", + }) + @useAuth(ApiKeyAuth) + @test namespace DemoService; + `)) as { DemoService: Namespace }; + + const client = $.clientLibrary.listClients(DemoService)[0]; + const constructor = $.client.getConstructor(client); + // no constructor overloads, should just be one + expect($.operation.getOverloads(client, constructor)).toHaveLength(0); + const params = $.operation.getClientSignature(client, constructor); + expect(params).toHaveLength(2); + expect(params[0].name).toEqual("endpoint"); + expect($.scalar.isString(params[0].type)).toBeTruthy(); + const credParam = params[1]; + expect(credParam.name).toEqual("credential"); + ok($.literal.isString(credParam.type)); + expect(credParam.type.value).toEqual("apiKey"); + }); + + it("bearer", async () => { + const { DemoService } = (await runner.compile(` + @service({ + title: "Widget Service", + }) + @useAuth(OAuth2Auth<[{ + type: OAuth2FlowType.implicit; + authorizationUrl: "https://login.microsoftonline.com/common/oauth2/authorize"; + scopes: ["https://security.microsoft.com/.default"]; + }]>) + @test namespace DemoService; + `)) as { DemoService: Namespace }; + + const client = $.clientLibrary.listClients(DemoService)[0]; + const constructor = $.client.getConstructor(client); + // no constructor overloads, should just be one + expect($.operation.getOverloads(client, constructor)).toHaveLength(0); + const params = $.operation.getClientSignature(client, constructor); + expect(params).toHaveLength(2); + expect(params[0].name).toEqual("endpoint"); + expect($.scalar.isString(params[0].type)).toBeTruthy(); + const credParam = params[1]; + expect(credParam.name).toEqual("credential"); + ok($.literal.isString(credParam.type)); + expect(credParam.type.value).toEqual("oauth2"); + }); + }); + describe("endpoint", () => { + it("no servers", async () => { + const { DemoService } = (await runner.compile(` + @service({ + title: "Widget Service", + }) + @test namespace DemoService; + `)) as { DemoService: Namespace }; + + const client = $.clientLibrary.listClients(DemoService)[0]; + const constructor = $.client.getConstructor(client); + // no overloads, should just be one + expect($.operation.getOverloads(client, constructor)).toHaveLength(0); + const params = $.operation.getClientSignature(client, constructor); + expect(params).toHaveLength(1); + expect(params[0].name).toEqual("endpoint"); + expect($.scalar.isString(params[0].type)).toBeTruthy(); + }); + it("one server, no params", async () => { + const { DemoService } = (await runner.compile(` + @server("https://example.com", "The service endpoint") + @service({ + title: "Widget Service", + }) + @test namespace DemoService; + `)) as { DemoService: Namespace }; + + const client = $.clientLibrary.listClients(DemoService)[0]; + const constructor = $.client.getConstructor(client); + expect($.operation.getOverloads(client, constructor)).toHaveLength(0); + const params = $.operation.getClientSignature(client, constructor); + expect(params).toHaveLength(1); + expect(params[0].name).toEqual("endpoint"); + const clientDefaultValue = $.modelProperty.getClientDefaultValue(client, params[0]); + ok(clientDefaultValue?.valueKind === "StringValue"); + expect(clientDefaultValue.value).toEqual("https://example.com"); + }); + it("one server with parameter", async () => { + const { DemoService } = (await runner.compile(` + @server("https://example.com/{name}/foo", "My service url", { name: string }) + @service({ + title: "Widget Service", + }) + @test namespace DemoService; + `)) as { DemoService: Namespace }; + + const client = $.clientLibrary.listClients(DemoService)[0]; + const constructor = $.client.getConstructor(client); + + // base operation + expect(constructor.returnType).toEqual($.program.checker.voidType); + const params = $.operation.getClientSignature(client, constructor); + expect(params).toHaveLength(2); + const endpointParam = params.find((p) => p.name === "endpoint"); + ok(endpointParam); + expect(endpointParam.optional).toBeTruthy(); + + const nameParam = params.find((p) => p.name === "name"); + ok(nameParam); + expect(nameParam.optional).toBeTruthy(); + + // should have two overloads, one for completely overriding endpoint, one for just the parameter name + expect($.operation.getOverloads(client, constructor)).toHaveLength(2); + + // parameter name overload + const paramNameOverload = $.operation + .getOverloads(client, constructor) + .find((o) => $.operation.getClientSignature(client, o).find((p) => p.name === "name")); + ok(paramNameOverload); + + const paramNameOverloadParams = $.operation.getClientSignature(client, paramNameOverload); + expect(paramNameOverloadParams).toHaveLength(1); + expect(paramNameOverloadParams[0].name).toEqual("name"); + expect(paramNameOverloadParams[0].optional).toBeFalsy(); + + expect(paramNameOverload.returnType).toEqual($.program.checker.voidType); + + // endpoint overload + const endpointOverload = $.operation + .getOverloads(client, constructor) + .find((o) => $.operation.getClientSignature(client, o).find((p) => p.name === "endpoint")); + ok(endpointOverload); + + const endpointOverloadParams = $.operation.getClientSignature(client, endpointOverload); + expect(endpointOverloadParams).toHaveLength(1); + expect(endpointOverloadParams[0].name).toEqual("endpoint"); + expect(endpointOverloadParams[0].optional).toBeFalsy(); + + expect(endpointOverload.returnType).toEqual($.program.checker.voidType); + }); + it("multiple servers", async () => { + const { DemoService } = (await runner.compile(` + @server("https://example.com", "The service endpoint") + @server("https://example.org", "The service endpoint") + @service({ + title: "Widget Service", + }) + @test namespace DemoService; + `)) as { DemoService: Namespace }; + const client = $.clientLibrary.listClients(DemoService)[0]; + const constructor = $.client.getConstructor(client); + + // base operation + expect(constructor.returnType).toEqual($.program.checker.voidType); + const params = $.operation.getClientSignature(client, constructor); + expect(params).toHaveLength(1); + const endpointParam = params.find((p) => p.name === "endpoint"); + ok(endpointParam); + expect(endpointParam.optional).toBeFalsy(); + // TODO: i'm getting a ProxyRef to a String type instead of an actual string type, so $.isString is failing + ok(endpointParam.type.kind === "Scalar" && endpointParam.type.name === "string"); + + // should have two overloads, one for each server + const overloads = $.operation.getOverloads(client, constructor); + expect(overloads).toHaveLength(2); + + // .com overload + const comOverload = overloads[0]; + const comOverloadParams = $.operation.getClientSignature(client, comOverload); + expect(comOverloadParams).toHaveLength(1); + }); + }); +}); + +describe("isPubliclyInitializable", () => { + it("namespace", async () => { + const { DemoService } = (await runner.compile(` + @service({ + title: "Widget Service", + }) + @test namespace DemoService; + `)) as { DemoService: Namespace }; + + const responses = $.clientLibrary.listClients(DemoService); + expect(responses).toHaveLength(1); + expect(responses[0].name).toEqual("DemoServiceClient"); + expect($.client.isPubliclyInitializable(responses[0])).toBeTruthy(); + }); + it("nested namespace", async () => { + const { DemoService } = (await runner.compile(` + @service({ + title: "Widget Service", + }) + @test namespace DemoService { + namespace NestedService {}; + } + `)) as { DemoService: Namespace }; + + const responses = $.clientLibrary.listClients(DemoService); + expect(responses).toHaveLength(1); + expect(responses[0].name).toEqual("DemoServiceClient"); + expect($.client.isPubliclyInitializable(responses[0])).toBeTruthy(); + + const subclients = $.clientLibrary.listClients(responses[0]); + expect(subclients).toHaveLength(1); + expect(subclients[0].name).toEqual("NestedServiceClient"); + expect($.client.isPubliclyInitializable(subclients[0])).toBeTruthy(); + }); + it("nested interface", async () => { + const { DemoService } = (await runner.compile(` + @service({ + title: "Widget Service", + }) + @test namespace DemoService { + interface NestedInterface {}; + } + `)) as { DemoService: Namespace }; + + const responses = $.clientLibrary.listClients(DemoService); + expect(responses).toHaveLength(1); + expect(responses[0].name).toEqual("DemoServiceClient"); + expect($.client.isPubliclyInitializable(responses[0])).toBeTruthy(); + + const subclients = $.clientLibrary.listClients(responses[0]); + expect(subclients).toHaveLength(1); + expect(subclients[0].name).toEqual("NestedInterfaceClient"); + expect($.client.isPubliclyInitializable(subclients[0])).toBeFalsy(); + }); +}); + +describe("listServiceOperations", () => { + it("should list only operations defined in the spec", async () => { + await runner.compile(` + op foo(): void; + `); + + const namespace = $.program.getGlobalNamespaceType(); + const client = $.client.getClient(namespace); + + const operations = $.client.listServiceOperations(client); + + expect(operations).toHaveLength(1); + expect(operations[0].name).toEqual("foo"); + }); + + it("no operations", async () => { + const { DemoService } = (await runner.compile(` + @service({ + title: "Widget Service", + }) + @test namespace DemoService; + `)) as { DemoService: Namespace }; + const client = $.clientLibrary.listClients(DemoService)[0]; + const operations = $.client.listServiceOperations(client); + expect(operations).toHaveLength(0); + }); + it("nested namespace", async () => { + const { DemoService, NestedService } = (await runner.compile(` + @service({ + title: "Widget Service", + }) + @test namespace DemoService { + @route("demo") + op demoServiceOp(): void; + @test namespace NestedService { + @route("nested") + op nestedServiceOp(): void; + }; + } + `)) as { DemoService: Namespace; NestedService: Namespace }; + + const demoServiceClient = $.clientLibrary.listClients(DemoService)[0]; + expect($.client.listServiceOperations(demoServiceClient)).toHaveLength(1); + expect($.client.listServiceOperations(demoServiceClient)[0].name).toEqual("demoServiceOp"); + + const nestedServiceClient = $.clientLibrary.listClients(NestedService)[0]; + expect($.client.listServiceOperations(nestedServiceClient)).toHaveLength(1); + expect($.client.listServiceOperations(nestedServiceClient)[0].name).toEqual("nestedServiceOp"); + }); +}); diff --git a/packages/http-client/test/typekit/model-property.test.ts b/packages/http-client/test/typekit/model-property.test.ts new file mode 100644 index 0000000000..ba5bbef520 --- /dev/null +++ b/packages/http-client/test/typekit/model-property.test.ts @@ -0,0 +1,238 @@ +import { Namespace } from "@typespec/compiler"; +import { $ } from "@typespec/compiler/experimental/typekit"; +import { BasicTestRunner } from "@typespec/compiler/testing"; +import { ok } from "assert"; +import { beforeEach, describe, expect, it } from "vitest"; +import "../../src/typekit/index.js"; +import { createTypespecHttpClientLibraryTestRunner } from "../test-host.js"; + +let runner: BasicTestRunner; + +beforeEach(async () => { + runner = await createTypespecHttpClientLibraryTestRunner(); +}); + +describe("getCredentialAuth", () => { + it("should return the correct http scheme", async () => { + const { DemoService } = (await runner.compile(` + @service({ + title: "Widget Service", + }) + @useAuth(ApiKeyAuth) + @test namespace DemoService; + `)) as { DemoService: Namespace }; + + const client = $.client.getClient(DemoService); + const constructor = $.client.getConstructor(client); + const parameters = $.operation.getClientSignature(client, constructor); + + const credential = parameters.find((p) => $.modelProperty.isCredential(p)); + expect(credential).toBeDefined(); + + const auth = $.modelProperty.getCredentialAuth(credential!)!; + expect(auth).toBeDefined(); + expect(auth).toHaveLength(1); + expect(auth[0].type).toEqual("apiKey"); + }); + + it("should return the correct http schemes", async () => { + const { DemoService } = (await runner.compile(` + @service({ + title: "Widget Service", + }) + @useAuth(ApiKeyAuth | OAuth2Auth<[{ + type: OAuth2FlowType.implicit; + authorizationUrl: "https://login.microsoftonline.com/common/oauth2/authorize"; + scopes: ["https://security.microsoft.com/.default"]; + }]>) + @test namespace DemoService; + `)) as { DemoService: Namespace }; + + const client = $.client.getClient(DemoService); + const constructor = $.client.getConstructor(client); + const parameters = $.operation.getClientSignature(client, constructor); + + const credential = parameters.find((p) => $.modelProperty.isCredential(p)); + expect(credential).toBeDefined(); + + const auth = $.modelProperty.getCredentialAuth(credential!)!; + expect(auth).toBeDefined(); + expect(auth).toHaveLength(2); + expect(auth[0].type).toEqual("apiKey"); + expect(auth[1].type).toEqual("oauth2"); + }); +}); + +describe("isOnClient", () => { + describe("endpoint", () => { + it("no servers", async () => { + const { DemoService } = (await runner.compile(` + @service({ + title: "Widget Service", + }) + @test namespace DemoService; + `)) as { DemoService: Namespace }; + + const client = $.clientLibrary.listClients(DemoService)[0]; + const constructor = $.client.getConstructor(client); + const params = $.operation.getClientSignature(client, constructor); + expect(params).toHaveLength(1); + expect(params[0].name).toEqual("endpoint"); + expect($.modelProperty.isOnClient(client, params[0])).toBe(true); + }); + it("one server, no params", async () => { + const { DemoService } = (await runner.compile(` + @server("https://example.com", "The service endpoint") + @service({ + title: "Widget Service", + }) + @test namespace DemoService; + `)) as { DemoService: Namespace }; + + const client = $.clientLibrary.listClients(DemoService)[0]; + const constructor = $.client.getConstructor(client); + const params = $.operation.getClientSignature(client, constructor); + expect(params).toHaveLength(1); + expect(params[0].name).toEqual("endpoint"); + expect($.modelProperty.isOnClient(client, params[0])).toBe(true); + }); + it("one server with parameter", async () => { + const { DemoService } = (await runner.compile(` + @server("https://example.com/{name}/foo", "My service url", { name: string }) + @service({ + title: "Widget Service", + }) + @test namespace DemoService; + `)) as { DemoService: Namespace }; + + const client = $.clientLibrary.listClients(DemoService)[0]; + const constructor = $.client.getConstructor(client); + + // base operation + const params = $.operation.getClientSignature(client, constructor); + const endpointParam = params.find((p) => p.name === "endpoint"); + ok(endpointParam); + expect($.modelProperty.isOnClient(client, endpointParam)).toBe(true); + + // should have two overloads, one for completely overriding endpoint, one for just the parameter name + expect($.operation.getOverloads(client, constructor)).toHaveLength(2); + + // parameter name overload + const paramNameOverload = $.operation + .getOverloads(client, constructor) + .find((o) => $.operation.getClientSignature(client, o).find((p) => p.name === "name")); + ok(paramNameOverload); + + const paramNameOverloadParams = $.operation.getClientSignature(client, paramNameOverload); + expect(paramNameOverloadParams).toHaveLength(1); + expect(paramNameOverloadParams[0].name).toEqual("name"); + paramNameOverloadParams.forEach((p) => + expect($.modelProperty.isOnClient(client, p)).toBe(true), + ); + + // endpoint overload + const endpointOverload = $.operation + .getOverloads(client, constructor) + .find((o) => $.operation.getClientSignature(client, o).find((p) => p.name === "endpoint")); + ok(endpointOverload); + + const endpointOverloadParams = $.operation.getClientSignature(client, endpointOverload); + expect(endpointOverloadParams).toHaveLength(1); + endpointOverloadParams.forEach((p) => + expect($.modelProperty.isOnClient(client, p)).toBe(true), + ); + + expect(endpointOverloadParams[0].name).toEqual("endpoint"); + expect(endpointOverloadParams[0].optional).toBeFalsy(); + + expect(endpointOverload.returnType).toEqual($.program.checker.voidType); + }); + }); + describe("credential", () => { + it("apikey", async () => { + const { DemoService } = (await runner.compile(` + @service({ + title: "Widget Service", + }) + @useAuth(ApiKeyAuth) + @test namespace DemoService; + `)) as { DemoService: Namespace }; + + const client = $.client.getClient(DemoService); + const constructor = $.client.getConstructor(client); + // no constructor overloads, should just be one + const credential = $.operation + .getClientSignature(client, constructor) + .find((p) => $.modelProperty.isCredential(p)); + ok(credential); + expect($.modelProperty.isOnClient(client, credential)).toBe(true); + }); + it("bearer", async () => { + const { DemoService } = (await runner.compile(` + @service({ + title: "Widget Service", + }) + @useAuth(OAuth2Auth<[{ + type: OAuth2FlowType.implicit; + authorizationUrl: "https://login.microsoftonline.com/common/oauth2/authorize"; + scopes: ["https://security.microsoft.com/.default"]; + }]>) + @test namespace DemoService; + `)) as { DemoService: Namespace }; + + const client = $.clientLibrary.listClients(DemoService)[0]; + const constructor = $.client.getConstructor(client); + // no constructor overloads, should just be one + expect($.operation.getOverloads(client, constructor)).toHaveLength(0); + const credential = $.operation + .getClientSignature(client, constructor) + .find((p) => $.modelProperty.isCredential(p)); + ok(credential); + expect($.modelProperty.isOnClient(client, credential)).toBe(true); + }); + }); +}); + +describe("isCredential", () => { + it("apikey", async () => { + const { DemoService } = (await runner.compile(` + @service({ + title: "Widget Service", + }) + @useAuth(ApiKeyAuth) + @test namespace DemoService; + `)) as { DemoService: Namespace }; + + const client = $.clientLibrary.listClients(DemoService)[0]; + const constructor = $.client.getConstructor(client); + // no constructor overloads, should just be one + const credential = $.operation + .getClientSignature(client, constructor) + .find((p) => $.modelProperty.isCredential(p)); + ok(credential); + expect($.modelProperty.isCredential(credential)).toBe(true); + }); + it("bearer", async () => { + const { DemoService } = (await runner.compile(` + @service({ + title: "Widget Service", + }) + @useAuth(OAuth2Auth<[{ + type: OAuth2FlowType.implicit; + authorizationUrl: "https://login.microsoftonline.com/common/oauth2/authorize"; + scopes: ["https://security.microsoft.com/.default"]; + }]>) + @test namespace DemoService; + `)) as { DemoService: Namespace }; + + const client = $.clientLibrary.listClients(DemoService)[0]; + const constructor = $.client.getConstructor(client); + // no constructor overloads, should just be one + expect($.operation.getOverloads(client, constructor)).toHaveLength(0); + const credential = $.operation + .getClientSignature(client, constructor) + .find((p) => $.modelProperty.isCredential(p)); + ok(credential); + expect($.modelProperty.isCredential(credential)).toBe(true); + }); +}); diff --git a/packages/http-client/tsconfig.config.json b/packages/http-client/tsconfig.config.json new file mode 100644 index 0000000000..79fb341f39 --- /dev/null +++ b/packages/http-client/tsconfig.config.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": {} +} diff --git a/packages/http-client/tsconfig.json b/packages/http-client/tsconfig.json new file mode 100644 index 0000000000..3cfa099f99 --- /dev/null +++ b/packages/http-client/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, + "jsx": "preserve", + + "outDir": "dist" + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "test/**/*.ts", "test/**/*.tsx"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/http-client/vitest.config.js b/packages/http-client/vitest.config.js new file mode 100644 index 0000000000..6666bfd125 --- /dev/null +++ b/packages/http-client/vitest.config.js @@ -0,0 +1,22 @@ +import { babel } from "@rollup/plugin-babel"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["test/**/*.test.ts", "test/**/*.test.tsx"], + exclude: ["test/**/*.d.ts"], + }, + esbuild: { + jsx: "preserve", + sourcemap: "both", + }, + plugins: [ + babel({ + inputSourceMap: true, + sourceMaps: "both", + babelHelpers: "bundled", + extensions: [".ts", ".tsx"], + presets: ["@babel/preset-typescript", "@alloy-js/babel-preset"], + }), + ], +}); diff --git a/packages/http/src/experimental/typekit/kits/http-operation.ts b/packages/http/src/experimental/typekit/kits/http-operation.ts index 23e153ed71..2037298032 100644 --- a/packages/http/src/experimental/typekit/kits/http-operation.ts +++ b/packages/http/src/experimental/typekit/kits/http-operation.ts @@ -23,12 +23,12 @@ export interface HttpOperationKit { * Get the responses for the given operation. This function will return an array of responses grouped by status code and content type. * @param op operation to extract the HttpResponse from */ - getResponses(op: Operation): FlatHttpResponse[]; + flattenResponses(op: HttpOperation): FlatHttpResponse[]; /** * Get the Http Return type for the given operation. This function will resolve the returnType based on the Http Operation. * @param op operation to get the return type for */ - getReturnType(op: Operation, options?: { includeErrors?: boolean }): Type; + getReturnType(op: HttpOperation, options?: { includeErrors?: boolean }): Type; } /** @@ -47,6 +47,11 @@ export interface FlatHttpResponse { * Response content. */ responseContent: HttpOperationResponseContent; + /** + * Response type. + * + */ + type: Type; } interface TypekitExtension { @@ -66,11 +71,11 @@ defineKit({ get(op) { return ignoreDiagnostics(getHttpOperation(this.program, op)); }, - getReturnType(operation, options) { - let responses = this.httpOperation.getResponses(operation); + getReturnType(httpOperation, options) { + let responses = this.httpOperation.flattenResponses(httpOperation); if (!options?.includeErrors) { - responses = responses.filter((r) => !this.httpResponse.isErrorResponse(r.responseContent)); + responses = responses.filter((r) => !this.httpResponse.isErrorResponse(r)); } const voidType = { kind: "Intrinsic", name: "void" } as VoidType; @@ -95,9 +100,8 @@ defineKit({ return httpReturnType; }, - getResponses(operation) { + flattenResponses(httpOperation) { const responsesMap: FlatHttpResponse[] = []; - const httpOperation = this.httpOperation.get(operation); for (const response of httpOperation.responses) { for (const responseContent of response.responses) { const contentTypeProperty = responseContent.properties.find( @@ -112,7 +116,12 @@ defineKit({ contentType = "application/json"; } - responsesMap.push({ statusCode: response.statusCodes, contentType, responseContent }); + responsesMap.push({ + statusCode: response.statusCodes, + contentType, + responseContent, + type: response.type, + }); } } diff --git a/packages/http/src/experimental/typekit/kits/http-part.ts b/packages/http/src/experimental/typekit/kits/http-part.ts new file mode 100644 index 0000000000..dcc71f315e --- /dev/null +++ b/packages/http/src/experimental/typekit/kits/http-part.ts @@ -0,0 +1,51 @@ +import { Type } from "@typespec/compiler"; +import { defineKit } from "@typespec/compiler/experimental/typekit"; +import { getHttpPart, HttpPart } from "../../../private.decorators.js"; + +/** + * Utilities for working with HTTP Parts. + * @experimental + */ +export interface HttpPartKit { + /** + * Check if the model is a HTTP part. + * @param type model to check + */ + is(type: Type): boolean; + /* + * Get the HTTP part from the model. + */ + get(type: Type): HttpPart | undefined; + /** + * Unpacks the wrapped model from the HTTP part or the original model if + * not an HttpPart. + * @param type HttpPart model to unpack + */ + unpack(type: Type): Type; +} + +export interface TypekitExtension { + httpPart: HttpPartKit; +} + +declare module "@typespec/compiler/experimental/typekit" { + interface Typekit extends TypekitExtension {} +} + +defineKit({ + httpPart: { + is(type) { + return this.model.is(type) && this.httpPart.get(type) !== undefined; + }, + get(type) { + return getHttpPart(this.program, type); + }, + unpack(type) { + const part = this.httpPart.get(type); + if (part) { + return part.type; + } + return type; + }, + }, +}); diff --git a/packages/http/src/experimental/typekit/kits/http-request.ts b/packages/http/src/experimental/typekit/kits/http-request.ts index df398586da..0a64ba0b5b 100644 --- a/packages/http/src/experimental/typekit/kits/http-request.ts +++ b/packages/http/src/experimental/typekit/kits/http-request.ts @@ -4,10 +4,14 @@ import { HttpOperation } from "../../../types.js"; export type HttpRequestParameterKind = "query" | "header" | "path" | "contentType" | "body"; +/** + * Utilities for working with HTTP Requests operations. + * @experimental + */ interface HttpRequestKit { body: { /** - * Checks the body is a property explicitly tagged with @body or @bodyRoot + * Checks the body is a property explicitly tagged with @body @bodyRoot or @multipartBody * @param httpOperation the http operation to check */ isExplicit(httpOperation: HttpOperation): boolean; @@ -42,7 +46,7 @@ defineKit({ isExplicit(httpOperation: HttpOperation) { return ( httpOperation.parameters.properties.find( - (p) => p.kind === "body" || p.kind === "bodyRoot", + (p) => p.kind === "body" || p.kind === "bodyRoot" || p.kind === "multipartBody", ) !== undefined ); }, @@ -74,35 +78,25 @@ defineKit({ kind: HttpRequestParameterKind | HttpRequestParameterKind[], ): Model | undefined { const kinds = new Set(Array.isArray(kind) ? kind : [kind]); - const parameterProperties: ModelProperty[] = []; + const parameterProperties = new Map(); - for (const kind of kinds) { + kinds.forEach((kind) => { if (kind === "body") { - const bodyParams = Array.from( - this.httpRequest.getBodyParameters(httpOperation)?.properties.values() ?? [], - ); - if (bodyParams) { - parameterProperties.push(...bodyParams); - } + this.httpRequest + .getBodyParameters(httpOperation) + ?.properties.forEach((value, key) => parameterProperties.set(key, value)); } else { - const params = httpOperation.parameters.properties - .filter((p) => p.kind === kind) - .map((p) => p.property); - parameterProperties.push(...params); + httpOperation.parameters.properties + .filter((p) => p.kind === kind && p.property) + .forEach((p) => parameterProperties.set(p.property!.name, p.property!)); } - } + }); - if (parameterProperties.length === 0) { + if (parameterProperties.size === 0) { return undefined; } - const properties = parameterProperties.reduce( - (acc, prop) => { - acc[prop.name] = prop; - return acc; - }, - {} as Record, - ); + const properties = Object.fromEntries(parameterProperties); return this.model.create({ properties }); }, diff --git a/packages/http/src/experimental/typekit/kits/http-response.ts b/packages/http/src/experimental/typekit/kits/http-response.ts index db91ae9b29..7d63de2554 100644 --- a/packages/http/src/experimental/typekit/kits/http-response.ts +++ b/packages/http/src/experimental/typekit/kits/http-response.ts @@ -1,10 +1,7 @@ import { isErrorModel } from "@typespec/compiler"; import { defineKit } from "@typespec/compiler/experimental/typekit"; -import { - HttpOperationResponseContent, - HttpStatusCodeRange, - HttpStatusCodesEntry, -} from "../../../types.js"; +import { HttpStatusCodeRange, HttpStatusCodesEntry } from "../../../types.js"; +import { FlatHttpResponse } from "./http-operation.js"; /** * Utilities for working with HTTP responses. @@ -14,7 +11,7 @@ export interface HttpResponseKit { /** * Check if the response is an error response. */ - isErrorResponse(response: HttpOperationResponseContent): boolean; + isErrorResponse(response: FlatHttpResponse): boolean; /** * utilities to perform checks on status codes */ @@ -52,7 +49,7 @@ declare module "@typespec/compiler/experimental/typekit" { defineKit({ httpResponse: { isErrorResponse(response) { - return response.body ? isErrorModel(this.program, response.body.type) : false; + return this.model.is(response.type) ? isErrorModel(this.program, response.type) : false; }, statusCode: { isSingle(statusCode) { diff --git a/packages/http/src/experimental/typekit/kits/index.ts b/packages/http/src/experimental/typekit/kits/index.ts index 8f5054200d..85c11796c4 100644 --- a/packages/http/src/experimental/typekit/kits/index.ts +++ b/packages/http/src/experimental/typekit/kits/index.ts @@ -1,4 +1,6 @@ export * from "./http-operation.js"; +export * from "./http-part.js"; export * from "./http-request.js"; export * from "./http-response.js"; export * from "./model-property.js"; +export * from "./model.js"; diff --git a/packages/http/src/experimental/typekit/kits/model.ts b/packages/http/src/experimental/typekit/kits/model.ts new file mode 100644 index 0000000000..2465ec5308 --- /dev/null +++ b/packages/http/src/experimental/typekit/kits/model.ts @@ -0,0 +1,31 @@ +import { Model } from "@typespec/compiler"; +import { defineKit } from "@typespec/compiler/experimental/typekit"; +import { isHttpFile } from "../../../private.decorators.js"; + +/** + * Utilities for working with Models in the context of Http. + * @experimental + */ +export interface HttpModel { + /** + * Check if a model is an Http file. + * @param model model to check + */ + isHttpFile(model: Model): boolean; +} + +interface TypekitExtension { + model: HttpModel; +} + +declare module "@typespec/compiler/experimental/typekit" { + interface ModelKit extends HttpModel {} +} + +defineKit({ + model: { + isHttpFile(model: Model) { + return isHttpFile(this.program, model); + }, + }, +}); diff --git a/packages/http/test/experimental/typekit/http-operation.test.ts b/packages/http/test/experimental/typekit/http-operation.test.ts index 6b59534820..fbe324e5fa 100644 --- a/packages/http/test/experimental/typekit/http-operation.test.ts +++ b/packages/http/test/experimental/typekit/http-operation.test.ts @@ -34,7 +34,8 @@ describe("httpOperation:getResponses", () => { @test op getFoo(): Foo | Error; `)) as { getFoo: Operation; Foo: Model; Error: Model }; - const responses = $.httpOperation.getResponses(getFoo); + const httpOperation = $.httpOperation.get(getFoo); + const responses = $.httpOperation.flattenResponses(httpOperation); expect(responses).toHaveLength(2); expect(responses[0].statusCode).toBe(200); expect(responses[0].contentType).toBe("application/json"); @@ -56,7 +57,8 @@ describe("httpOperation:getResponses", () => { @test op getFoo(): Foo | void; `)) as { getFoo: Operation; Foo: Model; Error: Model }; - const responses = $.httpOperation.getResponses(getFoo); + const httpOperation = $.httpOperation.get(getFoo); + const responses = $.httpOperation.flattenResponses(httpOperation); expect(responses).toHaveLength(2); expect(responses[0].statusCode).toBe(200); expect(responses[0].contentType).toBe("application/json"); @@ -84,7 +86,8 @@ describe("httpOperation:getResponses", () => { @test op getFoo(): Foo | {...Foo, @header contentType: "text/plain"} | Error; `)) as { getFoo: Operation; Foo: Model; Error: Model }; - const responses = $.httpOperation.getResponses(getFoo); + const httpOperation = $.httpOperation.get(getFoo); + const responses = $.httpOperation.flattenResponses(httpOperation); expect(responses).toHaveLength(3); expect(responses[0].statusCode).toBe(200); expect(responses[0].contentType).toBe("application/json"); diff --git a/packages/http/test/experimental/typekit/http-response.test.ts b/packages/http/test/experimental/typekit/http-response.test.ts index 19590da612..7a138ebbf9 100644 --- a/packages/http/test/experimental/typekit/http-response.test.ts +++ b/packages/http/test/experimental/typekit/http-response.test.ts @@ -32,10 +32,11 @@ it("should return true for an error response", async () => { @test op getFoo(): Foo | Error; `)) as { getFoo: Operation; Foo: Model; Error: Model }; - const responses = $.httpOperation.getResponses(getFoo); + const httpOperation = $.httpOperation.get(getFoo); + const responses = $.httpOperation.flattenResponses(httpOperation); expect(responses).toHaveLength(2); - expect($.httpResponse.isErrorResponse(responses[0].responseContent)).toBe(false); - expect($.httpResponse.isErrorResponse(responses[1].responseContent)).toBe(true); + expect($.httpResponse.isErrorResponse(responses[0])).toBe(false); + expect($.httpResponse.isErrorResponse(responses[1])).toBe(true); }); it("should identify a single and default status code", async () => { @@ -57,8 +58,10 @@ it("should identify a single and default status code", async () => { @test op getFoo(): Foo | Error; `)) as { getFoo: Operation; Foo: Model; Error: Model }; - const response = $.httpOperation.getResponses(getFoo)[0]; - const error = $.httpOperation.getResponses(getFoo)[1]; + const httpOperation = $.httpOperation.get(getFoo); + const responses = $.httpOperation.flattenResponses(httpOperation); + const response = responses[0]; + const error = responses[1]; expect($.httpResponse.statusCode.isSingle(response.statusCode)).toBe(true); expect($.httpResponse.statusCode.isDefault(error.statusCode)).toBe(true); }); @@ -83,8 +86,10 @@ it("should identify a range status code", async () => { @test op getFoo(): Foo | Error; `)) as { getFoo: Operation; Foo: Model; Error: Model }; - const response = $.httpOperation.getResponses(getFoo)[0]; - const error = $.httpOperation.getResponses(getFoo)[1]; + const httpOperation = $.httpOperation.get(getFoo); + const responses = $.httpOperation.flattenResponses(httpOperation); + const response = responses[0]; + const error = responses[1]; expect($.httpResponse.statusCode.isRange(response.statusCode)).toBe(true); expect($.httpResponse.statusCode.isDefault(error.statusCode)).toBe(true); }); diff --git a/packages/http/test/typekit/http-opperation.test.ts b/packages/http/test/typekit/http-opperation.test.ts new file mode 100644 index 0000000000..ba0e92912b --- /dev/null +++ b/packages/http/test/typekit/http-opperation.test.ts @@ -0,0 +1,97 @@ +import { Model, Operation } from "@typespec/compiler"; +import { $ } from "@typespec/compiler/experimental/typekit"; +import { BasicTestRunner } from "@typespec/compiler/testing"; +import { beforeEach, describe, expect, it } from "vitest"; +import "../../src/experimental/typekit/index.js"; +import { createHttpTestRunner } from "./../test-host.js"; + +let runner: BasicTestRunner; + +beforeEach(async () => { + runner = await createHttpTestRunner(); +}); + +describe("httpOperation:getResponses", () => { + it("should get responses", async () => { + const { getFoo } = (await runner.compile(` + @test model Foo { + @visibility("create") + id: int32; + age: int32; + name: string; + } + + @error + @test model Error { + message: string; + code: int32 + } + + @route("/foo") + @get + @test op getFoo(): Foo | Error; + `)) as { getFoo: Operation; Foo: Model; Error: Model }; + + const httpOperation = $.httpOperation.get(getFoo); + const responses = $.httpOperation.flattenResponses(httpOperation); + expect(responses).toHaveLength(2); + expect(responses[0].statusCode).toBe(200); + expect(responses[0].contentType).toBe("application/json"); + expect(responses[1].statusCode).toBe("*"); + expect(responses[1].contentType).toBe("application/json"); + }); + + it("should get responses with multiple status codes", async () => { + const { getFoo } = (await runner.compile(` + @test model Foo { + @visibility("create") + id: int32; + age: int32; + name: string; + } + + @route("/foo") + @get + @test op getFoo(): Foo | void; + `)) as { getFoo: Operation; Foo: Model; Error: Model }; + + const httpOperation = $.httpOperation.get(getFoo); + const responses = $.httpOperation.flattenResponses(httpOperation); + expect(responses).toHaveLength(2); + expect(responses[0].statusCode).toBe(200); + expect(responses[0].contentType).toBe("application/json"); + expect(responses[1].statusCode).toBe(204); + expect(responses[1].contentType).toBe(undefined); + }); + + it("should get responses with multiple status codes and contentTypes", async () => { + const { getFoo } = (await runner.compile(` + @test model Foo { + @visibility("create") + id: int32; + age: int32; + name: string; + } + + @error + @test model Error { + message: string; + code: int32 + } + + @route("/foo") + @get + @test op getFoo(): Foo | {...Foo, @header contentType: "text/plain"} | Error; + `)) as { getFoo: Operation; Foo: Model; Error: Model }; + + const httpOperation = $.httpOperation.get(getFoo); + const responses = $.httpOperation.flattenResponses(httpOperation); + expect(responses).toHaveLength(3); + expect(responses[0].statusCode).toBe(200); + expect(responses[0].contentType).toBe("application/json"); + expect(responses[1].statusCode).toBe(200); + expect(responses[1].contentType).toBe("text/plain"); + expect(responses[2].statusCode).toBe("*"); + expect(responses[2].contentType).toBe("application/json"); + }); +}); diff --git a/packages/http/test/typekit/http-request.test.ts b/packages/http/test/typekit/http-request.test.ts new file mode 100644 index 0000000000..8e9aa8fa35 --- /dev/null +++ b/packages/http/test/typekit/http-request.test.ts @@ -0,0 +1,320 @@ +import { Model, Operation } from "@typespec/compiler"; +import { $ } from "@typespec/compiler/experimental/typekit"; +import { BasicTestRunner } from "@typespec/compiler/testing"; +import { beforeEach, describe, expect, it } from "vitest"; +import "../../src/experimental/typekit/index.js"; +import { createHttpTestRunner } from "./../test-host.js"; + +let runner: BasicTestRunner; + +beforeEach(async () => { + runner = await createHttpTestRunner(); +}); + +describe("HttpRequest Body Parameters", () => { + it("should handle model is array response", async () => { + const { get } = (await runner.compile(` + model EmbeddingVector is Array; + + @test op get(): EmbeddingVector; + `)) as { get: Operation; Foo: Model }; + + const httpOperation = $.httpOperation.get(get); + const body = $.httpOperation.getReturnType(httpOperation)!; + expect(body).toBeDefined(); + expect($.model.is(body)).toBe(true); + expect($.array.is(body)).toBe(true); + expect($.array.getElementType(body as Model)).toBe($.builtin.int32); + }); + + it("should get the body parameters model when spread", async () => { + const { createFoo } = (await runner.compile(` + @test model Foo { + id: int32; + age: int32; + name: string; + } + + @route("/foo") + @post + @test op createFoo(...Foo): void; + `)) as { createFoo: Operation; Foo: Model }; + + const httpOperation = $.httpOperation.get(createFoo); + const body = $.httpRequest.getBodyParameters(httpOperation)!; + expect(body).toBeDefined(); + expect($.model.is(body)).toBe(true); + expect($.model.isExpresion(body as Model)).toBe(true); + expect((body as Model).properties.size).toBe(3); + }); + + it("should get the body model params when body is defined explicitly as a property", async () => { + const { createFoo } = (await runner.compile(` + @route("/foo") + @post + @test op createFoo(@body foo: int32): void; + `)) as { createFoo: Operation; Foo: Model }; + + const httpOperation = $.httpOperation.get(createFoo); + const body = $.httpRequest.getBodyParameters(httpOperation)!; + expect(body).toBeDefined(); + expect($.model.is(body)).toBe(true); + expect($.model.isExpresion(body)).toBe(true); + expect(body.properties.size).toBe(1); + expect(body.properties.get("foo")!.name).toBe("foo"); + }); + + it("should get the body when spread and nested", async () => { + const { createFoo } = (await runner.compile(` + @test model Foo { + @path id: int32; + age: int32; + name: string; + options: { + @path token: string; + subProp: string; + } + } + + @route("/foo") + @post + @test op createFoo(...Foo): void; + `)) as { createFoo: Operation; Foo: Model }; + + const httpOperation = $.httpOperation.get(createFoo); + const body = $.httpRequest.getBodyParameters(httpOperation)!; + expect(body).toBeDefined(); + expect((body as Model).properties.size).toBe(3); + const properties = Array.from(body.properties.values()) + .map((p) => p.name) + .join(","); + expect(properties).toBe("age,name,options"); + + const optionsParam = (body as Model).properties.get("options")!.type as Model; + const optionsProps = Array.from(optionsParam.properties.values()) + .map((p) => p.name) + .join(","); + + // TODO: Why do we get the path property token here? + expect(optionsProps).toEqual("token,subProp"); + }); + + it("should get the body when named body model", async () => { + const { createFoo } = (await runner.compile(` + @test model Foo { + id: int32; + age: int32; + name: string; + } + + @route("/foo") + @post + @test op createFoo(@body foo: Foo): void; + `)) as { createFoo: Operation; Foo: Model }; + + const httpOperation = $.httpOperation.get(createFoo); + const body = $.httpRequest.getBodyParameters(httpOperation)!; + expect(body).toBeDefined(); + expect($.model.is(body)).toBe(true); + // Should have a single property called foo + expect(body.properties.size).toBe(1); + expect((body.properties.get("foo")?.type as Model).name).toBe("Foo"); + }); + + it("should get the named body body when combined", async () => { + const { createFoo } = (await runner.compile(` + @test model Foo { + @path id: int32; + age: int32; + name: string; + } + + @route("/foo") + @post + @test op createFoo(foo: Foo): void; + `)) as { createFoo: Operation; Foo: Model }; + + const httpOperation = $.httpOperation.get(createFoo); + const body = $.httpRequest.getBodyParameters(httpOperation)!; + expect(body).toBeDefined(); + expect($.model.is(body)).toBe(true); + // foo is a positional parameter to the operation, but not the body itself so the body model is anonymous with a single property "foo" + expect($.model.isExpresion(body as Model)).toBe(true); + expect((body as Model).properties.size).toBe(1); + expect(((body as Model).properties.get("foo")?.type as any).name).toBe("Foo"); + }); +}); + +describe("HttpRequest Get Parameters", () => { + it("should only have body parameters", async () => { + const { createFoo } = (await runner.compile(` + @test model Foo { + id: int32; + age: int32; + name: string; + } + + @route("/foo") + @post + @test op createFoo(...Foo): void; + `)) as { createFoo: Operation; Foo: Model }; + + const httpOperation = $.httpOperation.get(createFoo); + const body = $.httpRequest.getBodyParameters(httpOperation)!; + const headers = $.httpRequest.getParameters(httpOperation, "header"); + const path = $.httpRequest.getParameters(httpOperation, "path"); + const query = $.httpRequest.getParameters(httpOperation, "query"); + expect(body).toBeDefined(); + expect(headers).toBeUndefined(); + expect(path).toBeUndefined(); + expect(query).toBeUndefined(); + }); + + it("should be able to get parameter options", async () => { + const { createFoo } = (await runner.compile(` + @test model Foo { + @path(#{allowReserved: true}) id: string; + @header({format: "csv"}) requestId: string[]; + @query(#{explode: true}) data: string[]; + } + + @route("/foo") + @post + @test op createFoo(...Foo): void; + `)) as { createFoo: Operation; Foo: Model }; + + const httpOperation = $.httpOperation.get(createFoo); + const headers = $.httpRequest.getParameters(httpOperation, "header"); + const path = $.httpRequest.getParameters(httpOperation, "path"); + const query = $.httpRequest.getParameters(httpOperation, "query"); + + const requestIdProperty = headers!.properties.get("requestId"); + const idProperty = path!.properties.get("id"); + const dataProperty = query!.properties.get("data"); + + expect($.modelProperty.getHttpHeaderOptions(requestIdProperty!)).toStrictEqual({ + format: "csv", + name: "request-id", + type: "header", + }); + + expect($.modelProperty.getHttpPathOptions(idProperty!)).toStrictEqual({ + allowReserved: true, + explode: false, + name: "id", + style: "simple", + type: "path", + }); + + expect($.modelProperty.getHttpQueryOptions(dataProperty!)).toStrictEqual({ + explode: true, + format: "multi", + name: "data", + type: "query", + }); + }); + + it("should only have header parameters", async () => { + const { createFoo } = (await runner.compile(` + @test model Foo { + @path id: int32; + age: int32; + name: string; + } + + @route("/foo") + @post + @test op createFoo(...Foo): void; + `)) as { createFoo: Operation; Foo: Model }; + + const httpOperation = $.httpOperation.get(createFoo); + const body = $.httpRequest.getBodyParameters(httpOperation)! as Model; + const headers = $.httpRequest.getParameters(httpOperation, "header"); + const path = $.httpRequest.getParameters(httpOperation, "path")!; + const query = $.httpRequest.getParameters(httpOperation, "query"); + expect(body).toBeDefined(); + expect(body.properties.size).toBe(2); + expect(path).toBeDefined(); + expect(path.properties.size).toBe(1); + expect(path.properties.get("id")?.name).toBe("id"); + expect(headers).toBeUndefined(); + expect(query).toBeUndefined(); + }); + + it("should only have path parameters", async () => { + const { createFoo } = (await runner.compile(` + @test model Foo { + @header id: int32; + @header age: int32; + name: string; + } + + @route("/foo") + @post + @test op createFoo(...Foo): void; + `)) as { createFoo: Operation; Foo: Model }; + + const httpOperation = $.httpOperation.get(createFoo); + const body = $.httpRequest.getBodyParameters(httpOperation)! as Model; + const headers = $.httpRequest.getParameters(httpOperation, "header")!; + const path = $.httpRequest.getParameters(httpOperation, "path"); + const query = $.httpRequest.getParameters(httpOperation, "query"); + expect(body).toBeDefined(); + expect(body.properties.size).toBe(1); + expect(headers).toBeDefined(); + expect(headers.properties.size).toBe(2); + expect(headers.properties.get("id")?.name).toBe("id"); + expect(headers.properties.get("age")?.name).toBe("age"); + expect(path).toBeUndefined(); + expect(query).toBeUndefined(); + }); + + it("should only have query parameters", async () => { + const { createFoo } = (await runner.compile(` + @test model Foo { + @query id: int32; + @query age: int32; + name: string; + } + + @route("/foo") + @post + @test op createFoo(...Foo): void; + `)) as { createFoo: Operation; Foo: Model }; + + const httpOperation = $.httpOperation.get(createFoo); + const body = $.httpRequest.getBodyParameters(httpOperation)! as Model; + const headers = $.httpRequest.getParameters(httpOperation, "header"); + const path = $.httpRequest.getParameters(httpOperation, "path"); + const query = $.httpRequest.getParameters(httpOperation, "query")!; + expect(body).toBeDefined(); + expect(body.properties.size).toBe(1); + expect(query).toBeDefined(); + expect(query.properties.size).toBe(2); + expect(query.properties.get("id")?.name).toBe("id"); + expect(query.properties.get("age")?.name).toBe("age"); + expect(path).toBeUndefined(); + expect(headers).toBeUndefined(); + }); + + it("should have query and header parameters", async () => { + const { createFoo } = (await runner.compile(` + @test model Foo { + @query id: int32; + @header age: int32; + name: string; + } + + @route("/foo") + @post + @test op createFoo(...Foo): void; + `)) as { createFoo: Operation; Foo: Model }; + + const httpOperation = $.httpOperation.get(createFoo); + const headerAndQuery = $.httpRequest.getParameters(httpOperation, ["header", "query"]); + expect(headerAndQuery).toBeDefined(); + expect(headerAndQuery!.properties.size).toBe(2); + expect(headerAndQuery!.properties.get("id")?.name).toBe("id"); + expect(headerAndQuery!.properties.get("age")?.name).toBe("age"); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c2061859d8..fbe8987257 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: .: devDependencies: + '@alloy-js/prettier-plugin-alloy': + specifier: ^0.1.0 + version: 0.1.0 '@chronus/chronus': specifier: ^0.14.1 version: 0.14.1 @@ -396,6 +399,73 @@ importers: specifier: ~9.2.0 version: 9.2.0 + packages/emitter-framework: + dependencies: + '@alloy-js/core': + specifier: ^0.5.0 + version: 0.5.0 + '@alloy-js/typescript': + specifier: ^0.5.0 + version: 0.5.0 + '@typespec/compiler': + specifier: workspace:~ + version: link:../compiler + '@typespec/http': + specifier: workspace:~ + version: link:../http + '@typespec/rest': + specifier: workspace:~ + version: link:../rest + devDependencies: + '@alloy-js/babel-preset': + specifier: ^0.1.1 + version: 0.1.1(@babel/core@7.26.0) + '@babel/cli': + specifier: ^7.24.8 + version: 7.26.4(@babel/core@7.26.0) + '@babel/core': + specifier: ^7.26.0 + version: 7.26.0 + '@rollup/plugin-babel': + specifier: ^6.0.4 + version: 6.0.4(@babel/core@7.26.0)(@types/babel__core@7.20.5)(rollup@4.34.6) + '@types/minimist': + specifier: ^1.2.5 + version: 1.2.5 + concurrently: + specifier: ^9.1.2 + version: 9.1.2 + minimist: + specifier: ^1.2.8 + version: 1.2.8 + prettier: + specifier: ~3.4.2 + version: 3.4.2 + tree-sitter: + specifier: ^0.21.1 + version: 0.21.1 + tree-sitter-c-sharp: + specifier: ^0.23.0 + version: 0.23.1(tree-sitter@0.21.1) + tree-sitter-java: + specifier: ^0.23.2 + version: 0.23.5(tree-sitter@0.21.1) + tree-sitter-javascript: + specifier: ^0.23.0 + version: 0.23.1(tree-sitter@0.21.1) + tree-sitter-python: + specifier: ^0.23.2 + version: 0.23.6(tree-sitter@0.21.1) + tree-sitter-typescript: + specifier: ^0.23.0 + version: 0.23.2(tree-sitter@0.21.1) + typescript: + specifier: ~5.7.3 + version: 5.7.3 + vitest: + specifier: ^3.0.5 + version: 3.0.5(@types/debug@4.1.12)(@types/node@22.10.10)(@vitest/ui@3.0.4)(happy-dom@16.7.2)(jiti@1.21.6)(jsdom@19.0.0)(tsx@4.19.2)(yaml@2.7.0) + packages/eslint-plugin-typespec: dependencies: '@typescript-eslint/utils': @@ -544,6 +614,9 @@ importers: vite-plugin-dts: specifier: 4.5.0 version: 4.5.0(@types/node@22.10.10)(rollup@4.34.6)(typescript@5.7.3)(vite@6.0.11(@types/node@22.10.10)(jiti@1.21.6)(tsx@4.19.2)(yaml@2.7.0)) + vite-plugin-node-polyfills: + specifier: ^0.23.0 + version: 0.23.0(rollup@4.34.6)(vite@6.0.11(@types/node@22.10.10)(jiti@1.21.6)(tsx@4.19.2)(yaml@2.7.0)) vitest: specifier: ^3.0.5 version: 3.0.5(@types/debug@4.1.12)(@types/node@22.10.10)(@vitest/ui@3.0.4)(happy-dom@16.7.2)(jiti@1.21.6)(jsdom@19.0.0)(tsx@4.19.2)(yaml@2.7.0) @@ -584,6 +657,46 @@ importers: specifier: ^3.0.5 version: 3.0.5(@types/debug@4.1.12)(@types/node@22.10.10)(@vitest/ui@3.0.4)(happy-dom@16.7.2)(jiti@1.21.6)(jsdom@19.0.0)(tsx@4.19.2)(yaml@2.7.0) + packages/http-client: + dependencies: + '@alloy-js/core': + specifier: ^0.5.0 + version: 0.5.0 + '@typespec/compiler': + specifier: workspace:~ + version: link:../compiler + '@typespec/http': + specifier: workspace:~ + version: link:../http + devDependencies: + '@alloy-js/babel-preset': + specifier: ^0.1.1 + version: 0.1.1(@babel/core@7.26.0) + '@babel/cli': + specifier: ^7.24.8 + version: 7.26.4(@babel/core@7.26.0) + '@babel/core': + specifier: ^7.26.0 + version: 7.26.0 + '@rollup/plugin-babel': + specifier: ^6.0.4 + version: 6.0.4(@babel/core@7.26.0)(@types/babel__core@7.20.5)(rollup@4.34.6) + '@types/node': + specifier: ~22.10.10 + version: 22.10.10 + eslint: + specifier: ^9.18.0 + version: 9.18.0(jiti@1.21.6) + prettier: + specifier: ~3.4.2 + version: 3.4.2 + typescript: + specifier: ~5.7.3 + version: 5.7.3 + vitest: + specifier: ^3.0.5 + version: 3.0.5(@types/debug@4.1.12)(@types/node@22.10.10)(@vitest/ui@3.0.4)(happy-dom@16.7.2)(jiti@1.21.6)(jsdom@19.0.0)(tsx@4.19.2)(yaml@2.7.0) + packages/http-server-csharp: dependencies: change-case: @@ -2392,6 +2505,29 @@ packages: resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} + '@alloy-js/babel-plugin-jsx-dom-expressions@0.37.21': + resolution: {integrity: sha512-1ULoB6jxSgeRBnR9ktOqj6jewUc3zNRzx4sk8shyqwaD9kLKJ03cObmTFn0xDR3Y3JOP3TmhobL4/niycPyWYA==} + peerDependencies: + '@babel/core': ^7.24.7 + + '@alloy-js/babel-plugin@0.1.0': + resolution: {integrity: sha512-G4Is8ZECXVkbbSXvitMqJOfWeWYmd+ZRdnLxk9MGOrw/N2Sh/d8QXx9rI1DNJuMNf3wi3iE60p5srhtUGNLt8g==} + peerDependencies: + '@babel/core': ^7.24.7 + + '@alloy-js/babel-preset@0.1.1': + resolution: {integrity: sha512-sX3nb9+qciBXTaafIYYa/aW3FFZPcSIWm7VFwz134nFJu79jMS+JgsBgcL+md6zH4Vf9WPE/8V1E4LX9WdxS1A==} + + '@alloy-js/core@0.5.0': + resolution: {integrity: sha512-EGmjQ8f02xeKPchq3fLlRhK5mt1acce9x0eG1MLOGidNY/PNPlwm6McgT4kSlTIvqGB1R8S64MQQIh86fOpDhQ==} + + '@alloy-js/prettier-plugin-alloy@0.1.0': + resolution: {integrity: sha512-mFfag8sQm5gPJMoGvJNc1fx66Z3X3nxCeoiitBeFnd5nkBMsMnvVw6LZSrVyybt2qTzY+G/9Qvz3ULkW7RB15Q==} + engines: {node: '>=18.0.0'} + + '@alloy-js/typescript@0.5.0': + resolution: {integrity: sha512-asIUIxqCJDBw3HINDdy+mj5vWm3FUCLlXxfSuydyCzmFvVIfMz2TLQGa660CNx98KLsNzCWegmXnsC5QYH3rbg==} + '@ampproject/remapping@2.3.0': resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} engines: {node: '>=6.0.0'} @@ -2563,6 +2699,13 @@ packages: resolution: {integrity: sha512-SriLPKezypIsiZ+TtlFfE46uuBIap2HeaQVS78e1P7rz5OSbq0rsd52WE1mC5f7vAeLiXqv7I7oRhL3WFZEw3Q==} engines: {node: '>=18.0.0'} + '@babel/cli@7.26.4': + resolution: {integrity: sha512-+mORf3ezU3p3qr+82WvJSnQNE1GAYeoCfEv4fik6B5/2cvKZ75AX8oawWQdXtM9MmndooQj15Jr9kelRFWsuRw==} + engines: {node: '>=6.9.0'} + hasBin: true + peerDependencies: + '@babel/core': ^7.0.0-0 + '@babel/code-frame@7.12.11': resolution: {integrity: sha512-Zt1yodBx1UcyiePMSkWnU4hPqhwq7hGi2nFL1LeA3EUl+q2LQx16MISgJ0+z7dnmgvP9QtIleuETGOiOH1RcIw==} @@ -2615,6 +2758,10 @@ packages: resolution: {integrity: sha512-wbfdZ9w5vk0C0oyHqAJbc62+vet5prjj01jjJ8sKn3j9h3MQQlflEdXYvuqRWjHnM12coDEqiC1IRCi0U/EKwQ==} engines: {node: '>=6.9.0'} + '@babel/helper-module-imports@7.18.6': + resolution: {integrity: sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA==} + engines: {node: '>=6.9.0'} + '@babel/helper-module-imports@7.25.9': resolution: {integrity: sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw==} engines: {node: '>=6.9.0'} @@ -3169,6 +3316,10 @@ packages: engines: {node: '>=16.0.0'} hasBin: true + '@colors/colors@1.5.0': + resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} + engines: {node: '>=0.1.90'} + '@colors/colors@1.6.0': resolution: {integrity: sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==} engines: {node: '>=0.1.90'} @@ -4752,6 +4903,9 @@ packages: '@microsoft/tsdoc@0.15.1': resolution: {integrity: sha512-4aErSrCR/On/e5G2hDP0wjooqDdauzEbIq8hIkIe5pXV0rtWJZvdCEKL0ykZxex+IxIwBp0eGeV48hQN07dXtw==} + '@nicolo-ribaudo/chokidar-2@2.1.8-no-fsevents.3': + resolution: {integrity: sha512-s88O1aVtXftvp5bCPB7WnmXc5IwOZZ7YPuwNPt+GtOOXpPvad1LfbmjYv+qII7zP6RU2QGnqve27dnLycEnyEQ==} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -5146,6 +5300,19 @@ packages: rollup: optional: true + '@rollup/plugin-babel@6.0.4': + resolution: {integrity: sha512-YF7Y52kFdFT/xVSuVdjkV5ZdX/3YtmX0QulG+x0taQOtJdHYzVU61aSSkAgVJ7NOv6qPkIYiJSgSWWN/DM5sGw==} + engines: {node: '>=14.0.0'} + peerDependencies: + '@babel/core': ^7.0.0 + '@types/babel__core': ^7.1.9 + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + '@types/babel__core': + optional: true + rollup: + optional: true + '@rollup/plugin-commonjs@28.0.2': resolution: {integrity: sha512-BEFI2EDqzl+vA1rl97IDRZ61AIwGH093d9nz8+dThxJNH8oSoB7MjWvPCX3dkaK1/RCJ/1v/R1XB15FuSs0fQw==} engines: {node: '>=16.0.0 || 14 >= 14.17'} @@ -5888,6 +6055,9 @@ packages: '@types/mime@1.3.5': resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} + '@types/minimist@1.2.5': + resolution: {integrity: sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==} + '@types/mocha@10.0.10': resolution: {integrity: sha512-xPyYSz1cMPnJQhl0CLMH68j3gprKZaTjG3s5Vi+fDgx+uhG9NOXwbVt52eFS8ECyXhyKcjDLCBEqBExKuiZb7Q==} @@ -6295,6 +6465,9 @@ packages: typescript: optional: true + '@vue/reactivity@3.5.13': + resolution: {integrity: sha512-NaCwtw8o48B9I6L1zl2p41OHo/2Z4wqYGGIK1Khu5T7yxrn+ATOixn/Udn2m+6kZKB/J7cuT9DbWWhRxqixACg==} + '@vue/shared@3.5.13': resolution: {integrity: sha512-/hnE/qP5ZoGpol0a5mDi45bOd7t3tjYJBjsgCsivow7D48cJeV5l05RD82lPqi7gRiphZM37rnhW1l6ZoCNNnQ==} @@ -7243,6 +7416,10 @@ packages: resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} engines: {node: '>=6'} + cli-table3@0.6.5: + resolution: {integrity: sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==} + engines: {node: 10.* || >= 12.*} + cli-truncate@2.1.0: resolution: {integrity: sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==} engines: {node: '>=8'} @@ -8595,6 +8772,9 @@ packages: resolution: {integrity: sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + fs-readdir-recursive@1.1.0: + resolution: {integrity: sha512-GNanXlVr2pf02+sPN40XN8HG+ePaNcvM0q5mZBd668Obwb0yD5GiUbZOFgwn8kGMY6I3mdyDJzieUy3PTYyTRA==} + fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} @@ -8953,6 +9133,9 @@ packages: resolution: {integrity: sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==} engines: {node: '>=12'} + html-entities@2.3.3: + resolution: {integrity: sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA==} + html-escape@1.0.2: resolution: {integrity: sha512-r4cqVc7QAX1/jpPsW9OJNsTTtFhcf+ZBqoA3rWOddMg/y+n6ElKfz+IGKbvV2RTeECDzyrQXa2rpo3IFFrANWg==} @@ -10308,6 +10491,10 @@ packages: node-addon-api@4.3.0: resolution: {integrity: sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ==} + node-addon-api@8.3.0: + resolution: {integrity: sha512-8VOpLHFrOQlAH+qA0ZzuGRlALRA6/LVh8QJldbrC4DY0hXoMP0l4Acq8TzFC018HztWiRqyCEj2aTWY2UvnJUg==} + engines: {node: ^18 || ^20 || >= 21} + node-dir@0.1.17: resolution: {integrity: sha512-tmPX422rYgofd4epzrNoOXiE8XFZYOcCq1vD7MAXCDO+O+zndlA2ztdKKMa+EeuBG5tHETpr4ml4RGgpqDCCAg==} engines: {node: '>= 0.10.5'} @@ -10323,6 +10510,10 @@ packages: resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + node-gyp-build@4.8.4: + resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} + hasBin: true + node-gyp@11.0.0: resolution: {integrity: sha512-zQS+9MTTeCMgY0F3cWPyJyRFAkVltQ1uXm+xXu/ES6KFgC6Czo1Seb9vQW2wNxSX2OrDTiqL0ojtkFxBQ0ypIw==} engines: {node: ^18.17.0 || >=20.5.0} @@ -11226,7 +11417,7 @@ packages: engines: {node: '>= 14.16.0'} readline-sync@1.4.9: - resolution: {integrity: sha1-PtqOZfI80qF+YTAbHwADOWr17No=} + resolution: {integrity: sha512-mp5h1N39kuKbCRGebLPIKTBOhuDw55GaNg5S+K9TW9uDAS1wIHpGUc2YokdUMZJb8GqS49sWmWEDijaESYh0Hg==} engines: {node: '>= 0.8.0'} realpath-missing@1.1.0: @@ -11710,6 +11901,10 @@ packages: engines: {node: '>=14.0.0', npm: '>=6.0.0'} hasBin: true + slash@2.0.0: + resolution: {integrity: sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==} + engines: {node: '>=6'} + slash@3.0.0: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} @@ -12142,6 +12337,49 @@ packages: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true + tree-sitter-c-sharp@0.23.1: + resolution: {integrity: sha512-9zZ4FlcTRWWfRf6f4PgGhG8saPls6qOOt75tDfX7un9vQZJmARjPrAC6yBNCX2T/VKcCjIDbgq0evFaB3iGhQw==} + peerDependencies: + tree-sitter: ^0.21.1 + peerDependenciesMeta: + tree-sitter: + optional: true + + tree-sitter-java@0.23.5: + resolution: {integrity: sha512-Yju7oQ0Xx7GcUT01mUglPP+bYfvqjNCGdxqigTnew9nLGoII42PNVP3bHrYeMxswiCRM0yubWmN5qk+zsg0zMA==} + peerDependencies: + tree-sitter: ^0.21.1 + peerDependenciesMeta: + tree-sitter: + optional: true + + tree-sitter-javascript@0.23.1: + resolution: {integrity: sha512-/bnhbrTD9frUYHQTiYnPcxyHORIw157ERBa6dqzaKxvR/x3PC4Yzd+D1pZIMS6zNg2v3a8BZ0oK7jHqsQo9fWA==} + peerDependencies: + tree-sitter: ^0.21.1 + peerDependenciesMeta: + tree-sitter: + optional: true + + tree-sitter-python@0.23.6: + resolution: {integrity: sha512-yIM9z0oxKIxT7bAtPOhgoVl6gTXlmlIhue7liFT4oBPF/lha7Ha4dQBS82Av6hMMRZoVnFJI8M6mL+SwWoLD3A==} + peerDependencies: + tree-sitter: ^0.22.1 + peerDependenciesMeta: + tree-sitter: + optional: true + + tree-sitter-typescript@0.23.2: + resolution: {integrity: sha512-e04JUUKxTT53/x3Uq1zIL45DoYKVfHH4CZqwgZhPg5qYROl5nQjV+85ruFzFGZxu+QeFVbRTPDRnqL9UbU4VeA==} + peerDependencies: + tree-sitter: ^0.21.0 + peerDependenciesMeta: + tree-sitter: + optional: true + + tree-sitter@0.21.1: + resolution: {integrity: sha512-7dxoA6kYvtgWw80265MyqJlkRl4yawIjO7S5MigytjELkX43fV2WsAXzsNfO7sBpPPCF5Gp0+XzHk0DwLCq3xQ==} + treeify@1.1.0: resolution: {integrity: sha512-1m4RA7xVAJrSGrrXGs0L3YTwyvBs2S8PbRHaLZAkFw7JR8oIFwYtysxlBZhYIa7xSyiYJKZ3iGrrk55cGA3i9A==} engines: {node: '>=0.6'} @@ -12589,6 +12827,9 @@ packages: resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==} engines: {node: '>=10.12.0'} + validate-html-nesting@1.2.2: + resolution: {integrity: sha512-hGdgQozCsQJMyfK5urgFcWEqsSSrK63Awe0t/IMR0bZ0QMtnuaiHzThW81guu3qx9abLi99NEuiaN6P9gVYsNg==} + validate-npm-package-license@3.0.4: resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==} @@ -13410,6 +13651,52 @@ snapshots: '@alloc/quick-lru@5.2.0': {} + '@alloy-js/babel-plugin-jsx-dom-expressions@0.37.21(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + '@babel/helper-module-imports': 7.18.6 + '@babel/plugin-syntax-jsx': 7.25.9(@babel/core@7.26.0) + '@babel/types': 7.26.5 + html-entities: 2.3.3 + validate-html-nesting: 1.2.2 + + '@alloy-js/babel-plugin@0.1.0(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + '@babel/generator': 7.26.2 + '@babel/helper-module-imports': 7.18.6 + '@babel/plugin-syntax-jsx': 7.25.9(@babel/core@7.26.0) + '@babel/types': 7.26.5 + + '@alloy-js/babel-preset@0.1.1(@babel/core@7.26.0)': + dependencies: + '@alloy-js/babel-plugin': 0.1.0(@babel/core@7.26.0) + '@alloy-js/babel-plugin-jsx-dom-expressions': 0.37.21(@babel/core@7.26.0) + transitivePeerDependencies: + - '@babel/core' + + '@alloy-js/core@0.5.0': + dependencies: + '@alloy-js/babel-preset': 0.1.1(@babel/core@7.26.0) + '@babel/core': 7.26.0 + '@babel/preset-typescript': 7.26.0(@babel/core@7.26.0) + '@vue/reactivity': 3.5.13 + chalk: 5.4.1 + cli-table3: 0.6.5 + pathe: 1.1.2 + transitivePeerDependencies: + - supports-color + + '@alloy-js/prettier-plugin-alloy@0.1.0': {} + + '@alloy-js/typescript@0.5.0': + dependencies: + '@alloy-js/core': 0.5.0 + change-case: 5.4.4 + pathe: 1.1.2 + transitivePeerDependencies: + - supports-color + '@ampproject/remapping@2.3.0': dependencies: '@jridgewell/gen-mapping': 0.3.8 @@ -13749,6 +14036,20 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/cli@7.26.4(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + '@jridgewell/trace-mapping': 0.3.25 + commander: 6.2.1 + convert-source-map: 2.0.0 + fs-readdir-recursive: 1.1.0 + glob: 7.2.3 + make-dir: 2.1.0 + slash: 2.0.0 + optionalDependencies: + '@nicolo-ribaudo/chokidar-2': 2.1.8-no-fsevents.3 + chokidar: 3.6.0 + '@babel/code-frame@7.12.11': dependencies: '@babel/highlight': 7.25.9 @@ -13846,6 +14147,10 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/helper-module-imports@7.18.6': + dependencies: + '@babel/types': 7.26.5 + '@babel/helper-module-imports@7.25.9': dependencies: '@babel/traverse': 7.25.9 @@ -14569,6 +14874,9 @@ snapshots: - bluebird - supports-color + '@colors/colors@1.5.0': + optional: true + '@colors/colors@1.6.0': {} '@cspell/cspell-bundled-dicts@8.17.2': @@ -16620,6 +16928,9 @@ snapshots: '@microsoft/tsdoc@0.15.1': {} + '@nicolo-ribaudo/chokidar-2@2.1.8-no-fsevents.3': + optional: true + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -17204,6 +17515,17 @@ snapshots: optionalDependencies: rollup: 4.31.0 + '@rollup/plugin-babel@6.0.4(@babel/core@7.26.0)(@types/babel__core@7.20.5)(rollup@4.34.6)': + dependencies: + '@babel/core': 7.26.0 + '@babel/helper-module-imports': 7.25.9 + '@rollup/pluginutils': 5.1.4(rollup@4.34.6) + optionalDependencies: + '@types/babel__core': 7.20.5 + rollup: 4.34.6 + transitivePeerDependencies: + - supports-color + '@rollup/plugin-commonjs@28.0.2(rollup@4.31.0)': dependencies: '@rollup/pluginutils': 5.1.4(rollup@4.31.0) @@ -18014,6 +18336,8 @@ snapshots: '@types/mime@1.3.5': {} + '@types/minimist@1.2.5': {} + '@types/mocha@10.0.10': {} '@types/morgan@1.9.9': @@ -18605,6 +18929,10 @@ snapshots: optionalDependencies: typescript: 5.7.3 + '@vue/reactivity@3.5.13': + dependencies: + '@vue/shared': 3.5.13 + '@vue/shared@3.5.13': {} '@xmldom/xmldom@0.8.10': {} @@ -19960,6 +20288,12 @@ snapshots: cli-spinners@2.9.2: {} + cli-table3@0.6.5: + dependencies: + string-width: 4.2.3 + optionalDependencies: + '@colors/colors': 1.5.0 + cli-truncate@2.1.0: dependencies: slice-ansi: 3.0.0 @@ -21631,6 +21965,8 @@ snapshots: dependencies: minipass: 7.1.2 + fs-readdir-recursive@1.1.0: {} + fs.realpath@1.0.0: {} fsevents@2.3.2: @@ -22177,6 +22513,8 @@ snapshots: dependencies: whatwg-encoding: 2.0.0 + html-entities@2.3.3: {} + html-escape@1.0.2: {} html-escaper@2.0.2: {} @@ -23890,12 +24228,14 @@ snapshots: node-abi@3.71.0: dependencies: - semver: 7.6.3 + semver: 7.7.1 optional: true node-addon-api@4.3.0: optional: true + node-addon-api@8.3.0: {} + node-dir@0.1.17: dependencies: minimatch: 3.1.2 @@ -23910,6 +24250,8 @@ snapshots: fetch-blob: 3.2.0 formdata-polyfill: 4.0.10 + node-gyp-build@4.8.4: {} + node-gyp@11.0.0: dependencies: env-paths: 2.2.1 @@ -25669,6 +26011,8 @@ snapshots: arg: 5.0.2 sax: 1.4.1 + slash@2.0.0: {} + slash@3.0.0: {} slash@5.1.0: {} @@ -26161,6 +26505,47 @@ snapshots: tree-kill@1.2.2: {} + tree-sitter-c-sharp@0.23.1(tree-sitter@0.21.1): + dependencies: + node-addon-api: 8.3.0 + node-gyp-build: 4.8.4 + optionalDependencies: + tree-sitter: 0.21.1 + + tree-sitter-java@0.23.5(tree-sitter@0.21.1): + dependencies: + node-addon-api: 8.3.0 + node-gyp-build: 4.8.4 + optionalDependencies: + tree-sitter: 0.21.1 + + tree-sitter-javascript@0.23.1(tree-sitter@0.21.1): + dependencies: + node-addon-api: 8.3.0 + node-gyp-build: 4.8.4 + optionalDependencies: + tree-sitter: 0.21.1 + + tree-sitter-python@0.23.6(tree-sitter@0.21.1): + dependencies: + node-addon-api: 8.3.0 + node-gyp-build: 4.8.4 + optionalDependencies: + tree-sitter: 0.21.1 + + tree-sitter-typescript@0.23.2(tree-sitter@0.21.1): + dependencies: + node-addon-api: 8.3.0 + node-gyp-build: 4.8.4 + tree-sitter-javascript: 0.23.1(tree-sitter@0.21.1) + optionalDependencies: + tree-sitter: 0.21.1 + + tree-sitter@0.21.1: + dependencies: + node-addon-api: 8.3.0 + node-gyp-build: 4.8.4 + treeify@1.1.0: {} treeverse@3.0.0: {} @@ -26551,6 +26936,8 @@ snapshots: '@types/istanbul-lib-coverage': 2.0.6 convert-source-map: 2.0.0 + validate-html-nesting@1.2.2: {} + validate-npm-package-license@3.0.4: dependencies: spdx-correct: 3.2.0