diff --git a/.chronus/changes/witemple-msft-transforms-fns-2026-2-3-17-53-51.md b/.chronus/changes/witemple-msft-transforms-fns-2026-2-3-17-53-51.md new file mode 100644 index 00000000000..38ed7ea5cf9 --- /dev/null +++ b/.chronus/changes/witemple-msft-transforms-fns-2026-2-3-17-53-51.md @@ -0,0 +1,8 @@ +--- +changeKind: internal +packages: + - "@typespec/compiler" + - "@typespec/http" +--- + +Replaced visibility and merge-patch transforms with invocations of `internal` functions for greater accuracy. \ No newline at end of file diff --git a/.chronus/changes/witemple-msft-transforms-fns-2026-2-3-17-56-49.md b/.chronus/changes/witemple-msft-transforms-fns-2026-2-3-17-56-49.md new file mode 100644 index 00000000000..8db25ebf34c --- /dev/null +++ b/.chronus/changes/witemple-msft-transforms-fns-2026-2-3-17-56-49.md @@ -0,0 +1,7 @@ +--- +changeKind: fix +packages: + - "@typespec/compiler" +--- + +Fixed a bug that would prevent template parameters from assigning to values in some cases. \ No newline at end of file diff --git a/.chronus/changes/witemple-msft-transforms-fns-2026-2-3-18-15-47.md b/.chronus/changes/witemple-msft-transforms-fns-2026-2-3-18-15-47.md new file mode 100644 index 00000000000..2a278118075 --- /dev/null +++ b/.chronus/changes/witemple-msft-transforms-fns-2026-2-3-18-15-47.md @@ -0,0 +1,7 @@ +--- +changeKind: feature +packages: + - "@typespec/compiler" +--- + +Added a new template `FilterVisibility` to support more accurate visibility transforms. This replaces the `@withVisibilityFilter` decorator, which is now deprecated and slated for removal in a future version of TypeSpec. \ No newline at end of file diff --git a/packages/compiler/generated-defs/TypeSpec.ts b/packages/compiler/generated-defs/TypeSpec.ts index 177b9afc681..2f066713ac6 100644 --- a/packages/compiler/generated-defs/TypeSpec.ts +++ b/packages/compiler/generated-defs/TypeSpec.ts @@ -3,6 +3,7 @@ import type { DecoratorValidatorCallbacks, Enum, EnumValue, + FunctionContext, Interface, Model, ModelProperty, @@ -1206,3 +1207,21 @@ export type TypeSpecDecorators = { withVisibilityFilter: WithVisibilityFilterDecorator; withLifecycleUpdate: WithLifecycleUpdateDecorator; }; + +export type ApplyVisibilityFilterFunctionImplementation = ( + context: FunctionContext, + input: Model, + filter: VisibilityFilter, + nameTemplate?: string, +) => Model; + +export type ApplyLifecycleUpdateFunctionImplementation = ( + context: FunctionContext, + input: Model, + nameTemplate?: string, +) => Model; + +export type TypeSpecFunctions = { + applyVisibilityFilter: ApplyVisibilityFilterFunctionImplementation; + applyLifecycleUpdate: ApplyLifecycleUpdateFunctionImplementation; +}; diff --git a/packages/compiler/generated-defs/TypeSpec.ts-test.ts b/packages/compiler/generated-defs/TypeSpec.ts-test.ts index 4e0ce70e3c3..568630da9c4 100644 --- a/packages/compiler/generated-defs/TypeSpec.ts-test.ts +++ b/packages/compiler/generated-defs/TypeSpec.ts-test.ts @@ -1,10 +1,15 @@ // An error in the imports would mean that the decorator is not exported or // doesn't have the right name. -import { $decorators } from "../src/index.js"; -import type { TypeSpecDecorators } from "./TypeSpec.js"; +import { $decorators, $functions } from "../src/index.js"; +import type { TypeSpecDecorators, TypeSpecFunctions } from "./TypeSpec.js"; /** * An error here would mean that the exported decorator is not using the same signature. Make sure to have export const $decName: DecNameDecorator = (...) => ... */ const _decs: TypeSpecDecorators = $decorators["TypeSpec"]; + +/** + * An error here would mean that the exported function is not using the same signature. Make sure to have export const $funcName: FuncNameFunction = (...) => ... + */ +const _funcs: TypeSpecFunctions = $functions["TypeSpec"]; diff --git a/packages/compiler/lib/std/visibility.tsp b/packages/compiler/lib/std/visibility.tsp index fad44fa1ea5..3024070f396 100644 --- a/packages/compiler/lib/std/visibility.tsp +++ b/packages/compiler/lib/std/visibility.tsp @@ -299,12 +299,60 @@ model VisibilityFilter { * } * ``` */ +#deprecated "withVisibilityFilter is deprecated and will be removed in a future release. Use the `FilterVisibility` template or Lifecycle specific templates (e.g. `Read`, `Create`, `Update`, etc.) instead." extern dec withVisibilityFilter( target: Model, filter: valueof VisibilityFilter, nameTemplate?: valueof string ); +/** + * A copy of the input model `M` with only the properties that match the given visibility filter. + * + * This transformation is recursive, so it will also apply the filter to any nested + * or referenced models that are the types of any properties in the `target`. + * + * If a `nameTemplate` is provided, newly-created type instances will be named according + * to the template. See the `@friendlyName` decorator for more information on the template + * syntax. The transformed type is provided as the argument to the template. + * + * @template M the model to apply the visibility filter to. + * @template Filter the visibility filter to apply to the properties of the target model. + * @template NameTemplate the name template to use when renaming new type instances. + * + * @example + * ```typespec + * model Dog { + * @visibility(CustomVisibility.A) + * id: int32; + * @removeVisibility(CustomVisibility.A) + * name: string; + * } + * + * enum CustomVisibility { + * A, + * B, + * } + * + * const customFilter: VisibilityFilter = #{ all: #[CustomVisibility.A] }; + * + * // This model will have the `id` property but not the `name` property, since `id` has the CustomVisibility.A visibility and `name` does not. + * model DogRead is FilterVisibility; + * ``` + */ +alias FilterVisibility< + M extends Model, + Filter extends valueof VisibilityFilter, + NameTemplate extends valueof string +> = applyVisibilityFilter(M, Filter, NameTemplate); + +#suppress "experimental-feature" +internal extern fn applyVisibilityFilter( + input: Model, + filter: valueof VisibilityFilter, + nameTemplate?: valueof string +): Model; + /** * Transforms the `target` model to include only properties that are visible during the * "Update" lifecycle phase. @@ -338,8 +386,12 @@ extern dec withVisibilityFilter( * } * ``` */ +#deprecated "withLifecycleUpdate is deprecated and will be removed in a future release. Use the `Update` template instead." extern dec withLifecycleUpdate(target: Model, nameTemplate?: valueof string); +#suppress "experimental-feature" +internal extern fn applyLifecycleUpdate(input: Model, nameTemplate?: valueof string): Model; + /** * A copy of the input model `T` with only the properties that are visible during the * "Create" resource lifecycle phase. @@ -366,12 +418,10 @@ extern dec withLifecycleUpdate(target: Model, nameTemplate?: valueof string); * model CreateDog is Create; * ``` */ -@doc("") -@friendlyName(NameTemplate, T) -@withVisibilityFilter(#{ all: #[Lifecycle.Create] }, NameTemplate) -model Create { - ...T; -} +alias Create< + T extends Model, + NameTemplate extends valueof string = "Create{name}" +> = applyVisibilityFilter(T, #{ all: #[Lifecycle.Create] }, NameTemplate); /** * A copy of the input model `T` with only the properties that are visible during the @@ -405,12 +455,10 @@ model Create { - ...T; -} +alias Read< + T extends Model, + NameTemplate extends valueof string = "Read{name}" +> = applyVisibilityFilter(T, #{ all: #[Lifecycle.Read] }, NameTemplate); /** * A copy of the input model `T` with only the properties that are visible during the @@ -445,12 +493,10 @@ model Read { - ...T; -} +alias Update< + T extends Model, + NameTemplate extends valueof string = "Update{name}" +> = applyLifecycleUpdate(T, NameTemplate); /** * A copy of the input model `T` with only the properties that are visible during the @@ -487,15 +533,10 @@ model Update { - ...T; -} +> = applyVisibilityFilter(T, #{ any: #[Lifecycle.Create, Lifecycle.Update] }, NameTemplate); /** * A copy of the input model `T` with only the properties that are visible during the @@ -531,12 +572,10 @@ model CreateOrUpdate< * model DeleteDog is Delete; * ``` */ -@doc("") -@friendlyName(NameTemplate, T) -@withVisibilityFilter(#{ all: #[Lifecycle.Delete] }, NameTemplate) -model Delete { - ...T; -} +alias Delete< + T extends Model, + NameTemplate extends valueof string = "Delete{name}" +> = applyVisibilityFilter(T, #{ all: #[Lifecycle.Delete] }, NameTemplate); /** * A copy of the input model `T` with only the properties that are visible during the @@ -580,9 +619,7 @@ model Delete { - ...T; -} +alias Query< + T extends Model, + NameTemplate extends valueof string = "Query{name}" +> = applyVisibilityFilter(T, #{ all: #[Lifecycle.Query] }, NameTemplate); diff --git a/packages/compiler/src/core/checker.ts b/packages/compiler/src/core/checker.ts index 0d46a8aa6f9..66a1658d6c0 100644 --- a/packages/compiler/src/core/checker.ts +++ b/packages/compiler/src/core/checker.ts @@ -785,12 +785,13 @@ export function createChecker(program: Program, resolver: NameResolver): Checker if (entity.valueKind === "Function") return entity; return constraint ? inferScalarsFromConstraints(entity, constraint.type) : entity; } - // If a template parameter that can be a value is used in a template declaration then we allow it but we return null because we don't have an actual value. + // If a template parameter that can be a value is used where a value is expected, + // synthesize a template value placeholder even when the template parameter is mapped + // from an outer template declaration. if ( entity.kind === "TemplateParameter" && entity.constraint?.valueType && - entity.constraint.type === undefined && - ctx.mapper === undefined + entity.constraint.type === undefined ) { // We must also observe that the template parameter is used here. // ctx.observeTemplateParameter(entity); @@ -5140,6 +5141,23 @@ export function createChecker(program: Program, resolver: NameResolver): Checker } if (entity.entityKind === "Type") { + if ( + entity.kind === "TemplateParameter" && + entity.constraint?.valueType && + entity.constraint.type === undefined + ) { + return [ + createValue( + { + entityKind: "Value", + valueKind: "TemplateValue", + type: entity.constraint.valueType, + }, + entity.constraint.valueType, + ) as any, + [], + ]; + } return [ null, [ diff --git a/packages/compiler/src/index.ts b/packages/compiler/src/index.ts index 1600e2aa043..99122b7b8d0 100644 --- a/packages/compiler/src/index.ts +++ b/packages/compiler/src/index.ts @@ -191,6 +191,7 @@ export { serializeValueAsJson, Service, ServiceDetails, + setMediaTypeHint, VisibilityProvider, type BytesKnownEncoding, type DateTimeKnownEncoding, @@ -232,7 +233,7 @@ export { export type { PackageJson } from "./types/package-json.js"; import { $decorators as intrinsicDecorators } from "./lib/intrinsic/tsp-index.js"; -import { $decorators as stdDecorators } from "./lib/tsp-index.js"; +import { $decorators as stdDecorators, $functions as stdFunctions } from "./lib/tsp-index.js"; /** @internal for Typespec compiler */ export const $decorators = { TypeSpec: { @@ -243,6 +244,13 @@ export const $decorators = { }, }; +/** @internal for Typespec compiler */ +export const $functions = { + TypeSpec: { + ...stdFunctions.TypeSpec, + }, +}; + export { applyCodeFix, applyCodeFixes, resolveCodeFix } from "./core/code-fixes.js"; export { createAddDecoratorCodeFix } from "./core/compiler-code-fixes/create-add-decorator/create-add-decorator.codefix.js"; export { diff --git a/packages/compiler/src/lib/decorators.ts b/packages/compiler/src/lib/decorators.ts index 4ab03e7a673..c4a5128569a 100644 --- a/packages/compiler/src/lib/decorators.ts +++ b/packages/compiler/src/lib/decorators.ts @@ -435,7 +435,7 @@ export function isErrorModel(program: Program, target: Type): boolean { // -- @mediaTypeHint decorator -------------- -const [_getMediaTypeHint, setMediaTypeHint] = useStateMap( +const [_getMediaTypeHint, _setMediaTypeHint] = useStateMap( createStateSymbol("mediaTypeHint"), ); @@ -461,9 +461,40 @@ export const $mediaTypeHint: MediaTypeHintDecorator = ( }); } - setMediaTypeHint(context.program, target, mediaType); + _setMediaTypeHint(context.program, target, mediaType); }; +/** + * Sets the default media type hint for the given target type. + * + * This value is a hint _ONLY_. Emitters are not required to use it, but may use it to get the default media type + * associated with a TypeSpec type. + * + * If a type already has a default media type hint set, this function will override it with the new value. + * + * WARNING: this function _will throw an error_ if the provided media type string is not recognized as a valid + * MIME type. + * + * @param program - the Program containing the target + * @param target - the target to set the MIME type hint for + * @param mediaType - the default media type hint to set for the target + * @throws if the provided media type string is not recognized as a valid MIME type + */ +export function setMediaTypeHint( + program: Program, + target: MediaTypeHintable, + mediaType: string, +): void { + const mimeTypeObj = parseMimeType(mediaType); + + compilerAssert( + mimeTypeObj !== undefined, + `Invalid MIME type '${mediaType}' provided to setMediaTypeHint`, + ); + + _setMediaTypeHint(program, target, mediaType); +} + /** * Get the default media type hint for the given target type. * diff --git a/packages/compiler/src/lib/tsp-index.ts b/packages/compiler/src/lib/tsp-index.ts index df7645e8335..255dc9793b1 100644 --- a/packages/compiler/src/lib/tsp-index.ts +++ b/packages/compiler/src/lib/tsp-index.ts @@ -1,4 +1,4 @@ -import { TypeSpecDecorators } from "../../generated-defs/TypeSpec.js"; +import { TypeSpecDecorators, TypeSpecFunctions } from "../../generated-defs/TypeSpec.js"; import { $discriminator, $doc, @@ -59,8 +59,18 @@ import { $withUpdateableProperties, $withVisibility, $withVisibilityFilter, + applyLifecycleUpdate, + applyVisibilityFilter, } from "./visibility.js"; +/** @internal */ +export const $functions = { + TypeSpec: { + applyVisibilityFilter, + applyLifecycleUpdate, + } satisfies TypeSpecFunctions, +}; + /** @internal */ export const $decorators = { TypeSpec: { diff --git a/packages/compiler/src/lib/visibility.ts b/packages/compiler/src/lib/visibility.ts index 1456e6402ae..dd8965ab487 100644 --- a/packages/compiler/src/lib/visibility.ts +++ b/packages/compiler/src/lib/visibility.ts @@ -405,12 +405,13 @@ interface VisibilityFilterMutatorCacheByNameTemplate { lifecycleUpdate?: Mutator; } -export const $withVisibilityFilter: WithVisibilityFilterDecorator = ( - context: DecoratorContext, - target: Model, +/** @internal */ +export function applyVisibilityFilter( + context: { program: Program }, + input: Model, _filter: GeneratedVisibilityFilter, nameTemplate?: string, -) => { +): Model { const filter = VisibilityFilter.fromDecoratorArgument(_filter); const mutatorCache = ((context.program as VisibilityFilterMutatorCache)[ @@ -436,27 +437,45 @@ export const $withVisibilityFilter: WithVisibilityFilterDecorator = ( if (!mutator) { mutator = createVisibilityFilterMutator(filter, { decoratorFn: $withVisibilityFilter, + decoratorName: "@withVisibilityFilter", nameTemplate, }); mutatorCacheByVisibilityFilter.set(vfKey, mutator); } - setAlwaysMutate(context.program, target); + setAlwaysMutate(context.program, input); - const { type } = cachedMutateSubgraph(context.program, mutator, target); + const { type } = cachedMutateSubgraph(context.program, mutator, input); - setAlwaysMutate(context.program, target, false); + setAlwaysMutate(context.program, input, false); - target.properties = (type as Model).properties; -}; + compilerAssert( + type.kind === "Model", + "Expected visibility filter mutator to return a Model type.", + ); -// -- @withLifecycleUpdate decorator ---------------------- + return type; +} -export const $withLifecycleUpdate: WithLifecycleUpdateDecorator = ( +export const $withVisibilityFilter: WithVisibilityFilterDecorator = ( context: DecoratorContext, target: Model, + _filter: GeneratedVisibilityFilter, nameTemplate?: string, ) => { + const transformed = applyVisibilityFilter(context, target, _filter, nameTemplate); + + target.properties = transformed.properties; +}; + +// -- @withLifecycleUpdate decorator ---------------------- + +/** @internal */ +export function applyLifecycleUpdate( + context: { program: Program }, + input: Model, + nameTemplate?: string, +): Model { const mutatorCache = ((context.program as VisibilityFilterMutatorCache)[ VISIBILITY_FILTER_MUTATOR_CACHE ] ??= {}); @@ -487,19 +506,35 @@ export const $withLifecycleUpdate: WithLifecycleUpdateDecorator = ( mutator = createVisibilityFilterMutator(lifecycleUpdate, { recur: createOrUpdateMutator, decoratorFn: $withLifecycleUpdate, + decoratorName: "@withLifecycleUpdate", nameTemplate, }); mutatorCacheByNameTemplate.lifecycleUpdate = mutator; } - setAlwaysMutate(context.program, target); + setAlwaysMutate(context.program, input); - const { type } = cachedMutateSubgraph(context.program, mutator, target); + const { type } = cachedMutateSubgraph(context.program, mutator, input); - setAlwaysMutate(context.program, target, false); + setAlwaysMutate(context.program, input, false); - target.properties = (type as Model).properties; + compilerAssert( + type.kind === "Model", + "Expected lifecycle update mutator to return a Model type.", + ); + + return type; +} + +export const $withLifecycleUpdate: WithLifecycleUpdateDecorator = ( + context: DecoratorContext, + target: Model, + nameTemplate?: string, +) => { + const transformed = applyLifecycleUpdate(context, target, nameTemplate); + + target.properties = transformed.properties; }; const VISIBILITY_FILTER_MUTATOR_RESULT = Symbol.for("TypeSpec.Core.visibilityFilterMutatorResult"); @@ -544,6 +579,13 @@ interface CreateVisibilityFilterMutatorOptions { */ decoratorFn?: DecoratorFunction; + /** + * Optionally, the fully-qualified TypeSpec decorator name corresponding to `decoratorFn`. + * + * This allows robust matching across module-boundary function identity differences. + */ + decoratorName?: `@${string}`; + /** * Optionally, the name template to apply in the mutator. * @@ -564,6 +606,19 @@ function createVisibilityFilterMutator( options: CreateVisibilityFilterMutatorOptions = {}, ): Mutator { const visibilityClasses = VisibilityFilter.getVisibilityClasses(filter); + const isTypeSpecDecorator = ( + application: DecoratorApplication, + decoratorName: `@${string}`, + ): boolean => + application.definition?.name === decoratorName && + application.definition.namespace.name === "TypeSpec"; + + const matchesDecorator = ( + application: DecoratorApplication, + decoratorName: `@${string}`, + decoratorFn: DecoratorFunction, + ): boolean => + isTypeSpecDecorator(application, decoratorName) || application.decorator === decoratorFn; const mpMutator: Mutator = { name: "VisibilityFilterProperty", ModelProperty: { @@ -577,8 +632,10 @@ function createVisibilityFilterMutator( const decorators: DecoratorApplication[] = []; for (const decorator of prop.decorators) { - const decFn = decorator.decorator; - if (decFn === $visibility || decFn === $removeVisibility) { + if ( + matchesDecorator(decorator, "@visibility", $visibility) || + matchesDecorator(decorator, "@removeVisibility", $removeVisibility) + ) { const nextArgs = decorator.args.filter((arg) => { if (arg.value.entityKind !== "Value") return false; @@ -597,7 +654,7 @@ function createVisibilityFilterMutator( args: nextArgs, }); } - } else if (decFn !== $invisible) { + } else if (!matchesDecorator(decorator, "@invisible", $invisible)) { decorators.push(decorator); } } @@ -687,12 +744,18 @@ function createVisibilityFilterMutator( clone.properties.set(key, mutated.type as ModelProperty); - modified ||= (mutated.type as ModelProperty).type !== prop.type; + modified ||= mutated.type !== prop; } } if (options.decoratorFn) { - clone.decorators = clone.decorators.filter((d) => d.decorator !== options.decoratorFn); + clone.decorators = clone.decorators.filter( + (d) => + !( + d.decorator === options.decoratorFn || + (options.decoratorName && isTypeSpecDecorator(d, options.decoratorName)) + ), + ); modified ||= clone.decorators.length !== model.decorators.length; } diff --git a/packages/compiler/src/server/completion.ts b/packages/compiler/src/server/completion.ts index 03a8278e679..a4c3cf381d2 100644 --- a/packages/compiler/src/server/completion.ts +++ b/packages/compiler/src/server/completion.ts @@ -10,6 +10,7 @@ import { import { getSymNode } from "../core/binder.js"; import { getDeprecationDetails } from "../core/deprecation.js"; import { compilerAssert, getSourceLocation } from "../core/diagnostics.js"; +import { getLocationContext } from "../core/helpers/location-context.js"; import { printIdentifier } from "../core/helpers/syntax-utils.js"; import { getFirstAncestor, positionInRange } from "../core/parser.js"; import { @@ -27,6 +28,7 @@ import { NodeFlags, PositionDetail, StringLiteralNode, + Sym, SymbolFlags, SyntaxKind, Type, @@ -411,7 +413,11 @@ async function addIdentifierCompletion( if (result.size === 0) { return; } + const sourceLocation = getLocationContext(program, node); for (const [key, { sym, label, suffix }] of result) { + if (!canAccessCompletionSymbol(sym, sourceLocation)) { + continue; + } let kind: CompletionItemKind; let deprecated = false; const symNode = getSymNode(sym); @@ -478,6 +484,27 @@ async function addIdentifierCompletion( if (node.parent?.kind === SyntaxKind.TypeReference) { addKeywordCompletion("identifier", completions); } + + function canAccessCompletionSymbol( + sym: Sym, + sourceLocation: ReturnType, + ) { + const isInternalDeclaration = + (sym.flags & (SymbolFlags.Internal | SymbolFlags.Declaration)) === + (SymbolFlags.Internal | SymbolFlags.Declaration); + + if (!isInternalDeclaration) return true; + if (sourceLocation.type === "synthetic" || sourceLocation.type === "compiler") return true; + + return sym.declarations.some((decl) => { + const declLocation = getLocationContext(program, decl); + + if (declLocation.type !== sourceLocation.type) return false; + if (declLocation.type === "project") return true; + + return declLocation === sourceLocation; + }); + } } const directiveNames = ["suppress", "deprecated"]; diff --git a/packages/compiler/test/checker/functions.test.ts b/packages/compiler/test/checker/functions.test.ts index c796d24dae6..7693cd49746 100644 --- a/packages/compiler/test/checker/functions.test.ts +++ b/packages/compiler/test/checker/functions.test.ts @@ -1687,6 +1687,17 @@ describe("function calls within template declarations", () => { strictEqual(receivedTypes.length, 0); }); + it("allows passing value-constrained template parameters to valueof function parameters", async () => { + const diagnostics = await tester.diagnose(` + extern fn f(T: valueof uint32): valueof uint32; + + alias Test = f(V); + `); + + expectFunctionDiagnosticsEmpty(diagnostics); + strictEqual(receivedTypes.length, 0); + }); + it("does not call a function in a decorator argument of a templated operation declaration", async () => { const diagnostics = await tester.diagnose(` extern fn f(T: unknown): unknown; diff --git a/packages/compiler/test/checker/model.test.ts b/packages/compiler/test/checker/model.test.ts index 5149d7e810a..0f6ad4b880b 100644 --- a/packages/compiler/test/checker/model.test.ts +++ b/packages/compiler/test/checker/model.test.ts @@ -190,6 +190,23 @@ describe("compiler: models", () => { strictEqual(foo.defaultValue?.valueKind, "StringValue"); }); + it(`set it with valid passthrough template constraint`, async () => { + testHost.addTypeSpecFile( + "main.tsp", + ` + model X { + @test i: uint32 = V; + } + + model Y { + x: X; + } + `, + ); + const diagnostics = await testHost.diagnose("main.tsp"); + expectDiagnosticEmpty(diagnostics); + }); + it(`error if constraint is not compatible with property type`, async () => { testHost.addTypeSpecFile( "main.tsp", diff --git a/packages/compiler/test/decorators/decorators.test.ts b/packages/compiler/test/decorators/decorators.test.ts index 111972c7e49..11c049a2866 100644 --- a/packages/compiler/test/decorators/decorators.test.ts +++ b/packages/compiler/test/decorators/decorators.test.ts @@ -10,6 +10,7 @@ import { Union, getDiscriminatedUnion, isSecret, + setMediaTypeHint, } from "../../src/index.js"; import { getDoc, @@ -1608,5 +1609,27 @@ describe("compiler: built-in decorators", () => { strictEqual(getMediaTypeHint(runner.program, A), undefined); strictEqual(getMediaTypeHint(runner.program, B), "text/plain"); }); + + it("can set media type hint programmatically", async () => { + const { A } = (await runner.compile(` + @test + model A {} + `)) as { A: Model }; + + strictEqual(getMediaTypeHint(runner.program, A), undefined); + setMediaTypeHint(runner.program, A, "application/merge-patch+json"); + strictEqual(getMediaTypeHint(runner.program, A), "application/merge-patch+json"); + }); + + it("validates media type when set programmatically", async () => { + const { A } = (await runner.compile(` + @test + model A {} + `)) as { A: Model }; + + expect(() => setMediaTypeHint(runner.program, A, "not-a-mime-type")).toThrow( + "Invalid MIME type 'not-a-mime-type' provided to setMediaTypeHint", + ); + }); }); }); diff --git a/packages/compiler/test/server/completion.test.ts b/packages/compiler/test/server/completion.test.ts index d12643b8c3b..26f68319109 100644 --- a/packages/compiler/test/server/completion.test.ts +++ b/packages/compiler/test/server/completion.test.ts @@ -457,6 +457,60 @@ describe("identifiers", () => { ); }); + it("completes internal functions in the same project", async () => { + const completions = await complete(` + internal fn inScopeInternal(): valueof string; + fn inScopePublic(): valueof string; + + const x = inS┆(); + `); + + deepStrictEqual( + ["inScopeInternal", "inScopePublic"], + completions.items + .filter((c) => c.label === "inScopeInternal" || c.label === "inScopePublic") + .map((c) => c.label) + .sort(), + ); + }); + + it("does not complete internal functions from another package", async () => { + const completions = await complete( + ` + import "@typespec/internal-lib"; + using InternalLib; + + const x = libFn┆(); + `, + undefined, + { + "test/package.json": JSON.stringify({ + dependencies: { + "@typespec/internal-lib": "~0.1.0", + }, + }), + "test/node_modules/@typespec/internal-lib/package.json": JSON.stringify({ + name: "@typespec/internal-lib", + version: "0.1.0", + tspMain: "./main.tsp", + }), + "test/node_modules/@typespec/internal-lib/main.tsp": ` + namespace InternalLib; + internal fn libFnInternal(): valueof string; + fn libFnPublic(): valueof string; + `, + }, + ); + + deepStrictEqual( + ["libFnPublic"], + completions.items + .filter((c) => c.label === "libFnInternal" || c.label === "libFnPublic") + .map((c) => c.label) + .sort(), + ); + }); + it("completes decorators on models", async () => { const completions = await complete( ` diff --git a/packages/compiler/test/visibility.test.ts b/packages/compiler/test/visibility.test.ts index a9aaff561f1..027f0d6adca 100644 --- a/packages/compiler/test/visibility.test.ts +++ b/packages/compiler/test/visibility.test.ts @@ -4,6 +4,7 @@ import { ok, strictEqual } from "assert"; import { beforeEach, describe, it } from "vitest"; import { VisibilityFilter } from "../src/core/visibility/core.js"; +import type { EnumMember, EnumValue, FunctionContext } from "../src/index.js"; import { $visibility, addVisibilityModifiers, @@ -11,7 +12,6 @@ import { Diagnostic, EmptyVisibilityProvider, Enum, - getFriendlyName, getLifecycleVisibilityEnum, getParameterVisibilityFilter, getVisibilityForClass, @@ -26,6 +26,7 @@ import { sealVisibilityModifiers, sealVisibilityModifiersForProgram, } from "../src/index.js"; +import { applyLifecycleUpdate, applyVisibilityFilter } from "../src/lib/visibility.js"; import { BasicTestRunner, createTestRunner, @@ -42,6 +43,21 @@ function assertSetsEqual(a: Set, b: Set): void { } } +function enumMemberToValue(member: EnumMember): EnumValue { + return { + entityKind: "Value", + valueKind: "EnumValue", + value: member, + type: member.enum, + }; +} + +function anyFilter(...members: EnumMember[]): Parameters[2] { + return { + any: members.map((m) => enumMemberToValue(m)), + }; +} + describe("compiler: visibility core", () => { let runner: BasicTestRunner; @@ -810,6 +826,74 @@ describe("compiler: visibility core", () => { validateUpdateTransform(props, Result, getProperties); }); + it("correctly applies Update transform via applyLifecycleUpdate", async () => { + const Lifecycle = { + Read: "Lifecycle.Read", + Create: "Lifecycle.Create", + Update: "Lifecycle.Update", + }; + + const { Example } = (await runner.compile(` + @test model Example { + @visibility(${Lifecycle.Read}) + r: string; + + cru: string; + + @visibility(${Lifecycle.Create}, ${Lifecycle.Read}) + cr: string; + + @visibility(${Lifecycle.Create}, ${Lifecycle.Update}) + cu: string; + + @visibility(${Lifecycle.Create}) + c: string; + + @visibility(${Lifecycle.Update}, ${Lifecycle.Read}) + ru: string; + + @visibility(${Lifecycle.Update}) + u: string; + + @invisible(Lifecycle) + invisible: string; + + nested: Nested; + } + + model Nested { + @visibility(${Lifecycle.Read}) + r: string; + + cru: string; + + @visibility(${Lifecycle.Create}, ${Lifecycle.Read}) + cr: string; + + @visibility(${Lifecycle.Create}, ${Lifecycle.Update}) + cu: string; + + @visibility(${Lifecycle.Create}) + c: string; + + @visibility(${Lifecycle.Update}, ${Lifecycle.Read}) + ru: string; + + @visibility(${Lifecycle.Update}) + u: string; + + @invisible(Lifecycle) + invisible: string; + }; + `)) as { Example: Model }; + + const fnContext = { program: runner.program } satisfies Pick; + const Result = applyLifecycleUpdate(fnContext, Example, "Update{name}"); + const props = getProperties(Result); + + validateUpdateTransform(props, Result, getProperties); + }); + it("correctly applies CreateOrUpdate transform", async () => { const Result = await compileWithTransform("CreateOrUpdate"); const props = getProperties(Result); @@ -991,11 +1075,13 @@ describe("compiler: visibility core", () => { foo_a: string; } + #suppress "deprecated" @withVisibilityFilter(#{ any: #[Example.A] }, "{name}A") @test model DataA { ...Data } + #suppress "deprecated" @withVisibilityFilter(#{ any: #[Example.B] }, "{name}B") @test model DataB { ...Data @@ -1028,6 +1114,171 @@ describe("compiler: visibility core", () => { ok(!FooB.properties.has("foo_a")); }); + it("deeply renames types using the name template via applyVisibilityFilter", async () => { + const { Data, Example } = (await runner.compile(` + @test enum Example { + A, + B, + } + + @test model Data { + @visibility(Example.A) + data_a: Foo; + + @visibility(Example.B) + data_b: Foo; + } + + model Foo { + @visibility(Example.B) + foo_b: string; + @visibility(Example.A) + foo_a: string; + } + `)) as { Data: Model; Example: Enum }; + + const fnContext = { program: runner.program } satisfies Pick; + const DataA = applyVisibilityFilter( + fnContext, + Data, + anyFilter(Example.members.get("A")!), + "{name}A", + ); + const DataB = applyVisibilityFilter( + fnContext, + Data, + anyFilter(Example.members.get("B")!), + "{name}B", + ); + + ok(DataA); + ok(DataB); + + ok(DataA.properties.has("data_a")); + ok(!DataA.properties.has("data_b")); + ok(DataB.properties.has("data_b")); + ok(!DataB.properties.has("data_a")); + + const dataA = DataA.properties.get("data_a")!; + const dataB = DataB.properties.get("data_b")!; + + strictEqual(dataA.type.kind, "Model"); + strictEqual(dataB.type.kind, "Model"); + + const FooA = dataA.type as Model; + const FooB = dataB.type as Model; + + strictEqual(FooA.name, "FooA"); + strictEqual(FooB.name, "FooB"); + + ok(FooA.properties.has("foo_a")); + ok(!FooA.properties.has("foo_b")); + ok(FooB.properties.has("foo_b")); + ok(!FooB.properties.has("foo_a")); + }); + + it("deeply renames types using FilterVisibility", async () => { + const { DataA, DataB } = (await runner.compile(` + enum Example { + A, + B, + } + + model Data { + @visibility(Example.A) + data_a: Foo; + + @visibility(Example.B) + data_b: Foo; + } + + model Foo { + @visibility(Example.B) + foo_b: string; + @visibility(Example.A) + foo_a: string; + } + + @test model DataA is FilterVisibility; + @test model DataB is FilterVisibility; + `)) as { DataA: Model; DataB: Model }; + + ok(DataA); + ok(DataB); + + ok(DataA.properties.has("data_a")); + ok(!DataA.properties.has("data_b")); + ok(DataB.properties.has("data_b")); + ok(!DataB.properties.has("data_a")); + + const dataA = DataA.properties.get("data_a")!; + const dataB = DataB.properties.get("data_b")!; + + strictEqual(dataA.type.kind, "Model"); + strictEqual(dataB.type.kind, "Model"); + + const FooA = dataA.type as Model; + const FooB = dataB.type as Model; + + strictEqual(FooA.name, "FooA"); + strictEqual(FooB.name, "FooB"); + + ok(FooA.properties.has("foo_a")); + ok(!FooA.properties.has("foo_b")); + ok(FooB.properties.has("foo_b")); + ok(!FooB.properties.has("foo_a")); + }); + + it("correctly transforms arrays and records via FilterVisibility", async () => { + const { Result } = (await runner.compile(` + model A { + @visibility(Lifecycle.Read) + a: string; + + @visibility(Lifecycle.Create) + invisible: string; + } + + model Input { + array: A[]; + record: Record; + } + + @test model Result is FilterVisibility; + `)) as { Result: Model }; + + ok(Result); + + const array = Result.properties.get("array"); + const record = Result.properties.get("record"); + + ok(array); + ok(record); + + const arrayType = array.type; + const recordType = record.type; + + strictEqual(arrayType.kind, "Model"); + strictEqual(recordType.kind, "Model"); + + ok($(runner.program).array.is(arrayType)); + ok($(runner.program).record.is(recordType)); + + const arrayA = (arrayType as Model).indexer!.value as Model; + const recordA = (recordType as Model).indexer!.value as Model; + + strictEqual(arrayA.kind, "Model"); + strictEqual(recordA.kind, "Model"); + + strictEqual(arrayA.name, "ATransform"); + strictEqual(recordA.name, "ATransform"); + + strictEqual(arrayA, recordA); + + ok(arrayA.properties.has("a")); + ok(!arrayA.properties.has("invisible")); + }); + it("correctly caches and deduplicates transformed instances", async () => { const { Out } = (await runner.compile(` model A { @@ -1078,8 +1329,8 @@ describe("compiler: visibility core", () => { const A = a.type as Model; const B = b.type as Model; - ok(getFriendlyName(runner.program, A) === "ReadA"); - ok(getFriendlyName(runner.program, B) === "ReadB"); + ok(A.name === "ReadA"); + ok(B.name === "ReadB"); ok(A.properties.has("a")); ok(!A.properties.has("invisible")); @@ -1150,6 +1401,7 @@ describe("compiler: visibility core", () => { invisible: string; } + #suppress "deprecated" @withVisibilityFilter(#{ any: #[Lifecycle.Read] }, "{name}Transform") @test model Result { array: A[]; @@ -1189,6 +1441,63 @@ describe("compiler: visibility core", () => { ok(!arrayA.properties.has("invisible")); }); + it("correctly transforms arrays and records via applyVisibilityFilter", async () => { + const { Result } = (await runner.compile(` + model A { + @visibility(Lifecycle.Read) + a: string; + + @visibility(Lifecycle.Create) + invisible: string; + } + + @test model Result { + array: A[]; + record: Record; + } + `)) as { Result: Model }; + + const fnContext = { program: runner.program } satisfies Pick; + const lifecycle = getLifecycleVisibilityEnum(runner.program); + const transformed = applyVisibilityFilter( + fnContext, + Result, + anyFilter(lifecycle.members.get("Read")!), + "{name}Transform", + ); + + ok(transformed); + + const array = transformed.properties.get("array"); + const record = transformed.properties.get("record"); + + ok(array); + ok(record); + + const arrayType = array.type; + const recordType = record.type; + + strictEqual(arrayType.kind, "Model"); + strictEqual(recordType.kind, "Model"); + + ok($(runner.program).array.is(arrayType)); + ok($(runner.program).record.is(recordType)); + + const arrayA = (arrayType as Model).indexer!.value as Model; + const recordA = (recordType as Model).indexer!.value as Model; + + strictEqual(arrayA.kind, "Model"); + strictEqual(recordA.kind, "Model"); + + strictEqual(arrayA.name, "ATransform"); + strictEqual(recordA.name, "ATransform"); + + strictEqual(arrayA, recordA); + + ok(arrayA.properties.has("a")); + ok(!arrayA.properties.has("invisible")); + }); + it("correctly transforms 'model is' declarations of arrays and records", async () => { const { Result } = (await runner.compile(` model A { @@ -1203,6 +1512,7 @@ describe("compiler: visibility core", () => { model C is Record; + #suppress "deprecated" @withVisibilityFilter(#{ any: #[Lifecycle.Read] }, "{name}Transform") @test model Result { arr: B; @@ -1242,6 +1552,67 @@ describe("compiler: visibility core", () => { ok(!arrA.properties.has("invisible")); }); + it("correctly transforms 'model is' declarations of arrays and records via applyVisibilityFilter", async () => { + const { Result } = (await runner.compile(` + model A { + @visibility(Lifecycle.Read) + a: string; + + @visibility(Lifecycle.Create) + invisible: string; + } + + model B is Array; + + model C is Record; + + @test model Result { + arr: B; + rec: C; + } + `)) as { Result: Model }; + + const fnContext = { program: runner.program } satisfies Pick; + const lifecycle = getLifecycleVisibilityEnum(runner.program); + const transformed = applyVisibilityFilter( + fnContext, + Result, + anyFilter(lifecycle.members.get("Read")!), + "{name}Transform", + ); + + ok(transformed); + + const arr = transformed.properties.get("arr"); + const rec = transformed.properties.get("rec"); + + ok(arr); + ok(rec); + + const arrType = arr.type; + const recType = rec.type; + + strictEqual(arrType.kind, "Model"); + strictEqual(recType.kind, "Model"); + + ok($(runner.program).array.is(arrType)); + ok($(runner.program).record.is(recType)); + + strictEqual(arrType.name, "BTransform"); + strictEqual(recType.name, "CTransform"); + + const arrA = (arrType as Model).indexer!.value as Model; + const recA = (recType as Model).indexer!.value as Model; + + strictEqual(arrA, recA); + + strictEqual(arrA.kind, "Model"); + strictEqual(arrA.name, "ATransform"); + + ok(arrA.properties.has("a")); + ok(!arrA.properties.has("invisible")); + }); + it("does not duplicate encodedName metadata", async () => { const diagnostics = await runner.diagnose(` model SomeModel { diff --git a/packages/http-server-csharp/test/generation.test.ts b/packages/http-server-csharp/test/generation.test.ts index 7f5c0175c66..e8a783d2fec 100644 --- a/packages/http-server-csharp/test/generation.test.ts +++ b/packages/http-server-csharp/test/generation.test.ts @@ -1178,7 +1178,6 @@ interface Widgets { [ "IWidgets.cs", [ - "using TypeSpec.Http;", "public interface IWidgets", "Task UpdateAsync( string id, WidgetMergePatchUpdate body);", ], @@ -1186,7 +1185,6 @@ interface Widgets { [ "WidgetsController.cs", [ - "using TypeSpec.Http;", "public partial class WidgetsController: ControllerBase", "public virtual async Task Update(string id, WidgetMergePatchUpdate body)", ], @@ -1194,7 +1192,7 @@ interface Widgets { [ "WidgetMergePatchUpdate.cs", [ - "namespace TypeSpec.Http {", + "namespace Microsoft.Contoso {", "public string Id { get; set; }", "public int? Weight { get; set; }", "public string Color { get; set; }", diff --git a/packages/http/generated-defs/TypeSpec.Http.Private.ts b/packages/http/generated-defs/TypeSpec.Http.Private.ts index 489b6a81a32..370b83beae8 100644 --- a/packages/http/generated-defs/TypeSpec.Http.Private.ts +++ b/packages/http/generated-defs/TypeSpec.Http.Private.ts @@ -1,6 +1,7 @@ import type { DecoratorContext, DecoratorValidatorCallbacks, + FunctionContext, Model, ModelProperty, Type, @@ -31,6 +32,17 @@ export type HttpPartDecorator = ( options: HttpPartOptions, ) => DecoratorValidatorCallbacks | void; +/** + * Specify if inapplicable metadata should be included in the payload for the given entity. + * + * @param value If true, inapplicable metadata will be included in the payload. + */ +export type IncludeInapplicableMetadataInPayloadDecorator = ( + context: DecoratorContext, + target: Type, + value: boolean, +) => DecoratorValidatorCallbacks | void; + /** * Performs the canonical merge-patch transformation on the given model and injects its * transformed properties into the target. @@ -43,17 +55,6 @@ export type ApplyMergePatchDecorator = ( options: ApplyMergePatchOptions, ) => DecoratorValidatorCallbacks | void; -/** - * Specify if inapplicable metadata should be included in the payload for the given entity. - * - * @param value If true, inapplicable metadata will be included in the payload. - */ -export type IncludeInapplicableMetadataInPayloadDecorator = ( - context: DecoratorContext, - target: Type, - value: boolean, -) => DecoratorValidatorCallbacks | void; - /** * Marks a model that was generated by applying the MergePatch * transform and links to its source model @@ -78,8 +79,19 @@ export type TypeSpecHttpPrivateDecorators = { plainData: PlainDataDecorator; httpFile: HttpFileDecorator; httpPart: HttpPartDecorator; - applyMergePatch: ApplyMergePatchDecorator; includeInapplicableMetadataInPayload: IncludeInapplicableMetadataInPayloadDecorator; + applyMergePatch: ApplyMergePatchDecorator; mergePatchModel: MergePatchModelDecorator; mergePatchProperty: MergePatchPropertyDecorator; }; + +export type ApplyMergePatchTransformFunctionImplementation = ( + context: FunctionContext, + input: Model, + nameTemplate: string, + options: ApplyMergePatchOptions, +) => Model; + +export type TypeSpecHttpPrivateFunctions = { + applyMergePatchTransform: ApplyMergePatchTransformFunctionImplementation; +}; diff --git a/packages/http/lib/main.tsp b/packages/http/lib/main.tsp index e67516ecc0d..4ae9b68e1cd 100644 --- a/packages/http/lib/main.tsp +++ b/packages/http/lib/main.tsp @@ -307,14 +307,14 @@ scalar LinkHeader | Link[]> extends string; * @patch op update(...MergePatchUpdate): Widget; * ``` */ -@doc("") -@friendlyName(NameTemplate, T) -@mediaTypeHint("application/merge-patch+json") -@applyMergePatch(T, NameTemplate, #{ visibilityMode: Private.MergePatchVisibilityMode.Update }) -model MergePatchUpdate< +alias MergePatchUpdate< T extends Reflection.Model, NameTemplate extends valueof string = "{name}MergePatchUpdate" -> {} +> = applyMergePatchTransform( + T, + NameTemplate, + #{ visibilityMode: Private.MergePatchVisibilityMode.Update } +); /** * Create a MergePatch Request body for creating or updating the given resource Model. @@ -351,15 +351,11 @@ model MergePatchUpdate< * @patch op update(...MergePatchCreateOrUpdate): Widget; * ``` */ -@doc("") -@friendlyName(NameTemplate, T) -@mediaTypeHint("application/merge-patch+json") -@applyMergePatch( +alias MergePatchCreateOrUpdate< + T extends Reflection.Model, + NameTemplate extends valueof string = "{name}MergePatchCreateOrUpdate" +> = applyMergePatchTransform( T, NameTemplate, #{ visibilityMode: Private.MergePatchVisibilityMode.CreateOrUpdate } -) -model MergePatchCreateOrUpdate< - T extends Reflection.Model, - NameTemplate extends valueof string = "{name}MergePatchCreateOrUpdate" -> {} +); diff --git a/packages/http/lib/private.decorators.tsp b/packages/http/lib/private.decorators.tsp index afffcde5550..5389fa50bd1 100644 --- a/packages/http/lib/private.decorators.tsp +++ b/packages/http/lib/private.decorators.tsp @@ -46,6 +46,7 @@ model ApplyMergePatchOptions { * Performs the canonical merge-patch transformation on the given model and injects its * transformed properties into the target. */ +#deprecated "applyMergePatch is deprecated and will be removed in a future release. This decorator is not intended for public use." extern dec applyMergePatch( target: Reflection.Model, source: Reflection.Model, @@ -53,6 +54,13 @@ extern dec applyMergePatch( options: valueof ApplyMergePatchOptions ); +#suppress "experimental-feature" +internal extern fn applyMergePatchTransform( + input: Reflection.Model, + nameTemplate: valueof string, + options: valueof ApplyMergePatchOptions +): Reflection.Model; + /** * Marks a model that was generated by applying the MergePatch * transform and links to its source model diff --git a/packages/http/src/index.ts b/packages/http/src/index.ts index c7b7b3883b9..478c4178872 100644 --- a/packages/http/src/index.ts +++ b/packages/http/src/index.ts @@ -147,4 +147,4 @@ export type { } from "./types.js"; /** @internal */ -export { $decorators } from "./tsp-index.js"; +export { $decorators, $functions } from "./tsp-index.js"; diff --git a/packages/http/src/merge-patch.ts b/packages/http/src/merge-patch.ts index e1b31f72c4e..e385d6e152e 100644 --- a/packages/http/src/merge-patch.ts +++ b/packages/http/src/merge-patch.ts @@ -6,6 +6,7 @@ import { DecoratorApplication, DecoratorContext, EnumValue, + FunctionContext, getDiscriminatedUnion, getDiscriminator, getLifecycleVisibilityEnum, @@ -15,6 +16,7 @@ import { navigateType, Program, resetVisibilityModifiersForClass, + setMediaTypeHint, Tuple, Type, Union, @@ -97,23 +99,21 @@ interface MergePatchMutatorCache { type MergePatchVisibilityMode = "Update" | "CreateOrUpdate"; -export const $applyMergePatch: ApplyMergePatchDecorator = ( - ctx: DecoratorContext, - target: Model, - source: Model, +export function applyMergePatchTransform( + ctx: FunctionContext | DecoratorContext, + input: Model, nameTemplate: string, options: ApplyMergePatchOptions, -) => { - setMergePatchSource(ctx.program, target, source); +): Model { let reported = false; navigateType( - source, + input, { intrinsic: (i) => { if (!reported && i.name === "null") { reportDiagnostic(ctx.program, { code: "merge-patch-contains-null", - target, + target: input, }); reported = true; } @@ -133,10 +133,29 @@ export const $applyMergePatch: ApplyMergePatchDecorator = ( visibilityMode, )); - const mutated = cachedMutateSubgraph(ctx.program, mutator, source); + const { type } = cachedMutateSubgraph(ctx.program, mutator, input); + + compilerAssert( + type.kind === "Model", + "Expected the root of the MergePatch transform to be a Model", + ); + + setMergePatchSource(ctx.program, type, input); + setMediaTypeHint(ctx.program, type, "application/merge-patch+json"); + + return type; +} + +export const $applyMergePatch: ApplyMergePatchDecorator = ( + ctx: DecoratorContext, + target: Model, + source: Model, + nameTemplate: string, + options: ApplyMergePatchOptions, +) => { + const transformed = applyMergePatchTransform(ctx, source, nameTemplate, options); - target.properties = (mutated.type as Model).properties; - ctx.program.stateMap(HttpStateKeys.mergePatchModel).set(target, source); + target.properties = transformed.properties; }; function visibilityModeToFilters( @@ -200,7 +219,7 @@ function setPropertyOverride( * @returns */ function createMergePatchMutator( - ctx: DecoratorContext, + ctx: DecoratorContext | FunctionContext, nameTemplate: string, visibilityMode: MergePatchVisibilityMode, ): Mutator { @@ -311,6 +330,7 @@ function createMergePatchMutator( } } + setMediaTypeHint(program, clone, "application/merge-patch+json"); rename(ctx.program, clone, nameTemplate); }, }, @@ -373,6 +393,7 @@ function createMergePatchMutator( } clone.decorators = clone.decorators.filter((d) => d.decorator !== $applyMergePatch); + setMediaTypeHint(program, clone, "application/merge-patch+json"); ctx.program.stateMap(HttpStateKeys.mergePatchModel).set(clone, model); rename(ctx.program, clone, nameTemplate); }, diff --git a/packages/http/src/tsp-index.ts b/packages/http/src/tsp-index.ts index e4a87965dff..227f96d878d 100644 --- a/packages/http/src/tsp-index.ts +++ b/packages/http/src/tsp-index.ts @@ -1,5 +1,8 @@ import { TypeSpecHttpDecorators } from "../generated-defs/TypeSpec.Http.js"; -import { TypeSpecHttpPrivateDecorators } from "../generated-defs/TypeSpec.Http.Private.js"; +import { + TypeSpecHttpPrivateDecorators, + TypeSpecHttpPrivateFunctions, +} from "../generated-defs/TypeSpec.Http.Private.js"; import { $body, $bodyIgnore, @@ -21,7 +24,12 @@ import { } from "./decorators.js"; import { $route } from "./decorators/route.js"; import { $sharedRoute } from "./decorators/shared-route.js"; -import { $applyMergePatch, $mergePatchModel, $mergePatchProperty } from "./merge-patch.js"; +import { + $applyMergePatch, + $mergePatchModel, + $mergePatchProperty, + applyMergePatchTransform, +} from "./merge-patch.js"; import { $httpFile, $httpPart, @@ -65,3 +73,9 @@ export const $decorators = { mergePatchProperty: $mergePatchProperty, } satisfies TypeSpecHttpPrivateDecorators, }; + +export const $functions = { + "TypeSpec.Http.Private": { + applyMergePatchTransform, + } satisfies TypeSpecHttpPrivateFunctions, +}; diff --git a/packages/http/test/merge-patch.test.ts b/packages/http/test/merge-patch.test.ts index 1cbac9f7d55..ef86050a38d 100644 --- a/packages/http/test/merge-patch.test.ts +++ b/packages/http/test/merge-patch.test.ts @@ -1,4 +1,12 @@ -import { Diagnostic, Model, ModelProperty, Program, Type, TypeKind } from "@typespec/compiler"; +import { + Diagnostic, + getMediaTypeHint, + Model, + ModelProperty, + Program, + Type, + TypeKind, +} from "@typespec/compiler"; import { expectDiagnosticEmpty, expectDiagnostics, @@ -245,6 +253,36 @@ describe("http operation support", () => { }); }); describe("mutator validation", () => { + it("sets media type hint on transformed models", async () => { + const [program, diag] = await compileAndDiagnoseWithRunner( + runner, + ` + model Child { + id: string; + } + + model Foo { + id: string; + child?: Child; + } + + @patch op update(@body body: MergePatchUpdate): void;`, + ); + + expectDiagnosticEmpty(diag); + const bodyType = program[0].parameters?.body?.type; + ok(bodyType); + deepStrictEqual(bodyType.kind, "Model"); + expect(getMediaTypeHint(runner.program, bodyType)).toBe("application/merge-patch+json"); + + const childProp = bodyType.properties.get("child"); + ok(childProp); + const childType = getNonNullableType(childProp.type); + ok(childType); + deepStrictEqual(childType.kind, "Model"); + expect(getMediaTypeHint(runner.program, childType)).toBe("application/merge-patch+json"); + }); + it("handles optional and required properties", async () => { const [program, diag] = await compileAndDiagnoseWithRunner( runner, diff --git a/packages/json-schema/test/arrays.test.ts b/packages/json-schema/test/arrays.test.ts index 2ed2f53ac00..5e01e28fd12 100644 --- a/packages/json-schema/test/arrays.test.ts +++ b/packages/json-schema/test/arrays.test.ts @@ -114,6 +114,7 @@ describe("arrays", () => { "Test.json": Test, "Person.json": Person, "Friend.json": Friend, + "CreatePerson.json": CreatePerson, "CreateFriend.json": CreateFriend, } = await emitSchema(` model Friend { @@ -141,15 +142,17 @@ describe("arrays", () => { name: { type: "string" }, }); + assert.deepStrictEqual(CreatePerson.properties, { + friends: { type: "array", items: { $ref: "CreateFriend.json" } }, + }); + assert.deepStrictEqual(Person.properties, { friends: { type: "array", items: { $ref: "Friend.json" } }, }); assert.deepStrictEqual(Test.properties, { a: { - type: "object", - properties: { friends: { type: "array", items: { $ref: "CreateFriend.json" } } }, - required: ["friends"], + $ref: "CreatePerson.json", }, }); }); diff --git a/packages/samples/test/output/init/@typespec/openapi3/openapi.yaml b/packages/samples/test/output/init/@typespec/openapi3/openapi.yaml index adc5ea85378..6a959730973 100644 --- a/packages/samples/test/output/init/@typespec/openapi3/openapi.yaml +++ b/packages/samples/test/output/init/@typespec/openapi3/openapi.yaml @@ -198,4 +198,3 @@ components: enum: - red - blue - description: '' diff --git a/packages/samples/test/output/todoApp/@typespec/openapi3/openapi.yaml b/packages/samples/test/output/todoApp/@typespec/openapi3/openapi.yaml index 33c096c3a2e..6e67d77c79c 100644 --- a/packages/samples/test/output/todoApp/@typespec/openapi3/openapi.yaml +++ b/packages/samples/test/output/todoApp/@typespec/openapi3/openapi.yaml @@ -487,7 +487,6 @@ components: allOf: - $ref: '#/components/schemas/TodoLabelsMergePatchUpdateOrCreate' nullable: true - description: '' TodoItems.InvalidTodoItem: type: object allOf: diff --git a/packages/samples/test/output/visibility/@typespec/openapi3/openapi.yaml b/packages/samples/test/output/visibility/@typespec/openapi3/openapi.yaml index fb8f7c09b4a..22103605a5e 100644 --- a/packages/samples/test/output/visibility/@typespec/openapi3/openapi.yaml +++ b/packages/samples/test/output/visibility/@typespec/openapi3/openapi.yaml @@ -185,7 +185,6 @@ components: type: array items: $ref: '#/components/schemas/PersonRelativeMergePatchUpdateReplaceOnly' - description: '' PersonMergePatchUpdateReplaceOnly: type: object required: diff --git a/website/src/content/docs/docs/libraries/http/reference/data-types.md b/website/src/content/docs/docs/libraries/http/reference/data-types.md index ca606b378d2..bd4681e0542 100644 --- a/website/src/content/docs/docs/libraries/http/reference/data-types.md +++ b/website/src/content/docs/docs/libraries/http/reference/data-types.md @@ -442,96 +442,6 @@ model TypeSpec.Http.LocationHeader | -------- | -------- | --------------------------------------------------------------------------------------------------- | | location | `string` | The Location header contains the URL where the status of the long running operation can be checked. | -### `MergePatchCreateOrUpdate` {#TypeSpec.Http.MergePatchCreateOrUpdate} - -Create a MergePatch Request body for creating or updating the given resource Model. -The MergePatch request created by this template provides a TypeSpec description of a -JSON MergePatch request that can successfully create or update the given resource. -The transformation follows the definition of JSON MergePatch requests in -rfc 7396: https://www.rfc-editor.org/rfc/rfc7396, -applying the merge-patch transform recursively to keyed types in the resource Model. - -Using this template in a PATCH request body overrides the `implicitOptionality` -setting for PATCH operations and sets `application/merge-patch+json` as the request -content-type. - -```typespec -model TypeSpec.Http.MergePatchCreateOrUpdate -``` - -#### Template Parameters - -| Name | Description | -| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| T | The type of the resource to create a MergePatch update request body for. | -| NameTemplate | A StringTemplate used to name any models created by applying
the merge-patch transform to the resource. The default name template is `{name}MergePatchCreateOrUpdate`,
for example, the merge patch transform of model `Widget` is named `WidgetMergePatchCreateOrUpdate`. | - -#### Examples - -```tsp -// An operation updating a 'Widget' using merge-patch -@patch op update(@body request: MergePatchCreateOrUpdate): Widget; -``` - -```tsp -// An operation updating a 'Widget' using merge-patch -@patch op update(@bodyRoot request: MergePatchCreateOrUpdate): Widget; -``` - -```tsp -// An operation updating a 'Widget' using merge-patch -@patch op update(...MergePatchCreateOrUpdate): Widget; -``` - -#### Properties - -None - -### `MergePatchUpdate` {#TypeSpec.Http.MergePatchUpdate} - -Create a MergePatch Request body for updating the given resource Model. -The MergePatch request created by this template provides a TypeSpec description of a -JSON MergePatch request that can successfully update the given resource. -The transformation follows the definition of JSON MergePatch requests in -rfc 7396: https://www.rfc-editor.org/rfc/rfc7396, -applying the merge-patch transform recursively to keyed types in the resource Model. - -Using this template in a PATCH request body overrides the `implicitOptionality` -setting for PATCH operations and sets `application/merge-patch+json` as the request -content-type. - -```typespec -model TypeSpec.Http.MergePatchUpdate -``` - -#### Template Parameters - -| Name | Description | -| ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| T | The type of the resource to create a MergePatch update request body for. | -| NameTemplate | A StringTemplate used to name any models created by applying
the merge-patch transform to the resource. The default name template is `{name}MergePatchUpdate`,
for example, the merge patch transform of model `Widget` is named `WidgetMergePatchUpdate`. | - -#### Examples - -```tsp -// An operation updating a 'Widget' using merge-patch -@patch op update(@body request: MergePatchUpdate): Widget; -``` - -```tsp -// An operation updating a 'Widget' using merge-patch -@patch op update(@bodyRoot request: MergePatchUpdate): Widget; -``` - -```tsp -// An operation updating a 'Widget' using merge-patch -@patch op update(...MergePatchUpdate): Widget; -``` - -#### Properties - -None - ### `MovedResponse` {#TypeSpec.Http.MovedResponse} The URL of the requested resource has been changed permanently. The new URL is given in the response. diff --git a/website/src/content/docs/docs/libraries/http/reference/index.mdx b/website/src/content/docs/docs/libraries/http/reference/index.mdx index b9915ad2783..b947edff0a5 100644 --- a/website/src/content/docs/docs/libraries/http/reference/index.mdx +++ b/website/src/content/docs/docs/libraries/http/reference/index.mdx @@ -73,8 +73,6 @@ npm install --save-peer @typespec/http - [`ImplicitFlow`](./data-types.md#TypeSpec.Http.ImplicitFlow) - [`Link`](./data-types.md#TypeSpec.Http.Link) - [`LocationHeader`](./data-types.md#TypeSpec.Http.LocationHeader) -- [`MergePatchCreateOrUpdate`](./data-types.md#TypeSpec.Http.MergePatchCreateOrUpdate) -- [`MergePatchUpdate`](./data-types.md#TypeSpec.Http.MergePatchUpdate) - [`MovedResponse`](./data-types.md#TypeSpec.Http.MovedResponse) - [`NoAuth`](./data-types.md#TypeSpec.Http.NoAuth) - [`NoContentResponse`](./data-types.md#TypeSpec.Http.NoContentResponse) diff --git a/website/src/content/docs/docs/standard-library/built-in-data-types.md b/website/src/content/docs/docs/standard-library/built-in-data-types.md index 663e1bca85d..fdd5d1d41e8 100644 --- a/website/src/content/docs/docs/standard-library/built-in-data-types.md +++ b/website/src/content/docs/docs/standard-library/built-in-data-types.md @@ -17,89 +17,6 @@ model Array | Element | The type of the array elements | -#### Properties -None - -### `Create` {#Create} - -A copy of the input model `T` with only the properties that are visible during the -"Create" resource lifecycle phase. - -This transformation is recursive, and will include only properties that have the -`Lifecycle.Create` visibility modifier. - -If a `NameTemplate` is provided, the new model will be named according to the template. -The template uses the same syntax as the `@friendlyName` decorator. -```typespec -model Create -``` - -#### Template Parameters -| Name | Description | -|------|-------------| -| T | The model to transform. | -| NameTemplate | The name template to use for the new model.

* | - -#### Examples - -```typespec -model Dog { - @visibility(Lifecycle.Read) - id: int32; - - name: string; -} - -// This model has only the `name` field. -model CreateDog is Create; -``` - -#### Properties -None - -### `CreateOrUpdate` {#CreateOrUpdate} - -A copy of the input model `T` with only the properties that are visible during the -"Create" or "Update" resource lifecycle phases. - -The "CreateOrUpdate" lifecycle phase is used by default for properties passed as parameters to operations -that can create _or_ update data, like HTTP PUT operations. - -This transformation is recursive, and will include only properties that have the -`Lifecycle.Create` or `Lifecycle.Update` visibility modifier. - -If a `NameTemplate` is provided, the new model will be named according to the template. -The template uses the same syntax as the `@friendlyName` decorator. -```typespec -model CreateOrUpdate -``` - -#### Template Parameters -| Name | Description | -|------|-------------| -| T | The model to transform. | -| NameTemplate | The name template to use for the new model.

* | - -#### Examples - -```typespec -model Dog { - @visibility(Lifecycle.Read) - id: int32; - - @visibility(Lifecycle.Create) - immutableSecret: string; - - @visibility(Lifecycle.Create, Lifecycle.Update) - secretName: string; - - name: string; -} - -// This model will have the `immutableSecret`, `secretName`, and `name` fields, but not the `id` field. -model CreateOrUpdateDog is CreateOrUpdate; -``` - #### Properties None @@ -117,51 +34,6 @@ model DefaultKeyVisibility | Visibility | The visibility to apply to all properties. | -#### Properties -None - -### `Delete` {#Delete} - -A copy of the input model `T` with only the properties that are visible during the -"Delete" resource lifecycle phase. - -The "Delete" lifecycle phase is used for properties passed as parameters to operations -that delete data, like HTTP DELETE operations. - -This transformation is recursive, and will include only properties that have the -`Lifecycle.Delete` visibility modifier. - -If a `NameTemplate` is provided, the new model will be named according to the template. -The template uses the same syntax as the `@friendlyName` decorator. -```typespec -model Delete -``` - -#### Template Parameters -| Name | Description | -|------|-------------| -| T | The model to transform. | -| NameTemplate | The name template to use for the new model.

* | - -#### Examples - -```typespec -model Dog { - @visibility(Lifecycle.Read) - id: int32; - - // Set when the Dog is removed from our data store. This happens when the - // Dog is re-homed to a new owner. - @visibility(Lifecycle.Delete) - nextOwner: string; - - name: string; -} - -// This model will have the `nextOwner` and `name` fields, but not the `id` field. -model DeleteDog is Delete; -``` - #### Properties None @@ -271,102 +143,6 @@ model PickProperties | Keys | The property keys to include. | -#### Properties -None - -### `Query` {#Query} - -A copy of the input model `T` with only the properties that are visible during the -"Query" resource lifecycle phase. - -The "Query" lifecycle phase is used for properties passed as parameters to operations -that read data, like HTTP GET or HEAD operations. This should not be confused for -the `@query` decorator, which specifies that the property is transmitted in the -query string of an HTTP request. - -This transformation is recursive, and will include only properties that have the -`Lifecycle.Query` visibility modifier. - -If a `NameTemplate` is provided, the new model will be named according to the template. -The template uses the same syntax as the `@friendlyName` decorator. -```typespec -model Query -``` - -#### Template Parameters -| Name | Description | -|------|-------------| -| T | The model to transform. | -| NameTemplate | The name template to use for the new model.

* | - -#### Examples - -```typespec -model Dog { - @visibility(Lifecycle.Read) - id: int32; - - // When getting information for a Dog, you can set this field to true to include - // some extra information about the Dog's pedigree that is normally not returned. - // Alternatively, you could just use a separate option parameter to get this - // information. - @visibility(Lifecycle.Query) - includePedigree?: boolean; - - name: string; - - // Only included if `includePedigree` is set to true in the request. - @visibility(Lifecycle.Read) - pedigree?: string; -} - -// This model will have the `includePedigree` and `name` fields, but not `id` or `pedigree`. -model QueryDog is Query; -``` - -#### Properties -None - -### `Read` {#Read} - -A copy of the input model `T` with only the properties that are visible during the -"Read" resource lifecycle phase. - -The "Read" lifecycle phase is used for properties returned by operations that read data, like -HTTP GET operations. - -This transformation is recursive, and will include only properties that have the -`Lifecycle.Read` visibility modifier. - -If a `NameTemplate` is provided, the new model will be named according to the template. -The template uses the same syntax as the `@friendlyName` decorator. -```typespec -model Read -``` - -#### Template Parameters -| Name | Description | -|------|-------------| -| T | The model to transform. | -| NameTemplate | The name template to use for the new model.

* | - -#### Examples - -```typespec -model Dog { - @visibility(Lifecycle.Read) - id: int32; - - @visibility(Lifecycle.Create, Lifecycle.Update) - secretName: string; - - name: string; -} - -// This model has the `id` and `name` fields, but not `secretName`. -model ReadDog is Read; -``` - #### Properties None @@ -400,50 +176,6 @@ model ServiceOptions |------|------|-------------| | title? | [`string`](#string) | Title of the service. | -### `Update` {#Update} - -A copy of the input model `T` with only the properties that are visible during the -"Update" resource lifecycle phase. - -The "Update" lifecycle phase is used for properties passed as parameters to operations -that update data, like HTTP PATCH operations. - -This transformation will include only the properties that have the `Lifecycle.Update` -visibility modifier, and the types of all properties will be replaced with the -equivalent `CreateOrUpdate` transformation. - -If a `NameTemplate` is provided, the new model will be named according to the template. -The template uses the same syntax as the `@friendlyName` decorator. -```typespec -model Update -``` - -#### Template Parameters -| Name | Description | -|------|-------------| -| T | The model to transform. | -| NameTemplate | The name template to use for the new model.

* | - -#### Examples - -```typespec -model Dog { - @visibility(Lifecycle.Read) - id: int32; - - @visibility(Lifecycle.Create, Lifecycle.Update) - secretName: string; - - name: string; -} - -// This model will have the `secretName` and `name` fields, but not the `id` field. -model UpdateDog is Update; -``` - -#### Properties -None - ### `UpdateableProperties` {#UpdateableProperties} Represents a collection of updateable properties. diff --git a/website/src/content/docs/docs/standard-library/built-in-decorators.md b/website/src/content/docs/docs/standard-library/built-in-decorators.md index 065a1cb5797..e38804062e6 100644 --- a/website/src/content/docs/docs/standard-library/built-in-decorators.md +++ b/website/src/content/docs/docs/standard-library/built-in-decorators.md @@ -1318,6 +1318,9 @@ Visibility may be set explicitly using any of the following decorators: ### `@withLifecycleUpdate` {#@withLifecycleUpdate} +:::caution +**Deprecated**: withLifecycleUpdate is deprecated and will be removed in a future release. Use the `Update` template instead. +::: Transforms the `target` model to include only properties that are visible during the "Update" lifecycle phase. @@ -1504,6 +1507,9 @@ model DogRead { ### `@withVisibilityFilter` {#@withVisibilityFilter} +:::caution +**Deprecated**: withVisibilityFilter is deprecated and will be removed in a future release. Use the `FilterVisibility` template or Lifecycle specific templates (e.g. `Read`, `Create`, `Update`, etc.) instead. +::: Applies the given visibility filter to the properties of the target model.