diff --git a/.chronus/changes/autorest-multifile-2025-11-17-18-2-54.md b/.chronus/changes/autorest-multifile-2025-11-17-18-2-54.md new file mode 100644 index 0000000000..3582c596f1 --- /dev/null +++ b/.chronus/changes/autorest-multifile-2025-11-17-18-2-54.md @@ -0,0 +1,7 @@ +--- +changeKind: fix +packages: + - "@azure-tools/typespec-autorest-canonical" +--- + +Adapt to shared type changes in typespec-autorest \ No newline at end of file diff --git a/.chronus/changes/autorest-multifile-2025-11-5-14-44-29.md b/.chronus/changes/autorest-multifile-2025-11-5-14-44-29.md new file mode 100644 index 0000000000..25fb7dffbf --- /dev/null +++ b/.chronus/changes/autorest-multifile-2025-11-5-14-44-29.md @@ -0,0 +1,8 @@ +--- +changeKind: feature +packages: + - "@azure-tools/typespec-autorest" + - "@azure-tools/typespec-azure-resource-manager" +--- + +Add support for multiple output files in typespec-autorest \ No newline at end of file diff --git a/packages/typespec-autorest-canonical/src/emitter.ts b/packages/typespec-autorest-canonical/src/emitter.ts index db98859c81..df49323871 100644 --- a/packages/typespec-autorest-canonical/src/emitter.ts +++ b/packages/typespec-autorest-canonical/src/emitter.ts @@ -95,8 +95,10 @@ async function emitAllServices( service, version: canonicalVersion, tcgcSdkContext, + multiService: services.length > 1, }; - const result = await getOpenAPIForService(context, options); + const results = await getOpenAPIForService(context, options); + const result = results[0]; const includedVersions = getVersion(program, service.type) ?.getVersions() ?.map((item) => item.value ?? item.name); diff --git a/packages/typespec-autorest/README.md b/packages/typespec-autorest/README.md index 0cf6f26ae5..9e670efde8 100644 --- a/packages/typespec-autorest/README.md +++ b/packages/typespec-autorest/README.md @@ -163,6 +163,12 @@ Determine whether and how to emit schemas for common-types rather than referenci Strategy for applying XML serialization metadata to schemas. +### `output-splitting` + +**Type:** `"legacy-feature-files"` + +Determines whether output should be split into multiple files. The only supported option for splitting is "legacy-feature-files", which uses the typespec-azure-resource-manager `@feature` decorators to split into output files based on feature. + ## Decorators ### Autorest diff --git a/packages/typespec-autorest/src/emit.ts b/packages/typespec-autorest/src/emit.ts index 9e01eeaa55..6029f94de5 100644 --- a/packages/typespec-autorest/src/emit.ts +++ b/packages/typespec-autorest/src/emit.ts @@ -23,6 +23,7 @@ import { getVersioningMutators } from "@typespec/versioning"; import { AutorestEmitterOptions, getTracer, reportDiagnostic } from "./lib.js"; import { AutorestDocumentEmitterOptions, + createDocumentProxy, getOpenAPIForService, sortOpenAPIDocument, } from "./openapi.js"; @@ -113,16 +114,34 @@ export function resolveAutorestOptions( emitLroOptions: resolvedOptions["emit-lro-options"], emitCommonTypesSchema: resolvedOptions["emit-common-types-schema"], xmlStrategy: resolvedOptions["xml-strategy"], + outputSplitting: resolvedOptions["output-splitting"], }; } -export async function getAllServicesAtAllVersions( +function getEmitterContext( program: Program, + service: Service, options: ResolvedAutorestEmitterOptions, -): Promise { + multiService: boolean = false, + version?: string, +): AutorestEmitterContext { const tcgcSdkContext = createTCGCContext(program, "@azure-tools/typespec-autorest"); tcgcSdkContext.enableLegacyHierarchyBuilding = true; + return { + program, + outputFile: resolveOutputFile(program, service, multiService, options, version), + service: service, + tcgcSdkContext, + proxy: createDocumentProxy(program, service, options, version), + version: version, + multiService: multiService, + }; +} +export async function getAllServicesAtAllVersions( + program: Program, + options: ResolvedAutorestEmitterOptions, +): Promise { const services = listServices(program); if (services.length === 0) { services.push({ type: program.getGlobalNamespaceType() }); @@ -133,33 +152,60 @@ export async function getAllServicesAtAllVersions( const versions = getVersioningMutators(program, service.type); if (versions === undefined) { - const context: AutorestEmitterContext = { + const context: AutorestEmitterContext = getEmitterContext( program, - outputFile: resolveOutputFile(program, service, services.length > 1, options), - service: service, - tcgcSdkContext, - }; - - const result = await getOpenAPIForService(context, options); - serviceRecords.push({ service, - versioned: false, - ...result, - }); + options, + services.length > 1, + ); + + const results = await getOpenAPIForService(context, options); + for (const result of results) { + const newResult = { ...result }; + newResult.outputFile = resolveOutputFile( + program, + service, + services.length > 1, + options, + undefined, + result.feature, + ); + serviceRecords.push({ + service, + versioned: false, + ...newResult, + }); + } } else if (versions.kind === "transient") { - const context: AutorestEmitterContext = { + const context: AutorestEmitterContext = getEmitterContext( program, - outputFile: resolveOutputFile(program, service, services.length > 1, options), - service: service, - tcgcSdkContext, - }; - - const result = await getVersionSnapshotDocument(context, versions.mutator, options); - serviceRecords.push({ service, - versioned: false, - ...result, - }); + options, + services.length > 1, + ); + + const results = await getVersionSnapshotDocument( + context, + versions.mutator, + options, + services.length > 1, + ); + for (const result of results) { + const newResult = { ...result }; + newResult.outputFile = resolveOutputFile( + program, + service, + services.length > 1, + options, + undefined, + result.feature, + ); + serviceRecords.push({ + service, + versioned: false, + ...newResult, + }); + } } else { const filteredVersions = versions.snapshots.filter( (v) => !options.version || options.version === v.version?.value, @@ -176,26 +222,36 @@ export async function getAllServicesAtAllVersions( serviceRecords.push(serviceRecord); for (const record of filteredVersions) { - const context: AutorestEmitterContext = { + const context: AutorestEmitterContext = getEmitterContext( program, - outputFile: resolveOutputFile( + service, + options, + services.length > 1, + record.version?.value, + ); + + const results = await getVersionSnapshotDocument( + context, + record.mutator, + options, + services.length > 1, + ); + for (const result of results) { + const newResult = { ...result }; + newResult.outputFile = resolveOutputFile( program, service, services.length > 1, options, - record.version?.value, - ), - service, - version: record.version?.value, - tcgcSdkContext, - }; - - const result = await getVersionSnapshotDocument(context, record.mutator, options); - serviceRecord.versions.push({ - ...result, - service, - version: record.version!.value, - }); + record.version!.value, + result.feature, + ); + serviceRecord.versions.push({ + ...newResult, + service, + version: record.version!.value, + }); + } } } } @@ -207,6 +263,7 @@ async function getVersionSnapshotDocument( context: AutorestEmitterContext, mutator: unsafe_MutatorWithNamespace, options: ResolvedAutorestEmitterOptions, + multiService: boolean = false, ) { const subgraph = unsafe_mutateSubgraphWithNamespace( context.program, @@ -215,10 +272,15 @@ async function getVersionSnapshotDocument( ); compilerAssert(subgraph.type.kind === "Namespace", "Should not have mutated to another type"); - const document = await getOpenAPIForService( - { ...context, service: getService(context.program, subgraph.type)! }, + const service = getService(context.program, subgraph.type)!; + const newContext: AutorestEmitterContext = getEmitterContext( + context.program, + service, options, + multiService, + context.version, ); + const document = await getOpenAPIForService(newContext, options); return document; } @@ -247,6 +309,9 @@ async function emitOutput( result: AutorestEmitterResult, options: ResolvedAutorestEmitterOptions, ) { + const currentFeature = result.feature; + if (currentFeature !== undefined && result.context.proxy !== undefined) + result.context.proxy.setCurrentFeature(currentFeature); const sortedDocument = sortOpenAPIDocument(result.document); // Write out the OpenAPI document to the output path @@ -277,12 +342,13 @@ function prettierOutput(output: string) { return output + "\n"; } -function resolveOutputFile( +export function resolveOutputFile( program: Program, service: Service, multipleServices: boolean, options: ResolvedAutorestEmitterOptions, version?: string, + feature?: string, ): string { const azureResourceProviderFolder = options.azureResourceProviderFolder; if (azureResourceProviderFolder) { @@ -301,6 +367,7 @@ function resolveOutputFile( : "stable" : undefined, version, + feature, }); return resolvePath(options.outputDir, interpolated); diff --git a/packages/typespec-autorest/src/lib.ts b/packages/typespec-autorest/src/lib.ts index a0bab1dc18..d1cfa04ca1 100644 --- a/packages/typespec-autorest/src/lib.ts +++ b/packages/typespec-autorest/src/lib.ts @@ -113,6 +113,12 @@ export interface AutorestEmitterOptions { * @default "xml-service" */ "xml-strategy"?: "xml-service" | "none"; + + /** + * Determines whether output should be split into multiple files. The only supported option for splitting is "legacy-feature-files", + * which uses the typespec-azure-resource-manager `@feature` decorators to split into output files based on feature. + */ + "output-splitting"?: "legacy-feature-files"; } const EmitterOptionsSchema: JSONSchemaType = { @@ -238,6 +244,13 @@ const EmitterOptionsSchema: JSONSchemaType = { default: "xml-service", description: "Strategy for applying XML serialization metadata to schemas.", }, + "output-splitting": { + type: "string", + enum: ["legacy-feature-files"], + nullable: true, + description: + 'Determines whether output should be split into multiple files. The only supported option for splitting is "legacy-feature-files", which uses the typespec-azure-resource-manager `@feature` decorators to split into output files based on feature.', + }, }, required: [], }; diff --git a/packages/typespec-autorest/src/openapi.ts b/packages/typespec-autorest/src/openapi.ts index a58ca03160..5420a38099 100644 --- a/packages/typespec-autorest/src/openapi.ts +++ b/packages/typespec-autorest/src/openapi.ts @@ -15,7 +15,9 @@ import { getArmKeyIdentifiers, getCustomResourceOptions, getExternalTypeRef, + getFeature, getInlineAzureType, + getResourceFeatureSet, isArmCommonType, isArmExternalType, isArmProviderNamespace, @@ -46,6 +48,7 @@ import { PagingOperation, Program, Scalar, + Service, StringLiteral, StringTemplate, Type, @@ -137,6 +140,7 @@ import { resolveRequestVisibility, } from "@typespec/http"; import { + AdditionalInfo, checkDuplicateTypeName, getExtensions, getExternalDocs, @@ -147,6 +151,7 @@ import { shouldInline, } from "@typespec/openapi"; import { getVersionsForEnum } from "@typespec/versioning"; +import { ArmFeatureOptions } from "../../typespec-azure-resource-manager/generated-defs/Azure.ResourceManager.Legacy.js"; import { AutorestOpenAPISchema } from "./autorest-openapi-schema.js"; import { getExamples, getRef } from "./decorators.js"; import { sortWithJsonSchema } from "./json-schema-sorter/sorter.js"; @@ -176,7 +181,14 @@ import { XmlObject, XmsPageable, } from "./openapi2-document.js"; -import type { AutorestEmitterResult, LoadedExample } from "./types.js"; +import { + LateBoundReference, + OpenApi2DocumentProxy, + PendingSchema, + ProcessedSchema, + type AutorestEmitterResult, + type LoadedExample, +} from "./types.js"; import { AutorestEmitterContext, getClientName, @@ -237,56 +249,10 @@ export interface AutorestDocumentEmitterOptions { readonly emitCommonTypesSchema?: "never" | "for-visibility-changes"; readonly xmlStrategy: "xml-service" | "none"; -} - -/** - * Represents a node that will hold a JSON reference. The value is computed - * at the end so that we can defer decisions about the name that is - * referenced. - */ -class Ref { - value?: string; - toJSON() { - compilerAssert(this.value, "Reference value never set."); - return this.value; - } -} - -/** - * Represents a non-inlined schema that will be emitted as a definition. - * Computation of the OpenAPI schema object is deferred. - */ -interface PendingSchema { - /** The TYPESPEC type for the schema */ - type: Type; - - /** The visibility to apply when computing the schema */ - visibility: Visibility; - - /** - * The JSON reference to use to point to this schema. - * - * Note that its value will not be computed until all schemas have been - * computed as we will add a suffix to the name if more than one schema - * must be emitted for the type for different visibilities. - */ - ref: Ref; - /** - * Determines the schema name if an override has been set - * @param name The default name of the schema - * @param visibility The visibility in which the schema is used - * @returns The name of the given schema in the given visibility context + * Determines whether output should be split into multiple files. The only supported option for splitting is "legacy-feature-files", */ - getSchemaNameOverride?: (name: string, visibility: Visibility) => string; -} - -/** - * Represents a schema that is ready to emit as its OpenAPI representation - * has been produced. - */ -interface ProcessedSchema extends PendingSchema { - schema: OpenAPI2Schema | undefined; + readonly outputSplitting?: "legacy-feature-files"; } type HttpParameterProperties = Extract< @@ -297,8 +263,10 @@ type HttpParameterProperties = Extract< export async function getOpenAPIForService( context: AutorestEmitterContext, options: AutorestDocumentEmitterOptions, -): Promise { +): Promise { const { program, service } = context; + const proxy = + context.proxy ?? createDefaultDocumentProxy(program, service, options, context.version); const typeNameOptions: TypeNameOptions = { // shorten type names by removing TypeSpec and service namespace namespaceFilter(ns) { @@ -306,33 +274,15 @@ export async function getOpenAPIForService( }, }; const httpService = ignoreDiagnostics(getHttpService(program, service.type)); - const info = resolveInfo(program, service.type); + proxy.addAdditionalInfo(resolveInfo(program, service.type)); const auth = processAuth(service.type); + if (auth?.securitySchemes) proxy.addSecuritySchemes(auth.securitySchemes); + if (auth?.security) proxy.addSecurityRequirements(auth.security); const xml = await resolveXmlModule(); const xmlStrategy = options.xmlStrategy; - const root: OpenAPI2Document = { - swagger: "2.0", - info: { - title: "(title)", - ...info, - version: context.version ?? info?.version ?? "0000-00-00", - "x-typespec-generated": [{ emitter: "@azure-tools/typespec-autorest" }], - }, - schemes: ["https"], - ...resolveHost(program, service.type), - externalDocs: getExternalDocs(program, service.type), - produces: [], // Pre-initialize produces and consumes so that - consumes: [], // they show up at the top of the document - security: auth?.security, - securityDefinitions: auth?.securitySchemes ?? {}, - tags: [], - paths: {}, - "x-ms-paths": {}, - definitions: {}, - parameters: {}, - }; + proxy.addHostInfo(resolveHost(program, service.type)); let currentEndpoint: OpenAPI2Operation; let currentConsumes: Set; @@ -347,7 +297,7 @@ export async function getOpenAPIForService( const pendingSchemas = new TwoLevelMap(); // Reuse a single ref object per Type+Visibility combination. - const refs = new TwoLevelMap(); + const refs = new TwoLevelMap(); // Keep track of inline types still in the process of having their schema computed // This is used to detect cycles in inline types, which is an @@ -364,9 +314,6 @@ export async function getOpenAPIForService( // - Multipart models const indirectlyProcessedTypes: Set = new Set(); - // De-dupe the per-endpoint tags that will be added into the #/tags - const tags: Set = new Set(); - const operationIdsWithExample = new Set(); const [exampleMap, diagnostics] = await loadExamples(program, options, context.version); @@ -413,45 +360,12 @@ export async function getOpenAPIForService( emitParameters(); emitSchemas(service.type); - emitTags(); - - // Finalize global produces/consumes - if (globalProduces.size > 0) { - root.produces = [...globalProduces.values()]; - } else { - delete root.produces; - } - if (globalConsumes.size > 0) { - root.consumes = [...globalConsumes.values()]; - } else { - delete root.consumes; - } - // Clean up empty entries - if (root["x-ms-paths"] && Object.keys(root["x-ms-paths"]).length === 0) { - delete root["x-ms-paths"]; - } - if (root.security && Object.keys(root.security).length === 0) { - delete root["security"]; - } - if (root.securityDefinitions && Object.keys(root.securityDefinitions).length === 0) { - delete root["securityDefinitions"]; - } + proxy.setGlobalConsumes([...globalConsumes]); + proxy.setGlobalProduces([...globalProduces]); - return { - document: root, - operationExamples: [...operationIdsWithExample] - .map((operationId) => { - const data = exampleMap.get(operationId); - if (data) { - return { operationId, examples: Object.values(data) }; - } else { - return undefined; - } - }) - .filter((x) => x) as any, - outputFile: context.outputFile, - }; + proxy.writeExamples(exampleMap, operationIdsWithExample); + return proxy.resolveDocuments(context); function resolveHost( program: Program, @@ -545,14 +459,6 @@ export async function getOpenAPIForService( return undefined; } - function requiresXMsPaths(path: string, operation: Operation): boolean { - const isShared = isSharedRoute(program, operation) ?? false; - if (path.includes("?")) { - return true; - } - return isShared; - } - function getFinalStateVia(metadata: LroMetadata): XMSLongRunningFinalState | undefined { switch (metadata.finalStateVia) { case FinalStateValue.azureAsyncOperation: @@ -568,7 +474,9 @@ export async function getOpenAPIForService( } } - function getFinalStateSchema(metadata: LroMetadata): { "final-state-schema": Ref } | undefined { + function getFinalStateSchema( + metadata: LroMetadata, + ): { "final-state-schema": LateBoundReference } | undefined { if ( metadata.finalResult !== undefined && metadata.finalResult !== "void" && @@ -578,70 +486,34 @@ export async function getOpenAPIForService( const schemaOrRef = resolveExternalRef(metadata.finalResult); if (schemaOrRef !== undefined) { - const ref = new Ref(); - ref.value = schemaOrRef.$ref; + const ref = proxy.createExternalRef(schemaOrRef.$ref); return { "final-state-schema": ref }; } const pending = pendingSchemas.getOrAdd(metadata.finalResult, Visibility.Read, () => ({ type: model, visibility: Visibility.Read, - ref: refs.getOrAdd(model, Visibility.Read, () => new Ref()), + ref: refs.getOrAdd(model, Visibility.Read, () => proxy.createLocalRef(model)), })); return { "final-state-schema": pending.ref }; } return undefined; } - /** Initialize the openapi PathItem object where this operation should be added. */ - function initPathItem(operation: HttpOperation): OpenAPI2PathItem { - let { path, operation: op, verb } = operation; - let pathsObject: Record = root.paths; - - if (root.paths[path]?.[verb] === undefined && !path.includes("?")) { - pathsObject = root.paths; - } else if (requiresXMsPaths(path, op)) { - // if the key already exists in x-ms-paths, append the operation id. - if (path.includes("?")) { - if (root["x-ms-paths"]?.[path] !== undefined) { - path += `&_overload=${operation.operation.name}`; - } - } else { - path += `?_overload=${operation.operation.name}`; - } - pathsObject = root["x-ms-paths"] as any; - } else { - // This should not happen because http library should have already validated duplicate path or the routes must have been using shared routes and so goes in previous condition. - compilerAssert(false, `Duplicate route "${path}". This is unexpected.`); - } - - if (!pathsObject[path]) { - pathsObject[path] = {}; - } - - return pathsObject[path]; - } - function emitOperation(operation: HttpOperation) { const { operation: op, verb, parameters } = operation; - const currentPath = initPathItem(operation); - if (!currentPath[verb]) { - currentPath[verb] = {} as any; - } - currentEndpoint = currentPath[verb]!; + currentEndpoint = proxy.createOrGetEndpoint(operation, context); currentConsumes = new Set(); currentProduces = new Set(); const currentTags = getAllTags(program, op); if (currentTags) { - currentEndpoint.tags = currentTags; + currentEndpoint.tags = [...currentTags.values()]; for (const tag of currentTags) { // Add to root tags if not already there - tags.add(tag); + proxy.addTag(tag, op); } } - currentEndpoint.operationId = resolveOperationId(context, op); - applyExternalDocs(op, currentEndpoint); // Set up basic endpoint fields @@ -703,7 +575,7 @@ export async function getOpenAPIForService( ); } - const autoExamples = exampleMap.get(currentEndpoint.operationId); + const autoExamples = exampleMap.get(currentEndpoint.operationId!); if (autoExamples && currentEndpoint.operationId) { operationIdsWithExample.add(currentEndpoint.operationId); currentEndpoint["x-ms-examples"] = currentEndpoint["x-ms-examples"] || {}; @@ -1023,12 +895,13 @@ export async function getOpenAPIForService( const pending = pendingSchemas.getOrAdd(type, schemaContext.visibility, () => ({ type, visibility: schemaContext.visibility, - ref: refs.getOrAdd(type, schemaContext.visibility, () => new Ref()), + ref: refs.getOrAdd(type, schemaContext.visibility, () => proxy.createLocalRef(type)), getSchemaNameOverride: schemaNameOverride, })); return { $ref: pending.ref }; } } + function getSchemaForInlineType( type: Type, name: string, @@ -1235,7 +1108,6 @@ export async function getOpenAPIForService( if (isNeverType(prop.type)) { return; } - const ph = getParamPlaceholder(prop); currentEndpoint.parameters.push(ph); @@ -1545,8 +1417,14 @@ export async function getOpenAPIForService( param["x-ms-parameter-location"] = "method"; } - const key = getParameterKey(program, property, param, root.parameters!, typeNameOptions); - root.parameters![key] = { ...param }; + const key = getParameterKey( + program, + property, + param, + proxy.getParameterMap(), + typeNameOptions, + ); + proxy.writeParameter(key, property, param); const refedParam = param as any; for (const key of Object.keys(param)) { @@ -1580,16 +1458,16 @@ export async function getOpenAPIForService( name += getVisibilitySuffix(visibility, Visibility.Read); } - checkDuplicateTypeName(program, processed.type, name, root.definitions!); - processed.ref.value = "#/definitions/" + encodeURIComponent(name); + checkDuplicateTypeName(program, processed.type, name!, proxy.getDefinitionMap()!); + processed.ref.setLocalValue(program, encodeURIComponent(name!), processed.type); if (processed.schema) { if (shouldEmitXml) attachXml( processed.type, - name, + name!, processed as ProcessedSchema & { schema: OpenAPI2Schema }, ); - root.definitions![name] = processed.schema; + proxy.writeDefinition(name!, processed, processed.schema); } } } @@ -1659,12 +1537,6 @@ export async function getOpenAPIForService( return false; } - function emitTags() { - for (const tag of tags) { - root.tags!.push({ name: tag }); - } - } - function getSchemaForType( type: Type, schemaContext: SchemaContext, @@ -1885,6 +1757,7 @@ export async function getOpenAPIForService( description: getDoc(program, member), }; } + function getSchemaForUnionVariant( variant: UnionVariant, schemaContext: SchemaContext, @@ -2491,6 +2364,7 @@ export async function getOpenAPIForService( target.title = summary; } } + function applyExternalDocs(typespecType: Type, target: { externalDocs?: OpenAPI2ExternalDocs }) { const externalDocs = getExternalDocs(program, typespecType); if (externalDocs) { @@ -2982,3 +2856,442 @@ function isHttpParameterProperty( ): httpProperty is HttpParameterProperties { return ["header", "query", "path", "cookie"].includes(httpProperty.kind); } + +export function createDocumentProxy( + program: Program, + service: Service, + options: AutorestDocumentEmitterOptions, + version?: string, +): OpenApi2DocumentProxy { + const features = getResourceFeatureSet(program, service.type!); + if ( + options.outputSplitting === undefined || + options.outputSplitting !== "legacy-feature-files" || + features === undefined || + features.size < 2 + ) { + return createDefaultDocumentProxy(program, service, options, version); + } else { + return createFeatureDocumentProxy(program, service, options, version); + } +} + +export function createDefaultDocumentProxy( + program: Program, + service: Service, + options: AutorestDocumentEmitterOptions, + version?: string, +): OpenApi2DocumentProxy { + const root: OpenAPI2Document = initializeOpenApi2Document(program, service, version); + const tags = new Set(); + const definitions = new Map(); + const parameters: Map = new Map(); + let examples: Map> = new Map(); + let operationIdsWithExamples: Set = new Set(); + return { + getDefinitionMap() { + const result: Record = {}; + for (const [name, schema] of definitions) { + result[name] = schema; + } + return result; + }, + writeDefinition: (name: string, processed: ProcessedSchema, schema: OpenAPI2Schema) => { + definitions.set(name, schema); + }, + getParameterMap() { + const result: Record = {}; + for (const [name, [_, parameter]] of parameters) { + result[name] = parameter; + } + return result; + }, + writeParameter(key: string, prop: ModelProperty, parameter: OpenAPI2Parameter) { + parameters.set(key, [prop, parameter]); + let defaultParameters = root.parameters; + if (defaultParameters === undefined) { + defaultParameters = {}; + root.parameters = defaultParameters; + } + defaultParameters[key] = { ...parameter }; + }, + + createOrGetEndpoint(op: HttpOperation, context: AutorestEmitterContext): OpenAPI2Operation { + const pathItem = initPathItem(program, op, root); + if (!pathItem[op.verb]) { + pathItem[op.verb] = { parameters: [] }; + } + const resolvedOp = pathItem[op.verb]!; + resolvedOp.operationId = resolveOperationId(context, op.operation); + return resolvedOp; + }, + addTag(tag: string, op: Operation) { + tags.add(tag); + }, + setGlobalConsumes(consumes: string[]) { + root.consumes = consumes; + }, + + setGlobalProduces(produces: string[]) { + root.produces = produces; + }, + addAdditionalInfo(info?: AdditionalInfo) { + if (info !== undefined) { + Object.assign(root.info, info); + } + }, + addHostInfo(hostInfo: Pick) { + Object.assign(root, hostInfo); + }, + addSecurityRequirements(requirements?: Record[]) { + if (requirements !== undefined && requirements.length > 0) { + root.security = requirements; + } + }, + addSecuritySchemes(schemes?: Record) { + if (schemes !== undefined && Object.keys(schemes).length > 0) { + root.securityDefinitions = schemes; + } + }, + writeExamples(inExamples: Map>, exampleIds: Set) { + examples = inExamples; + operationIdsWithExamples = exampleIds; + }, + resolveDocuments(context: AutorestEmitterContext) { + root.definitions = {}; + for (const [name, schema] of definitions) { + root.definitions[name] = schema; + } + finalizeOpenApi2Document(root, tags); + return Promise.resolve([ + { + document: root, + operationExamples: [...operationIdsWithExamples] + .map((operationId) => { + const data = examples.get(operationId); + if (data) { + return { operationId, examples: Object.values(data) }; + } else { + return undefined; + } + }) + .filter((x) => x) as any, + outputFile: context.outputFile, + context: context, + }, + ]); + }, + createLocalRef(type) { + const feature = getFeature(program, type); + const result: LateBoundReference = new LateBoundReference(); + result.file = feature.fileName!; + return result; + }, + createExternalRef(absoluteRef: string) { + const result: LateBoundReference = new LateBoundReference(); + result.setRemoteValue(absoluteRef); + return result; + }, + getCurrentFeature() { + return undefined; + }, + setCurrentFeature(feature) { + return; + }, + getParameterRef(key: string): string { + return `#/parameters/${encodeURIComponent(key)}`; + }, + } as OpenApi2DocumentProxy; +} + +interface OpenAPI2DocumentItem { + document: OpenAPI2Document; + operationExamples: Map; + tags: Set; + options: ArmFeatureOptions; +} + +function createFeatureDocumentProxy( + program: Program, + service: Service, + options: AutorestDocumentEmitterOptions, + version?: string, +): OpenApi2DocumentProxy { + const features = getResourceFeatureSet(program, service.type!); + if (features === undefined || features.size < 2) + return createDefaultDocumentProxy(program, service, options, version); + const root: Map = new Map(); + const operationFeatures: Map> = new Map(); + let examples: Map> = new Map(); + let operationIdsWithExamples: Set = new Set(); + for (const featureName of features.keys()) { + const featureOptions = features.get(featureName)!; + root.set( + featureName.toLowerCase(), + initializeOpenAPIDocumentItem(program, service, featureOptions, version), + ); + } + const defaultFeature = [...root.entries()].filter( + ([key, _]) => key.toLowerCase() === "common", + )[0][1]; + const definitions = new Map(); + const resolvedParameters = new Map(); + let currentFeature: string = ""; + return { + getDefinitionMap() { + const result: Record = {}; + for (const [name, [_, schema]] of definitions) { + result[name] = schema; + } + return result; + }, + writeDefinition: (name: string, processed: ProcessedSchema, schema: OpenAPI2Schema) => { + const feature = getFeatureKey(program, processed.type); + definitions.set(name, [feature, schema]); + }, + getParameterMap() { + const result: Record = {}; + for (const [name, [_, parameter]] of resolvedParameters) { + result[name] = parameter; + } + return result; + }, + writeParameter(key: string, prop: ModelProperty, parameter: OpenAPI2Parameter) { + resolvedParameters.set(key, [prop, parameter]); + let defaultParameters = defaultFeature.document.parameters; + if (defaultParameters === undefined) { + defaultParameters = {}; + defaultFeature.document.parameters = defaultParameters; + } + defaultParameters[key] = { ...parameter }; + }, + createOrGetEndpoint(op: HttpOperation, context: AutorestEmitterContext): OpenAPI2Operation { + const options = getFeature(program, op.operation); + const item = root.get(options.featureName.toLowerCase())!; + const pathItem = initPathItem(program, op, item.document); + if (!pathItem[op.verb]) { + pathItem[op.verb] = { parameters: [] }; + } + const resolvedOp = pathItem[op.verb]!; + const opId = resolveOperationId(context, op.operation); + addFeatureOperation(opId, options.featureName); + resolvedOp.operationId = opId; + return resolvedOp; + }, + setGlobalConsumes(mimeTypes) { + for (const featureItem of root.values()) { + featureItem.document.consumes = []; + for (const mimeType of mimeTypes) { + featureItem.document.consumes.push(mimeType); + } + } + }, + setGlobalProduces(mimeTypes) { + for (const featureItem of root.values()) { + featureItem.document.produces = []; + for (const mimeType of mimeTypes) { + featureItem.document.produces.push(mimeType); + } + } + }, + addTag(tag: string, op: Operation) { + const feature = getFeatureKey(program, op); + const item = root.get(feature)!; + item.tags.add(tag); + }, + addAdditionalInfo(info?: AdditionalInfo) { + if (info !== undefined) { + for (const featureItem of root.values()) Object.assign(featureItem.document.info, info); + } + }, + addHostInfo(hostInfo: Pick) { + for (const featureItem of root.values()) Object.assign(featureItem.document, hostInfo); + }, + addSecurityRequirements(requirements?: Record[]) { + if (requirements !== undefined && requirements.length > 0) { + for (const featureItem of root.values()) featureItem.document.security = requirements; + } + }, + addSecuritySchemes(schemes?: Record) { + if (schemes !== undefined && Object.keys(schemes).length > 0) { + for (const featureItem of root.values()) featureItem.document.securityDefinitions = schemes; + } + }, + writeExamples(inExamples: Map>, exampleIds: Set) { + examples = inExamples; + operationIdsWithExamples = exampleIds; + }, + resolveDocuments(context: AutorestEmitterContext) { + const docs: AutorestEmitterResult[] = []; + for (const [featureName, featureItem] of root.entries()) { + const exampleIds = operationFeatures.get(featureName) || new Set(); + const featureExamples = [...exampleIds] + .filter((id) => operationIdsWithExamples.has(id)) + .map((operationId) => { + const data = examples.get(operationId); + if (data) { + return { operationId, examples: Object.values(data) }; + } else { + return undefined; + } + }) + .filter((x) => x) as any; + const definitionsForFeature = Array.from(definitions.entries()).filter( + ([_, [definitionFeature, __]]) => definitionFeature === featureName, + ); + featureItem.document.definitions = {}; + for (const [defName, [_, defSchema]] of definitionsForFeature) { + featureItem.document.definitions![defName] = defSchema; + } + finalizeOpenApi2Document(featureItem.document, featureItem.tags); + docs.push({ + document: featureItem.document, + operationExamples: featureExamples, + outputFile: context.outputFile, + feature: featureItem.options.fileName, + context: context, + }); + } + + // Collect operation examples for this feature + return Promise.resolve(docs); + }, + createLocalRef(type) { + const feature = getFeature(program, type); + currentFeature = feature.featureName; + const result: LateBoundReference = new LateBoundReference(); + result.useFeatures = true; + result.file = feature.fileName!; + result.getFileContext = () => this.getCurrentFeature(); + return result; + }, + createExternalRef(absoluteRef: string) { + const result: LateBoundReference = new LateBoundReference(); + result.setRemoteValue(absoluteRef); + return result; + }, + getCurrentFeature() { + return currentFeature; + }, + setCurrentFeature(feature) { + currentFeature = feature; + }, + getParameterRef(key: string): string { + return `./common.json/parameters/${encodeURIComponent(key)}`; + }, + } as OpenApi2DocumentProxy; + + function getFeatureKey(program: Program, type: Type): string { + const feature = getFeature(program, type); + return feature.featureName.toLowerCase(); + } + function addFeatureOperation(operationId: string, featureName: string) { + featureName = featureName.toLowerCase(); + if (!operationFeatures.has(featureName)) { + operationFeatures.set(featureName, new Set([operationId])); + return; + } + const ops = operationFeatures.get(featureName)!; + ops.add(operationId); + } +} + +function initializeOpenAPIDocumentItem( + program: Program, + service: Service, + options: ArmFeatureOptions, + version?: string, +): OpenAPI2DocumentItem { + return { + document: initializeOpenApi2Document(program, service, version), + operationExamples: new Map(), + tags: new Set(), + options, + }; +} + +function initializeOpenApi2Document( + program: Program, + service: Service, + version?: string, +): OpenAPI2Document { + return { + swagger: "2.0", + info: { + title: "(title)", + version: version ?? "0000-00-00", + "x-typespec-generated": [{ emitter: "@azure-tools/typespec-autorest" }], + }, + schemes: ["https"], + externalDocs: getExternalDocs(program, service.type), + produces: [], // Pre-initialize produces and consumes so that + consumes: [], // they show up at the top of the document + tags: [], + paths: {}, + "x-ms-paths": {}, + definitions: {}, + parameters: {}, + }; +} + +function finalizeOpenApi2Document(root: OpenAPI2Document, tags: Set) { + const xMsPaths = root["x-ms-paths"]; + if (xMsPaths && Object.keys(xMsPaths).length === 0) { + delete root["x-ms-paths"]; + } + const rootSecurity = root.security; + if (rootSecurity && Object.keys(rootSecurity).length === 0) { + delete root.security; + } + const securityDefs = root.securityDefinitions; + if (securityDefs && Object.keys(securityDefs).length === 0) { + delete root.securityDefinitions; + } + if (root.consumes !== undefined && root.consumes.length === 0) { + delete root.consumes; + } + if (root.produces !== undefined && root.produces.length === 0) { + delete root.produces; + } + root.tags = Array.from(tags).map((tagName) => ({ name: tagName })); +} + +function initPathItem( + program: Program, + operation: HttpOperation, + root: OpenAPI2Document, +): OpenAPI2PathItem { + let { path, operation: op, verb } = operation; + let pathsObject: Record = root.paths; + + if (root.paths[path]?.[verb] === undefined && !path.includes("?")) { + pathsObject = root.paths; + } else if (requiresXMsPaths(program, path, op)) { + // if the key already exists in x-ms-paths, append the operation id. + if (path.includes("?")) { + if (root["x-ms-paths"]?.[path] !== undefined) { + path += `&_overload=${operation.operation.name}`; + } + } else { + path += `?_overload=${operation.operation.name}`; + } + pathsObject = root["x-ms-paths"] as any; + } else { + // This should not happen because http library should have already validated duplicate path or the routes must have been using shared routes and so goes in previous condition. + compilerAssert(false, `Duplicate route "${path}". This is unexpected.`); + } + + if (!pathsObject[path]) { + pathsObject[path] = {}; + } + + return pathsObject[path]; +} + +function requiresXMsPaths(program: Program, path: string, operation: Operation): boolean { + const isShared = isSharedRoute(program, operation) ?? false; + if (path.includes("?")) { + return true; + } + return isShared; +} diff --git a/packages/typespec-autorest/src/types.ts b/packages/typespec-autorest/src/types.ts index 0b06c87441..5386ae2fb5 100644 --- a/packages/typespec-autorest/src/types.ts +++ b/packages/typespec-autorest/src/types.ts @@ -1,5 +1,23 @@ -import type { Service, SourceFile } from "@typespec/compiler"; -import type { OpenAPI2Document } from "./openapi2-document.js"; +import { getFeature } from "@azure-tools/typespec-azure-resource-manager"; +import { + compilerAssert, + Program, + type ModelProperty, + type Operation, + type Service, + type SourceFile, + type Type, +} from "@typespec/compiler"; +import { HttpOperation, Visibility } from "@typespec/http"; +import { AdditionalInfo } from "@typespec/openapi"; +import { AutorestEmitterContext } from "./index.js"; +import type { + OpenAPI2Document, + OpenAPI2Operation, + OpenAPI2Parameter, + OpenAPI2Schema, + OpenAPI2SecurityScheme, +} from "./openapi2-document.js"; /** * A record containing the the OpenAPI 3 documents corresponding to @@ -53,6 +71,12 @@ export interface AutorestEmitterResult { /** Output file used */ readonly outputFile: string; + + /** The feature associated with this file, if any */ + readonly feature?: string; + + /** The context associated with this emission */ + readonly context: AutorestEmitterContext; } export interface LoadedExample { @@ -60,3 +84,200 @@ export interface LoadedExample { readonly file: SourceFile; readonly data: any; } + +/** + * Represents a node that will hold a JSON reference. The value is computed + * at the end so that we can defer decisions about the name that is + * referenced. + */ +export class LateBoundReference { + isLocal?: boolean; + file?: string; + value?: string; + useFeatures: boolean = false; + getFileContext: () => string | undefined = () => undefined; + setLocalValue(program: Program, inValue: string, type?: Type): void { + if (type) { + switch (type.kind) { + case "Model": + case "ModelProperty": + this.file = this.useFeatures ? getFeature(program, type)?.fileName : undefined; + break; + default: + this.file = this.useFeatures ? "common" : undefined; + } + } + this.isLocal = true; + this.value = inValue; + } + setRemoteValue(inValue: string): void { + this.isLocal = false; + this.value = inValue; + } + toJSON() { + compilerAssert(this.value, "Reference value never set."); + const referencingFile = this.getFileContext(); + if (!this.isLocal) return this.value; + if (referencingFile === undefined || this.file === undefined || referencingFile === this.file) + return `#/definitions/${this.value}`; + return `./${this.file}.json/definitions/${this.value}`; + } +} + +/** + * Represents a non-inlined schema that will be emitted as a definition. + * Computation of the OpenAPI schema object is deferred. + */ +export interface PendingSchema { + /** The TYPESPEC type for the schema */ + type: Type; + + /** The visibility to apply when computing the schema */ + visibility: Visibility; + + /** + * The JSON reference to use to point to this schema. + * + * Note that its value will not be computed until all schemas have been + * computed as we will add a suffix to the name if more than one schema + * must be emitted for the type for different visibilities. + */ + ref: LateBoundReference; + + /** + * Determines the schema name if an override has been set + * @param name The default name of the schema + * @param visibility The visibility in which the schema is used + * @returns The name of the given schema in the given visibility context + */ + getSchemaNameOverride?: (name: string, visibility: Visibility) => string; +} + +/** + * Represents a schema that is ready to emit as its OpenAPI representation + * has been produced. + */ +export interface ProcessedSchema extends PendingSchema { + schema: OpenAPI2Schema | undefined; +} + +/** Abstracts away methods to create a OpenAPI 2.0 document ragardless of layout. */ +export interface OpenApi2DocumentProxy { + /** + * Add additionalInfo to the document header + * @param info The additional information to add + */ + addAdditionalInfo(info?: AdditionalInfo): void; + + /** + * Add security schemes to the document header + * @param schemes The security schemes to add + */ + addSecuritySchemes(schemes: Record): void; + + /** + * Add security requirements to the document header + * @param requirements The security requirements to add + */ + addSecurityRequirements(requirements: Record[]): void; + + /** + * Add host information to the document header + * @param hostData The host information to add + */ + addHostInfo( + hostData: Pick, + ): void; + + /** Set the global consumes MIME types + * @param mimeTypes The MIME types to set + */ + setGlobalConsumes(mimeTypes: string[]): void; + + /** Set the global produces MIME types + * @param mimeTypes The MIME types to set + */ + setGlobalProduces(mimeTypes: string[]): void; + + /** + * Get the resolved definitions in the document + */ + getDefinitionMap(): Record; + + /** + * Write a resolved definition to the document + * @param name The name of the definition + * @param processed The processed schema for this definition + * @param schema The OpenAPI schema to write + */ + writeDefinition(name: string, processed: ProcessedSchema, schema: OpenAPI2Schema): void; + + /** + * Get a map of resolved parameters + */ + getParameterMap(): Record; + + /** + * Write a resolved parameter to the document + * @param key The key of the parameter + * @param property The model property associated with the parameter + * @param param The OpenAPI parameter to write + */ + writeParameter(key: string, property: ModelProperty, param: OpenAPI2Parameter): void; + + /** + * Resolve the logical OpenAPI document into a set of emitter results + */ + resolveDocuments(context: AutorestEmitterContext): Promise; + + /** Add a tag to an operation + * @param op The operation to add a tag to + * @param tag The tag to add + */ + addTag(tag: string, op: Operation): void; + + /** + * write resolved examples to the document + * @param op The operation to add examples to + * @param examples The examples to add + */ + writeExamples( + examples: Map>, + exampleIds: Set, + ): void; + + /** + * Get or add the path associated with the given operation + * @param op The operation to get or add the path for + */ + createOrGetEndpoint(op: HttpOperation, context: AutorestEmitterContext): OpenAPI2Operation; + + /** + * Create a late bound reference for a type in the processed program + * @param type The type to create the reference for + */ + createLocalRef(type: Type): LateBoundReference; + + /** + * Create a late bound reference for an external type + * @param absoluteRef The absolute value of the external reference + */ + createExternalRef(absoluteRef: string): LateBoundReference; + + /** + * Get the current feature being emitted + */ + getCurrentFeature(): string | undefined; + + /** + * Set the current feature being emitted + * @param feature The feature being emitted + */ + setCurrentFeature(feature: string): void; + + /** + * Get a parameter reference for the given key + * @param key The key to get the parameter reference for + */ + getParameterRef(key: string): string; +} diff --git a/packages/typespec-autorest/src/utils.ts b/packages/typespec-autorest/src/utils.ts index 79318620f4..a6c85bdba4 100644 --- a/packages/typespec-autorest/src/utils.ts +++ b/packages/typespec-autorest/src/utils.ts @@ -18,6 +18,7 @@ import { } from "@typespec/compiler"; import { capitalize } from "@typespec/compiler/casing"; import { getOperationId } from "@typespec/openapi"; +import { OpenApi2DocumentProxy } from "./types.js"; export interface AutorestEmitterContext { readonly program: Program; @@ -25,6 +26,8 @@ export interface AutorestEmitterContext { readonly outputFile: string; readonly tcgcSdkContext: TCGCContext; readonly version?: string; + readonly proxy?: OpenApi2DocumentProxy; + readonly multiService: boolean; } export function getClientName(context: AutorestEmitterContext, type: Type & { name: string }) { diff --git a/packages/typespec-autorest/test/arm/arm.test.ts b/packages/typespec-autorest/test/arm/arm.test.ts index f47446e53c..1d4e58dde4 100644 --- a/packages/typespec-autorest/test/arm/arm.test.ts +++ b/packages/typespec-autorest/test/arm/arm.test.ts @@ -1,6 +1,6 @@ import { deepStrictEqual, ok, strictEqual } from "assert"; -import { it } from "vitest"; -import { compileOpenAPI } from "../test-host.js"; +import { expect, it } from "vitest"; +import { compileOpenAPI, CompileOpenApiWithFeatures } from "../test-host.js"; it("can share types with a library namespace", async () => { const openapi: any = await compileOpenAPI( @@ -519,3 +519,156 @@ it("generates PATCH bodies for resource patch of common resource envelope mixins openapi.definitions?.["Azure.ResourceManager.CommonTypes.SystemAssignedServiceIdentityUpdate"], ); }); +it("can split resources and operations by feature", async () => { + const { privateLink, privateEndpoint } = await CompileOpenApiWithFeatures( + ` + @Azure.ResourceManager.Legacy.features(Features) + @armProviderNamespace + @armCommonTypesVersion(Azure.ResourceManager.CommonTypes.Versions.v5) + namespace Microsoft.PrivateLinkTest; + + enum Features { + privateLink; + privateEndpoint; + common; + } + + interface Operations extends Azure.ResourceManager.Operations {} + + @Azure.ResourceManager.Legacy.feature(Features.privateEndpoint) + @tenantResource + model PrivateEndpointConnectionResource is ProxyResource { + @path + @segment("privateEndpointConnections") + @key("privateEndpointConnectionName") + name: string; + } + + @Azure.ResourceManager.Legacy.feature(Features.privateEndpoint) + @armResourceOperations(PrivateEndpointConnectionResource) + interface PrivateEndpointConnections { + #suppress "deprecated" "PrivateLinkResourceListResultV5 validation" + listConnections is ArmResourceListByParent>; + } + + @Azure.ResourceManager.Legacy.feature(Features.privateLink) + model PrivateLinkResource is ProxyResource { + ...PrivateLinkResourceParameter; + } + + @Azure.ResourceManager.Legacy.feature(Features.privateLink) + @armResourceOperations(PrivateLinkResource) + interface PrivateLinkResources { + #suppress "deprecated" "PrivateLinkResourceListResultV5 validation" + listByLinkResult is ArmResourceListByParent< PrivateLinkResource, + Response = ArmResponse + >; + } + `, + ["privateLink", "privateEndpoint", "common"], + { preset: "azure" }, + ); + + const privateEndpointList = "/providers/Microsoft.PrivateLinkTest/privateEndpointConnections"; + const privateLinkList = + "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.PrivateLinkTest/privateLinkResources"; + const pe = privateEndpoint as any; + const pl = privateLink as any; + + deepStrictEqual( + pe.paths[privateEndpointList].get.responses["200"].schema["$ref"], + "../../common-types/resource-management/v5/privatelinks.json#/definitions/PrivateEndpointConnectionListResult", + ); + deepStrictEqual( + pl.paths[privateLinkList].get.responses["200"].schema["$ref"], + "../../common-types/resource-management/v5/privatelinks.json#/definitions/PrivateLinkResourceListResult", + ); +}); +it("can represent type references within and between features", async () => { + const { featureA, featureB, common } = await CompileOpenApiWithFeatures( + ` + +@Azure.ResourceManager.Legacy.features(Features) +@armProviderNamespace("Microsoft.Test") +namespace Microsoft.Test; +enum Features { + /** Feature A */ + @Azure.ResourceManager.Legacy.featureOptions(#{featureName: "FeatureA", fileName: "featureA", description: "The data for feature A"}) + FeatureA: "Feature A", + /** Feature B */ + @Azure.ResourceManager.Legacy.featureOptions(#{featureName: "FeatureB", fileName: "featureB", description: "The data for feature B"}) + FeatureB: "Feature B", +} + @secret + scalar secretString extends string; + + @Azure.ResourceManager.Legacy.feature(Features.FeatureA) + model FooResource is TrackedResource { + ...ResourceNameParameter; + } + + @Azure.ResourceManager.Legacy.feature(Features.FeatureA) + model FooResourceProperties { + ...DefaultProvisioningStateProperty; + password: secretString; + } + + @Azure.ResourceManager.Legacy.feature(Features.FeatureB) + model BarResource is ProxyResource { + ...ResourceNameParameter; + } + @Azure.ResourceManager.Legacy.feature(Features.FeatureB) + model BarResourceProperties { + ...DefaultProvisioningStateProperty; + password: secretString; + } + + @Azure.ResourceManager.Legacy.feature(Features.FeatureA) + @armResourceOperations + interface Foos extends Azure.ResourceManager.TrackedResourceOperations {} + + @Azure.ResourceManager.Legacy.feature(Features.FeatureB) + @armResourceOperations + interface Bars extends Azure.ResourceManager.TrackedResourceOperations {} + `, + ["featureA", "featureB", "common"], + { preset: "azure" }, + ); + + const aFile = featureA as any; + const bFile = featureB as any; + const commonFile = common as any; + + expect(aFile.definitions).toBeDefined(); + expect(aFile.definitions["FooResource"]).toBeDefined(); + expect(aFile.definitions["FooResource"].properties["properties"].$ref).toBe( + "#/definitions/FooResourceProperties", + ); + expect(aFile.definitions["FooResourceProperties"]).toBeDefined(); + expect(aFile.definitions["FooResourceProperties"].properties["password"].$ref).toBe( + "./common.json/definitions/secretString", + ); + expect(aFile.definitions["FooResourceListResult"]).toBeDefined(); + expect(aFile.definitions["FooResourceUpdate"]).toBeDefined(); + expect(aFile.definitions["FooResourceUpdateProperties"]).toBeDefined(); + + expect(bFile.definitions).toBeDefined(); + expect(bFile.definitions["BarResource"]).toBeDefined(); + expect(bFile.definitions["BarResource"].properties["properties"].$ref).toBe( + "#/definitions/BarResourceProperties", + ); + expect(bFile.definitions["BarResourceProperties"]).toBeDefined(); + expect(bFile.definitions["BarResourceProperties"].properties["password"].$ref).toBe( + "./common.json/definitions/secretString", + ); + expect(bFile.definitions["BarResourceProperties"].properties["provisioningState"].$ref).toBe( + "./common.json/definitions/Azure.ResourceManager.ResourceProvisioningState", + ); + expect(bFile.definitions["BarResourceListResult"]).toBeDefined(); + expect(bFile.definitions["BarResourceUpdate"]).toBeDefined(); + expect(bFile.definitions["BarResourceUpdateProperties"]).toBeDefined(); + + expect(commonFile.definitions).toBeDefined(); + expect(commonFile.definitions["secretString"]).toBeDefined(); +}); diff --git a/packages/typespec-autorest/test/test-host.ts b/packages/typespec-autorest/test/test-host.ts index 883dc17bdc..4ff9b851ff 100644 --- a/packages/typespec-autorest/test/test-host.ts +++ b/packages/typespec-autorest/test/test-host.ts @@ -118,6 +118,33 @@ export async function compileVersionedOpenAPI( return output; } +export async function CompileOpenApiWithFeatures( + code: string, + features: F[], + options: CompileOpenAPIOptions = {}, +): Promise> { + const tester = + options?.tester ?? (await (options.preset === "azure" ? AzureTester : Tester).createInstance()); + const [{ outputs }, diagnostics] = await tester.compileAndDiagnose(code, { + compilerOptions: { + options: { + "@azure-tools/typespec-autorest": { + ...defaultOptions, + "output-splitting": "legacy-feature-files", + "output-file": "{emitter-output-dir}/{feature}.json", + }, + }, + }, + }); + expectDiagnosticEmpty(ignoreDiagnostics(diagnostics, ["@typespec/http/no-service-found"])); + + const output: any = {}; + for (const feature of features) { + output[feature] = JSON.parse(outputs[`${feature}.json`]); + } + return output; +} + /** * Deprecated use `compileOpenAPI` or `compileVersionedOpenAPI` instead */ diff --git a/packages/typespec-azure-resource-manager/README.md b/packages/typespec-azure-resource-manager/README.md index 1b8132ac02..caaebbaf57 100644 --- a/packages/typespec-azure-resource-manager/README.md +++ b/packages/typespec-azure-resource-manager/README.md @@ -572,6 +572,9 @@ This allows sharing Azure Resource Manager resource types across specifications - [`@armOperationRoute`](#@armoperationroute) - [`@customAzureResource`](#@customazureresource) - [`@externalTypeRef`](#@externaltyperef) +- [`@feature`](#@feature) +- [`@featureOptions`](#@featureoptions) +- [`@features`](#@features) - [`@renamePathParameter`](#@renamepathparameter) #### `@armExternalType` @@ -648,6 +651,63 @@ Specify an external reference that should be used when emitting this type. | ------- | ---------------- | ------------------------------------------------------------- | | jsonRef | `valueof string` | External reference(e.g. "../../common.json#/definitions/Foo") | +#### `@feature` + +Decorator to associate a feature with a model, interface, or namespace + +```typespec +@Azure.ResourceManager.Legacy.feature(featureName: EnumMember) +``` + +##### Target + +The target to associate the feature with +`Model | Interface | Namespace` + +##### Parameters + +| Name | Type | Description | +| ----------- | ------------ | ---------------------------------------- | +| featureName | `EnumMember` | The feature to associate with the target | + +#### `@featureOptions` + +Decorator to define options for a specific feature + +```typespec +@Azure.ResourceManager.Legacy.featureOptions(options: valueof Azure.ResourceManager.Legacy.ArmFeatureOptions) +``` + +##### Target + +The enum member that represents the feature +`EnumMember` + +##### Parameters + +| Name | Type | Description | +| ------- | ------------------------------------------------- | --------------------------- | +| options | [valueof `ArmFeatureOptions`](#armfeatureoptions) | The options for the feature | + +#### `@features` + +Decorator to define a set of features + +```typespec +@Azure.ResourceManager.Legacy.features(features: Enum) +``` + +##### Target + +The service namespace +`Namespace` + +##### Parameters + +| Name | Type | Description | +| -------- | ------ | ----------------------------------- | +| features | `Enum` | The enum that contains the features | + #### `@renamePathParameter` Renames a path parameter in an Azure Resource Manager operation. diff --git a/packages/typespec-azure-resource-manager/generated-defs/Azure.ResourceManager.Legacy.ts b/packages/typespec-azure-resource-manager/generated-defs/Azure.ResourceManager.Legacy.ts index 9cd454b3e4..b3378857f2 100644 --- a/packages/typespec-azure-resource-manager/generated-defs/Azure.ResourceManager.Legacy.ts +++ b/packages/typespec-azure-resource-manager/generated-defs/Azure.ResourceManager.Legacy.ts @@ -1,8 +1,12 @@ import type { DecoratorContext, DecoratorValidatorCallbacks, + Enum, + EnumMember, + Interface, Model, ModelProperty, + Namespace, Operation, } from "@typespec/compiler"; @@ -15,6 +19,14 @@ export interface ArmOperationOptions { readonly route?: string; } +export interface ArmFeatureOptions { + readonly featureName: string; + readonly fileName: string; + readonly description: string; + readonly title?: string; + readonly termsOfService?: string; +} + /** * This decorator is used on resources that do not satisfy the definition of a resource * but need to be identified as such. @@ -75,10 +87,49 @@ export type RenamePathParameterDecorator = ( targetParameterName: string, ) => DecoratorValidatorCallbacks | void; +/** + * Decorator to define a set of features + * + * @param target The service namespace + * @param features The enum that contains the features + */ +export type FeaturesDecorator = ( + context: DecoratorContext, + target: Namespace, + features: Enum, +) => DecoratorValidatorCallbacks | void; + +/** + * Decorator to define options for a specific feature + * + * @param target The enum member that represents the feature + * @param options The options for the feature + */ +export type FeatureOptionsDecorator = ( + context: DecoratorContext, + target: EnumMember, + options: ArmFeatureOptions, +) => DecoratorValidatorCallbacks | void; + +/** + * Decorator to associate a feature with a model, interface, or namespace + * + * @param target The target to associate the feature with + * @param featureName The feature to associate with the target + */ +export type FeatureDecorator = ( + context: DecoratorContext, + target: Model | Interface | Namespace, + featureName: EnumMember, +) => DecoratorValidatorCallbacks | void; + export type AzureResourceManagerLegacyDecorators = { customAzureResource: CustomAzureResourceDecorator; externalTypeRef: ExternalTypeRefDecorator; armOperationRoute: ArmOperationRouteDecorator; armExternalType: ArmExternalTypeDecorator; renamePathParameter: RenamePathParameterDecorator; + features: FeaturesDecorator; + featureOptions: FeatureOptionsDecorator; + feature: FeatureDecorator; }; diff --git a/packages/typespec-azure-resource-manager/lib/legacy-types/legacy.decorators.tsp b/packages/typespec-azure-resource-manager/lib/legacy-types/legacy.decorators.tsp index 9b7b55db4f..1978a809bb 100644 --- a/packages/typespec-azure-resource-manager/lib/legacy-types/legacy.decorators.tsp +++ b/packages/typespec-azure-resource-manager/lib/legacy-types/legacy.decorators.tsp @@ -57,3 +57,44 @@ extern dec renamePathParameter( sourceParameterName: valueof string, targetParameterName: valueof string ); + +/** + * Options for defining a feature and the associated file + */ +model ArmFeatureOptions { + /** The feature name */ + featureName: string; + + /** The associated file name for the features */ + fileName: string; + + /** The feature description in Swagger */ + description: string; + + /** The feature title in Swagger */ + title?: string; + + /** The feature terms of service in Swagger */ + termsOfService?: string; +} + +/** + * Decorator to define a set of features + * @param target The service namespace + * @param features The enum that contains the features + */ +extern dec features(target: Namespace, features: Enum); + +/** + * Decorator to define options for a specific feature + * @param target The enum member that represents the feature + * @param options The options for the feature + */ +extern dec featureOptions(target: EnumMember, options: valueof ArmFeatureOptions); + +/** + * Decorator to associate a feature with a model, interface, or namespace + * @param target The target to associate the feature with + * @param featureName The feature to associate with the target + */ +extern dec feature(target: Model | Interface | Namespace, featureName: EnumMember); diff --git a/packages/typespec-azure-resource-manager/src/resource.ts b/packages/typespec-azure-resource-manager/src/resource.ts index 348625bbe6..8c5d35cc48 100644 --- a/packages/typespec-azure-resource-manager/src/resource.ts +++ b/packages/typespec-azure-resource-manager/src/resource.ts @@ -4,6 +4,8 @@ import { ArrayModelType, getProperty as compilerGetProperty, DecoratorContext, + Enum, + EnumMember, getKeyName, getNamespaceFullName, getTags, @@ -25,7 +27,7 @@ import { useStateMap } from "@typespec/compiler/utils"; import { getHttpOperation, isPathParam } from "@typespec/http"; import { $autoRoute, getParentResource, getSegment } from "@typespec/rest"; -import { pascalCase } from "change-case"; +import { camelCase, pascalCase } from "change-case"; import { ArmProviderNameValueDecorator, ArmResourceOperationsDecorator, @@ -42,8 +44,12 @@ import { } from "../generated-defs/Azure.ResourceManager.js"; import { ArmExternalTypeDecorator, + ArmFeatureOptions, CustomAzureResourceDecorator, CustomResourceOptions, + FeatureDecorator, + FeatureOptionsDecorator, + FeaturesDecorator, } from "../generated-defs/Azure.ResourceManager.Legacy.js"; import { reportDiagnostic } from "./lib.js"; import { @@ -1285,3 +1291,152 @@ export function resolveResourceBaseType(type?: string | undefined): ResourceBase } return resolvedType; } + +export const [getResourceFeature, setResourceFeature] = useStateMap< + Model | Interface | Namespace, + EnumMember +>(ArmStateKeys.armFeature); + +export const [getResourceFeatureSet, setResourceFeatureSet] = useStateMap< + Namespace, + Map +>(ArmStateKeys.armFeatureSet); + +export const [getResourceFeatureOptions, setResourceFeatureOptions] = useStateMap< + EnumMember, + ArmFeatureOptions +>(ArmStateKeys.armFeatureOptions); + +const commonFeatureOptions: ArmFeatureOptions = { + featureName: "Common", + fileName: "common", + description: "", +}; +export function getFeatureOptions(program: Program, feature: EnumMember): ArmFeatureOptions { + const defaultFeatureName: string = (feature.value ?? feature.name) as string; + const defaultOptions: ArmFeatureOptions = { + featureName: defaultFeatureName, + fileName: camelCase(defaultFeatureName), + description: "", + }; + return program.stateMap(ArmStateKeys.armFeatureOptions).get(feature) ?? defaultOptions; +} + +/** + * Get the FeatureOptions for a given type, these could be inherited from the namespace or parent type + * @param program - The program to process. + * @param entity - The type entity to get feature options for. + * @returns The ArmFeatureOptions if found, otherwise undefined. + */ +export function getFeature(program: Program, entity: Type): ArmFeatureOptions { + switch (entity.kind) { + case "Namespace": { + const feature = getResourceFeature(program, entity); + if (feature === undefined) return commonFeatureOptions; + const options = getFeatureOptions(program, feature); + return options; + } + case "Interface": { + let feature = getResourceFeature(program, entity); + if (feature !== undefined) return getFeatureOptions(program, feature); + const namespace = entity.namespace; + if (namespace === undefined) return commonFeatureOptions; + feature = getResourceFeature(program, namespace); + if (feature === undefined) return commonFeatureOptions; + return getFeatureOptions(program, feature); + } + case "Model": { + let feature = getResourceFeature(program, entity); + if (feature !== undefined) return getFeatureOptions(program, feature); + if (isTemplateInstance(entity)) { + for (const arg of entity.templateMapper.args) { + if (arg.entityKind === "Type" && arg.kind === "Model") { + const options = getFeature(program, arg); + if (options !== commonFeatureOptions) return options; + } + } + } + const namespace = entity.namespace; + if (namespace === undefined) return commonFeatureOptions; + feature = getResourceFeature(program, namespace); + if (feature === undefined) return commonFeatureOptions; + return getFeatureOptions(program, feature); + } + case "Operation": { + const opInterface = entity.interface; + if (opInterface !== undefined) { + return getFeature(program, opInterface); + } + const namespace = entity.namespace; + if (namespace === undefined) return commonFeatureOptions; + const feature = getResourceFeature(program, namespace); + if (feature === undefined) return commonFeatureOptions; + return getFeatureOptions(program, feature); + } + case "EnumMember": { + return getFeature(program, entity.enum); + } + case "UnionVariant": { + return getFeature(program, entity.union); + } + case "ModelProperty": { + if (entity.model === undefined) return commonFeatureOptions; + return getFeature(program, entity.model); + } + case "Enum": + case "Union": + case "Scalar": { + const namespace = entity.namespace; + if (namespace === undefined) return commonFeatureOptions; + const feature = getResourceFeature(program, namespace); + if (feature === undefined) return commonFeatureOptions; + return getFeatureOptions(program, feature); + } + + default: + return commonFeatureOptions; + } +} + +export const $feature: FeatureDecorator = ( + context: DecoratorContext, + entity: Model | Interface | Namespace, + featureName: EnumMember, +) => { + const { program } = context; + setResourceFeature(program, entity, featureName); +}; + +export const $features: FeaturesDecorator = ( + context: DecoratorContext, + entity: Namespace, + features: Enum, +) => { + const { program } = context; + let featureMap: Map | undefined = getResourceFeatureSet( + program, + entity, + ); + if (featureMap !== undefined) { + return; + } + featureMap = new Map(); + + for (const member of features.members.values()) { + const options = getFeatureOptions(program, member); // Ensure defaults are created + featureMap.set(options.featureName, options); + } + const common = [...featureMap.keys()].some((k) => k.toLowerCase() === "common"); + if (!common) { + featureMap.set("Common", commonFeatureOptions); + } + setResourceFeatureSet(program, entity, featureMap); +}; + +export const $featureOptions: FeatureOptionsDecorator = ( + context: DecoratorContext, + entity: EnumMember, + options: ArmFeatureOptions, +) => { + setResourceFeatureOptions(context.program, entity, options); +}; diff --git a/packages/typespec-azure-resource-manager/src/state.ts b/packages/typespec-azure-resource-manager/src/state.ts index fbd2dbe21c..23d1b3e6ba 100644 --- a/packages/typespec-azure-resource-manager/src/state.ts +++ b/packages/typespec-azure-resource-manager/src/state.ts @@ -27,6 +27,9 @@ export const ArmStateKeys = { resourceBaseType: azureResourceManagerCreateStateSymbol("resourceBaseTypeKey"), armBuiltInResource: azureResourceManagerCreateStateSymbol("armExternalResource"), customAzureResource: azureResourceManagerCreateStateSymbol("azureCustomResource"), + armFeature: azureResourceManagerCreateStateSymbol("armFeature"), + armFeatureSet: azureResourceManagerCreateStateSymbol("armFeatureSet"), + armFeatureOptions: azureResourceManagerCreateStateSymbol("armFeatureOptions"), // private.decorator.ts azureResourceBase: azureResourceManagerCreateStateSymbol("azureResourceBase"), diff --git a/packages/typespec-azure-resource-manager/src/tsp-index.ts b/packages/typespec-azure-resource-manager/src/tsp-index.ts index 44cf12e3eb..443f4063e7 100644 --- a/packages/typespec-azure-resource-manager/src/tsp-index.ts +++ b/packages/typespec-azure-resource-manager/src/tsp-index.ts @@ -21,6 +21,9 @@ import { $armVirtualResource, $customAzureResource, $extensionResource, + $feature, + $featureOptions, + $features, $identifiers, $locationResource, $resourceBaseType, @@ -65,5 +68,8 @@ export const $decorators = { armOperationRoute: $armOperationRoute, armExternalType: $armExternalType, renamePathParameter: $renamePathParameter, + feature: $feature, + features: $features, + featureOptions: $featureOptions, } satisfies AzureResourceManagerLegacyDecorators, }; diff --git a/packages/typespec-azure-resource-manager/test/resource.test.ts b/packages/typespec-azure-resource-manager/test/resource.test.ts index 145e503f79..9a929956b8 100644 --- a/packages/typespec-azure-resource-manager/test/resource.test.ts +++ b/packages/typespec-azure-resource-manager/test/resource.test.ts @@ -4,7 +4,13 @@ import { getHttpOperation } from "@typespec/http"; import { ok, strictEqual } from "assert"; import { describe, expect, it } from "vitest"; import { ArmLifecycleOperationKind } from "../src/operations.js"; -import { ArmResourceDetails, getArmResources } from "../src/resource.js"; +import { + ArmResourceDetails, + getArmResources, + getFeature, + getResourceFeature, + getResourceFeatureSet, +} from "../src/resource.js"; import { Tester } from "./tester.js"; function assertLifecycleOperation( @@ -459,6 +465,248 @@ describe("ARM resource model:", () => { strictEqual((armIdProp?.type as Model).name, "armResourceIdentifier"); }); }); + describe("features support", () => { + it("sets standard features and feature options", async () => { + const [result, diagnostics] = await Tester.compileAndDiagnose(t.code` + +@Azure.ResourceManager.Legacy.features(Features) +@versioned(Versions) +@armProviderNamespace("Microsoft.Test") +namespace ${t.namespace("MSTest")}; +/** Contoso API versions */ +enum Versions { + /** 2021-10-01-preview version */ + v2025_11_19_preview: "2025-11-19-preview", +} +enum Features { + /** Feature A */ + FeatureA: "FeatureA", + /** Feature B */ + FeatureB: "FeatureB", +} + @Azure.ResourceManager.Legacy.feature(Features.FeatureA) + model ${t.model("FooResource")} is TrackedResource { + ...ResourceNameParameter; + } + model FooResourceProperties { + ...DefaultProvisioningStateProperty; + } + + @Azure.ResourceManager.Legacy.feature(Features.FeatureB) + model ${t.model("BarResource")} is ProxyResource { + ...ResourceNameParameter; + } + model BarResourceProperties { + ...DefaultProvisioningStateProperty; + } + `); + expectDiagnosticEmpty(diagnostics); + const features = getResourceFeatureSet(result.program, result.MSTest); + expect(features).toBeDefined(); + ok(features); + const keys = Array.from(features.keys()); + expect(keys).toEqual(["FeatureA", "FeatureB", "Common"]); + expect(features?.get("FeatureA")).toEqual({ + featureName: "FeatureA", + fileName: "featureA", + description: "", + }); + expect(features?.get("FeatureB")).toEqual({ + featureName: "FeatureB", + fileName: "featureB", + description: "", + }); + expect(features?.get("Common")).toEqual({ + featureName: "Common", + fileName: "common", + description: "", + }); + + const fooFeature = getResourceFeature(result.program, result.FooResource); + expect(fooFeature?.name).toMatch("FeatureA"); + const barFeature = getResourceFeature(result.program, result.BarResource); + expect(barFeature?.name).toMatch("FeatureB"); + }); + it("allows customizing features and feature options", async () => { + const [result, diagnostics] = await Tester.compileAndDiagnose(t.code` + +@Azure.ResourceManager.Legacy.features(Features) +@versioned(Versions) +@armProviderNamespace("Microsoft.Test") +namespace ${t.namespace("MSTest")}; +/** Contoso API versions */ +enum Versions { + /** 2021-10-01-preview version */ + v2025_11_19_preview: "2025-11-19-preview", +} +enum Features { + /** Feature A */ + @Azure.ResourceManager.Legacy.featureOptions(#{featureName: "FeatureA", fileName: "feature-a", description: "The data for feature A"}) + FeatureA: "Feature A", + /** Feature B */ + @Azure.ResourceManager.Legacy.featureOptions(#{featureName: "FeatureB", fileName: "feature-b", description: "The data for feature B"}) + FeatureB: "Feature B", + + /** Common feature */ + @Azure.ResourceManager.Legacy.featureOptions(#{featureName: "Common", fileName: "common", description: "The data in common for all features", title: "Common types for FeatureA and FeatureB", termsOfService: "MIT License"}) + Common: "Common", +} + @Azure.ResourceManager.Legacy.feature(Features.FeatureA) + model ${t.model("FooResource")} is TrackedResource { + ...ResourceNameParameter; + } + model FooResourceProperties { + ...DefaultProvisioningStateProperty; + } + + @Azure.ResourceManager.Legacy.feature(Features.FeatureB) + model ${t.model("BarResource")} is ProxyResource { + ...ResourceNameParameter; + } + model BarResourceProperties { + ...DefaultProvisioningStateProperty; + } + `); + expectDiagnosticEmpty(diagnostics); + const features = getResourceFeatureSet(result.program, result.MSTest); + expect(features).toBeDefined(); + ok(features); + const keys = Array.from(features.keys()); + expect(keys).toEqual(["FeatureA", "FeatureB", "Common"]); + expect(features?.get("FeatureA")).toEqual({ + featureName: "FeatureA", + fileName: "feature-a", + description: "The data for feature A", + }); + expect(features?.get("FeatureB")).toEqual({ + featureName: "FeatureB", + fileName: "feature-b", + description: "The data for feature B", + }); + expect(features?.get("Common")).toEqual({ + featureName: "Common", + fileName: "common", + description: "The data in common for all features", + title: "Common types for FeatureA and FeatureB", + termsOfService: "MIT License", + }); + + const fooFeature = getResourceFeature(result.program, result.FooResource); + expect(fooFeature?.name).toMatch("FeatureA"); + const barFeature = getResourceFeature(result.program, result.BarResource); + expect(barFeature?.name).toMatch("FeatureB"); + }); + it("reports correct features for child types", async () => { + const [result, diagnostics] = await Tester.compileAndDiagnose(t.code` + +@Azure.ResourceManager.Legacy.features(Features) +@versioned(Versions) +@armProviderNamespace("Microsoft.Test") +namespace ${t.namespace("MSTest")}; +/** Contoso API versions */ +enum Versions { + /** 2021-10-01-preview version */ + v2025_11_19_preview: "2025-11-19-preview", +} +enum Features { + /** Feature A */ + @Azure.ResourceManager.Legacy.featureOptions(#{featureName: "FeatureA", fileName: "feature-a", description: "The data for feature A"}) + FeatureA: "Feature A", + /** Feature B */ + @Azure.ResourceManager.Legacy.featureOptions(#{featureName: "FeatureB", fileName: "feature-b", description: "The data for feature B"}) + FeatureB: "Feature B", +} + @secret + scalar secretString extends string; + + @Azure.ResourceManager.Legacy.feature(Features.FeatureA) + model ${t.model("FooResource")} is TrackedResource { + ...ResourceNameParameter; + } + + @Azure.ResourceManager.Legacy.feature(Features.FeatureA) + model ${t.model("FooResourceProperties")} { + ...DefaultProvisioningStateProperty; + password: secretString; + } + + @Azure.ResourceManager.Legacy.feature(Features.FeatureB) + model ${t.model("BarResource")} is ProxyResource { + ...ResourceNameParameter; + } + model ${t.model("BarResourceProperties")} { + ...DefaultProvisioningStateProperty; + password: secretString; + } + + @Azure.ResourceManager.Legacy.feature(Features.FeatureA) + @armResourceOperations + interface ${t.interface("Foos")} extends Azure.ResourceManager.TrackedResourceOperations {} + + @Azure.ResourceManager.Legacy.feature(Features.FeatureB) + @armResourceOperations + interface ${t.interface("Bars")} extends Azure.ResourceManager.TrackedResourceOperations {} + `); + const featureAObject = { + featureName: "FeatureA", + fileName: "feature-a", + description: "The data for feature A", + }; + const featureBObject = { + featureName: "FeatureB", + fileName: "feature-b", + description: "The data for feature B", + }; + + const defaultObject = { + featureName: "Common", + fileName: "common", + description: "", + }; + expectDiagnosticEmpty(diagnostics); + const features = getResourceFeatureSet(result.program, result.MSTest); + expect(features).toBeDefined(); + ok(features); + const keys = Array.from(features.keys()); + expect(keys).toEqual(["FeatureA", "FeatureB", "Common"]); + expect(features?.get("FeatureA")).toEqual(featureAObject); + expect(features?.get("FeatureB")).toEqual(featureBObject); + expect(features?.get("Common")).toEqual(defaultObject); + + const fooFeature = getFeature(result.program, result.FooResource); + expect(fooFeature).toMatchObject(featureAObject); + const fooPropertiesFeature = getFeature(result.program, result.FooResourceProperties); + expect(fooPropertiesFeature).toMatchObject(featureAObject); + const fooPasswordProperty = result.FooResourceProperties.properties.get("password"); + expect(fooPasswordProperty).toBeDefined(); + const fooPasswordFeature = getFeature(result.program, fooPasswordProperty!); + expect(fooPasswordFeature).toMatchObject(featureAObject); + const fooPasswordTypeFeature = getFeature(result.program, fooPasswordProperty!.type); + expect(fooPasswordTypeFeature).toMatchObject(defaultObject); + const foosFeature = getFeature(result.program, result.Foos); + expect(foosFeature).toMatchObject(featureAObject); + for (const op of [...result.Foos.operations.values()]) { + const opFeature = getFeature(result.program, op); + expect(opFeature).toMatchObject(featureAObject); + } + const barFeature = getFeature(result.program, result.BarResource); + expect(barFeature).toMatchObject(featureBObject); + const barPropertiesFeature = getFeature(result.program, result.BarResourceProperties); + expect(barPropertiesFeature).toMatchObject(defaultObject); + const barPasswordProperty = result.BarResourceProperties.properties.get("password"); + expect(barPasswordProperty).toBeDefined(); + const barPasswordFeature = getFeature(result.program, barPasswordProperty!); + expect(barPasswordFeature).toMatchObject(defaultObject); + const barPasswordTypeFeature = getFeature(result.program, barPasswordProperty!.type); + expect(barPasswordTypeFeature).toMatchObject(defaultObject); + const barsFeature = getFeature(result.program, result.Bars); + expect(barsFeature).toMatchObject(featureBObject); + for (const op of [...result.Bars.operations.values()]) { + const opFeature = getFeature(result.program, op); + expect(opFeature).toMatchObject(featureBObject); + } + }); + }); describe("network security perimeter", () => { it("raises diagnostic when network security perimeter is used on default common-types version", async () => { const diagnostics = await Tester.diagnose(` diff --git a/website/src/content/docs/docs/emitters/typespec-autorest/reference/emitter.md b/website/src/content/docs/docs/emitters/typespec-autorest/reference/emitter.md index b66e66f061..8c24050680 100644 --- a/website/src/content/docs/docs/emitters/typespec-autorest/reference/emitter.md +++ b/website/src/content/docs/docs/emitters/typespec-autorest/reference/emitter.md @@ -156,3 +156,9 @@ Determine whether and how to emit schemas for common-types rather than referenci **Type:** `"xml-service" | "none"` Strategy for applying XML serialization metadata to schemas. + +### `output-splitting` + +**Type:** `"legacy-feature-files"` + +Determines whether output should be split into multiple files. The only supported option for splitting is "legacy-feature-files", which uses the typespec-azure-resource-manager `@feature` decorators to split into output files based on feature. diff --git a/website/src/content/docs/docs/libraries/azure-resource-manager/reference/data-types.md b/website/src/content/docs/docs/libraries/azure-resource-manager/reference/data-types.md index a4a0575963..cb1b3d8853 100644 --- a/website/src/content/docs/docs/libraries/azure-resource-manager/reference/data-types.md +++ b/website/src/content/docs/docs/libraries/azure-resource-manager/reference/data-types.md @@ -3406,6 +3406,24 @@ model Azure.ResourceManager.Foundations.TenantScope ## Azure.ResourceManager.Legacy +### `ArmFeatureOptions` {#Azure.ResourceManager.Legacy.ArmFeatureOptions} + +Options for defining a feature and the associated file + +```typespec +model Azure.ResourceManager.Legacy.ArmFeatureOptions +``` + +#### Properties + +| Name | Type | Description | +| --------------- | -------- | ----------------------------------------- | +| featureName | `string` | The feature name | +| fileName | `string` | The associated file name for the features | +| description | `string` | The feature description in Swagger | +| title? | `string` | The feature title in Swagger | +| termsOfService? | `string` | The feature terms of service in Swagger | + ### `ArmOperationOptions` {#Azure.ResourceManager.Legacy.ArmOperationOptions} Route options for an operation diff --git a/website/src/content/docs/docs/libraries/azure-resource-manager/reference/decorators.md b/website/src/content/docs/docs/libraries/azure-resource-manager/reference/decorators.md index 0ccd61923c..2acfc0b379 100644 --- a/website/src/content/docs/docs/libraries/azure-resource-manager/reference/decorators.md +++ b/website/src/content/docs/docs/libraries/azure-resource-manager/reference/decorators.md @@ -559,6 +559,63 @@ Specify an external reference that should be used when emitting this type. | ------- | ---------------- | ------------------------------------------------------------- | | jsonRef | `valueof string` | External reference(e.g. "../../common.json#/definitions/Foo") | +### `@feature` {#@Azure.ResourceManager.Legacy.feature} + +Decorator to associate a feature with a model, interface, or namespace + +```typespec +@Azure.ResourceManager.Legacy.feature(featureName: EnumMember) +``` + +#### Target + +The target to associate the feature with +`Model | Interface | Namespace` + +#### Parameters + +| Name | Type | Description | +| ----------- | ------------ | ---------------------------------------- | +| featureName | `EnumMember` | The feature to associate with the target | + +### `@featureOptions` {#@Azure.ResourceManager.Legacy.featureOptions} + +Decorator to define options for a specific feature + +```typespec +@Azure.ResourceManager.Legacy.featureOptions(options: valueof Azure.ResourceManager.Legacy.ArmFeatureOptions) +``` + +#### Target + +The enum member that represents the feature +`EnumMember` + +#### Parameters + +| Name | Type | Description | +| ------- | --------------------------------------------------------------------------------------------- | --------------------------- | +| options | [valueof `ArmFeatureOptions`](./data-types.md#Azure.ResourceManager.Legacy.ArmFeatureOptions) | The options for the feature | + +### `@features` {#@Azure.ResourceManager.Legacy.features} + +Decorator to define a set of features + +```typespec +@Azure.ResourceManager.Legacy.features(features: Enum) +``` + +#### Target + +The service namespace +`Namespace` + +#### Parameters + +| Name | Type | Description | +| -------- | ------ | ----------------------------------- | +| features | `Enum` | The enum that contains the features | + ### `@renamePathParameter` {#@Azure.ResourceManager.Legacy.renamePathParameter} Renames a path parameter in an Azure Resource Manager operation. diff --git a/website/src/content/docs/docs/libraries/azure-resource-manager/reference/index.mdx b/website/src/content/docs/docs/libraries/azure-resource-manager/reference/index.mdx index c726aa3f21..5b69e48fd9 100644 --- a/website/src/content/docs/docs/libraries/azure-resource-manager/reference/index.mdx +++ b/website/src/content/docs/docs/libraries/azure-resource-manager/reference/index.mdx @@ -326,6 +326,9 @@ npm install --save-peer @azure-tools/typespec-azure-resource-manager - [`@armOperationRoute`](./decorators.md#@Azure.ResourceManager.Legacy.armOperationRoute) - [`@customAzureResource`](./decorators.md#@Azure.ResourceManager.Legacy.customAzureResource) - [`@externalTypeRef`](./decorators.md#@Azure.ResourceManager.Legacy.externalTypeRef) +- [`@feature`](./decorators.md#@Azure.ResourceManager.Legacy.feature) +- [`@featureOptions`](./decorators.md#@Azure.ResourceManager.Legacy.featureOptions) +- [`@features`](./decorators.md#@Azure.ResourceManager.Legacy.features) - [`@renamePathParameter`](./decorators.md#@Azure.ResourceManager.Legacy.renamePathParameter) ### Interfaces @@ -348,6 +351,7 @@ npm install --save-peer @azure-tools/typespec-azure-resource-manager ### Models +- [`ArmFeatureOptions`](./data-types.md#Azure.ResourceManager.Legacy.ArmFeatureOptions) - [`ArmOperationOptions`](./data-types.md#Azure.ResourceManager.Legacy.ArmOperationOptions) - [`CustomResourceOptions`](./data-types.md#Azure.ResourceManager.Legacy.CustomResourceOptions) - [`DiscriminatedExtensionResource`](./data-types.md#Azure.ResourceManager.Legacy.DiscriminatedExtensionResource)