diff --git a/packages/typespec-ts/package.json b/packages/typespec-ts/package.json index 7de3662c42..804eba5f05 100644 --- a/packages/typespec-ts/package.json +++ b/packages/typespec-ts/package.json @@ -120,9 +120,9 @@ "@typespec/xml": "^0.82.0" }, "dependencies": { - "@azure-tools/rlc-common": "workspace:^0.53.1", "fast-xml-parser": "^4.5.0", "fs-extra": "^11.1.0", + "handlebars": "^4.7.7", "lodash": "^4.17.21", "prettier": "^3.3.3", "ts-morph": "^23.0.0", diff --git a/packages/typespec-ts/src/framework/hooks/sdkTypes.ts b/packages/typespec-ts/src/framework/hooks/sdkTypes.ts index 4ca4371113..835ae13a13 100644 --- a/packages/typespec-ts/src/framework/hooks/sdkTypes.ts +++ b/packages/typespec-ts/src/framework/hooks/sdkTypes.ts @@ -18,7 +18,7 @@ import { } from "../../modular/helpers/operationHelpers.js"; import { normalizeModelPropertyName } from "../../modular/type-expressions/get-type-expression.js"; import { reportDiagnostic } from "../../lib.js"; -import { NameType, normalizeName } from "@azure-tools/rlc-common"; +import { NameType, normalizeName } from "../../rlc-common/index.js"; export const emitQueue: Set = new Set(); export const flattenPropertyModelMap: Map = diff --git a/packages/typespec-ts/src/framework/load-static-helpers.ts b/packages/typespec-ts/src/framework/load-static-helpers.ts index c46ed7857d..9e53999a19 100644 --- a/packages/typespec-ts/src/framework/load-static-helpers.ts +++ b/packages/typespec-ts/src/framework/load-static-helpers.ts @@ -11,7 +11,7 @@ import { } from "ts-morph"; import { refkey } from "./refkey.js"; import { resolveProjectRoot } from "../utils/resolve-project-root.js"; -import { isAzurePackage } from "@azure-tools/rlc-common"; +import { isAzurePackage } from "../rlc-common/index.js"; import { ModularEmitterOptions } from "../modular/interfaces.js"; import { NoTarget, Program } from "@typespec/compiler"; import { reportDiagnostic } from "../lib.js"; diff --git a/packages/typespec-ts/src/index.ts b/packages/typespec-ts/src/index.ts index a42323d84e..1c1e4ee81e 100644 --- a/packages/typespec-ts/src/index.ts +++ b/packages/typespec-ts/src/index.ts @@ -68,7 +68,7 @@ import { buildSampleEnvFile, buildSnippets, buildTsSampleConfig -} from "@azure-tools/rlc-common"; +} from "./rlc-common/index.js"; import { buildRootIndex, buildSubClientIndexFile diff --git a/packages/typespec-ts/src/lib.ts b/packages/typespec-ts/src/lib.ts index 901eb7aee5..d8da921db9 100644 --- a/packages/typespec-ts/src/lib.ts +++ b/packages/typespec-ts/src/lib.ts @@ -6,7 +6,7 @@ import { DependencyInfo, ServiceInfo, PackageFlavor -} from "@azure-tools/rlc-common"; +} from "./rlc-common/index.js"; import { createTypeSpecLibrary, JSONSchemaType, diff --git a/packages/typespec-ts/src/metaTree.ts b/packages/typespec-ts/src/metaTree.ts index 3a14e99c26..46f2fd9fa9 100644 --- a/packages/typespec-ts/src/metaTree.ts +++ b/packages/typespec-ts/src/metaTree.ts @@ -1,4 +1,4 @@ -import { Schema as RlcType } from "@azure-tools/rlc-common"; +import { Schema as RlcType } from "./rlc-common/index.js"; import { Type } from "@typespec/compiler"; export interface RlcTypeMetadata { diff --git a/packages/typespec-ts/src/modular/buildClassicalClient.ts b/packages/typespec-ts/src/modular/buildClassicalClient.ts index c39a5e525c..d2cec77489 100644 --- a/packages/typespec-ts/src/modular/buildClassicalClient.ts +++ b/packages/typespec-ts/src/modular/buildClassicalClient.ts @@ -6,7 +6,7 @@ import { StructureKind } from "ts-morph"; import { ModularEmitterOptions } from "./interfaces.js"; -import { NameType, normalizeName } from "@azure-tools/rlc-common"; +import { NameType, normalizeName } from "../rlc-common/index.js"; import { buildUserAgentOptions, getClientParametersDeclaration diff --git a/packages/typespec-ts/src/modular/buildClassicalOperationGroups.ts b/packages/typespec-ts/src/modular/buildClassicalOperationGroups.ts index c55a2687ba..2b461683ee 100644 --- a/packages/typespec-ts/src/modular/buildClassicalOperationGroups.ts +++ b/packages/typespec-ts/src/modular/buildClassicalOperationGroups.ts @@ -1,4 +1,4 @@ -import { NameType } from "@azure-tools/rlc-common"; +import { NameType } from "../rlc-common/index.js"; import { SourceFile } from "ts-morph"; import { getClassicalOperation } from "./helpers/classicalOperationHelpers.js"; import { getClassicalLayerPrefix } from "./helpers/namingHelpers.js"; diff --git a/packages/typespec-ts/src/modular/buildClientContext.ts b/packages/typespec-ts/src/modular/buildClientContext.ts index 7df6532334..1e007381da 100644 --- a/packages/typespec-ts/src/modular/buildClientContext.ts +++ b/packages/typespec-ts/src/modular/buildClientContext.ts @@ -3,7 +3,7 @@ import { NameType, isAzurePackage, normalizeName -} from "@azure-tools/rlc-common"; +} from "../rlc-common/index.js"; import { buildGetClientCredentialParam, buildGetClientEndpointParam, diff --git a/packages/typespec-ts/src/modular/buildOperations.ts b/packages/typespec-ts/src/modular/buildOperations.ts index e64a66ad86..15f62927a3 100644 --- a/packages/typespec-ts/src/modular/buildOperations.ts +++ b/packages/typespec-ts/src/modular/buildOperations.ts @@ -1,5 +1,5 @@ import { ModularEmitterOptions } from "./interfaces.js"; -import { NameType, normalizeName } from "@azure-tools/rlc-common"; +import { NameType, normalizeName } from "../rlc-common/index.js"; import { SourceFile, InterfaceDeclarationStructure, diff --git a/packages/typespec-ts/src/modular/buildProjectFiles.ts b/packages/typespec-ts/src/modular/buildProjectFiles.ts index 70814bc6a4..c8d89c6da3 100644 --- a/packages/typespec-ts/src/modular/buildProjectFiles.ts +++ b/packages/typespec-ts/src/modular/buildProjectFiles.ts @@ -1,4 +1,4 @@ -import { NameType } from "@azure-tools/rlc-common"; +import { NameType } from "../rlc-common/index.js"; import { ModularEmitterOptions } from "./interfaces.js"; import { getClassicalLayerPrefix } from "./helpers/namingHelpers.js"; diff --git a/packages/typespec-ts/src/modular/buildRestorePoller.ts b/packages/typespec-ts/src/modular/buildRestorePoller.ts index 4c7d120ca5..0df70594d6 100644 --- a/packages/typespec-ts/src/modular/buildRestorePoller.ts +++ b/packages/typespec-ts/src/modular/buildRestorePoller.ts @@ -4,7 +4,7 @@ import { ModularEmitterOptions } from "./interfaces.js"; import path from "path"; import { buildLroDeserDetailMap } from "./buildOperations.js"; import { getClassicalClientName } from "./helpers/namingHelpers.js"; -import { NameType, normalizeName } from "@azure-tools/rlc-common"; +import { NameType, normalizeName } from "../rlc-common/index.js"; import { resolveReference } from "../framework/reference.js"; import { AzurePollingDependencies } from "./external-dependencies.js"; import { PollingHelpers } from "./static-helpers-metadata.js"; diff --git a/packages/typespec-ts/src/modular/buildRootIndex.ts b/packages/typespec-ts/src/modular/buildRootIndex.ts index 58a80b8eb9..098b7c4ca5 100644 --- a/packages/typespec-ts/src/modular/buildRootIndex.ts +++ b/packages/typespec-ts/src/modular/buildRootIndex.ts @@ -2,7 +2,7 @@ import { NameType, normalizeName, isAzurePackage -} from "@azure-tools/rlc-common"; +} from "../rlc-common/index.js"; import { Project, SourceFile } from "ts-morph"; import { getClassicalClientName } from "./helpers/namingHelpers.js"; import { ModularEmitterOptions } from "./interfaces.js"; diff --git a/packages/typespec-ts/src/modular/emitModels.ts b/packages/typespec-ts/src/modular/emitModels.ts index 34d59c29be..75bba73488 100644 --- a/packages/typespec-ts/src/modular/emitModels.ts +++ b/packages/typespec-ts/src/modular/emitModels.ts @@ -12,7 +12,7 @@ import { fixLeadingNumber, NameType, normalizeName -} from "@azure-tools/rlc-common"; +} from "../rlc-common/index.js"; import { SdkArrayType, SdkModelPropertyType, diff --git a/packages/typespec-ts/src/modular/emitModelsOptions.ts b/packages/typespec-ts/src/modular/emitModelsOptions.ts index edf9336b47..78fb55a8ad 100644 --- a/packages/typespec-ts/src/modular/emitModelsOptions.ts +++ b/packages/typespec-ts/src/modular/emitModelsOptions.ts @@ -10,7 +10,7 @@ import { } from "@azure-tools/typespec-client-generator-core"; import { getMethodHierarchiesMap } from "../utils/operationUtil.js"; import { getModularClientOptions } from "../utils/clientUtils.js"; -import { NameType, normalizeName } from "@azure-tools/rlc-common"; +import { NameType, normalizeName } from "../rlc-common/index.js"; import { useContext } from "../contextManager.js"; // ====== UTILITIES ====== diff --git a/packages/typespec-ts/src/modular/emitSamples.ts b/packages/typespec-ts/src/modular/emitSamples.ts index 466d3982ff..613b47275e 100644 --- a/packages/typespec-ts/src/modular/emitSamples.ts +++ b/packages/typespec-ts/src/modular/emitSamples.ts @@ -19,7 +19,7 @@ import { isAzurePackage, NameType, normalizeName -} from "@azure-tools/rlc-common"; +} from "../rlc-common/index.js"; import { useContext } from "../contextManager.js"; import { join } from "path"; import { AzureIdentityDependencies } from "../modular/external-dependencies.js"; diff --git a/packages/typespec-ts/src/modular/emitTests.ts b/packages/typespec-ts/src/modular/emitTests.ts index f786b5b61c..415f760044 100644 --- a/packages/typespec-ts/src/modular/emitTests.ts +++ b/packages/typespec-ts/src/modular/emitTests.ts @@ -1,6 +1,6 @@ import { SourceFile } from "ts-morph"; import { SdkContext } from "../utils/interfaces.js"; -import { NameType, normalizeName } from "@azure-tools/rlc-common"; +import { NameType, normalizeName } from "../rlc-common/index.js"; import { join } from "path"; import { existsSync, rmSync } from "fs"; import { getClassicalClientName } from "./helpers/namingHelpers.js"; diff --git a/packages/typespec-ts/src/modular/helpers/classicalOperationHelpers.ts b/packages/typespec-ts/src/modular/helpers/classicalOperationHelpers.ts index a76ab94531..1b2d07339a 100644 --- a/packages/typespec-ts/src/modular/helpers/classicalOperationHelpers.ts +++ b/packages/typespec-ts/src/modular/helpers/classicalOperationHelpers.ts @@ -1,4 +1,4 @@ -import { NameType, normalizeName } from "@azure-tools/rlc-common"; +import { NameType, normalizeName } from "../../rlc-common/index.js"; import { FunctionDeclarationStructure, InterfaceDeclarationStructure, diff --git a/packages/typespec-ts/src/modular/helpers/clientHelpers.ts b/packages/typespec-ts/src/modular/helpers/clientHelpers.ts index 13fccedf87..4edfebb1ed 100644 --- a/packages/typespec-ts/src/modular/helpers/clientHelpers.ts +++ b/packages/typespec-ts/src/modular/helpers/clientHelpers.ts @@ -17,7 +17,7 @@ import { NameType, normalizeName, PackageFlavor -} from "@azure-tools/rlc-common"; +} from "../../rlc-common/index.js"; import { SdkContext } from "../../utils/interfaces.js"; import { getClassicalClientName } from "./namingHelpers.js"; import { getTypeExpression } from "../type-expressions/get-type-expression.js"; diff --git a/packages/typespec-ts/src/modular/helpers/exampleValueHelpers.ts b/packages/typespec-ts/src/modular/helpers/exampleValueHelpers.ts index 2f78373366..d21a4fe58f 100644 --- a/packages/typespec-ts/src/modular/helpers/exampleValueHelpers.ts +++ b/packages/typespec-ts/src/modular/helpers/exampleValueHelpers.ts @@ -12,7 +12,7 @@ import { isAzurePackage, NameType, normalizeName -} from "@azure-tools/rlc-common"; +} from "../../rlc-common/index.js"; import { resolveReference } from "../../framework/reference.js"; import { SdkContext } from "../../utils/interfaces.js"; import { diff --git a/packages/typespec-ts/src/modular/helpers/namingHelpers.ts b/packages/typespec-ts/src/modular/helpers/namingHelpers.ts index 61225263be..68e3da8006 100644 --- a/packages/typespec-ts/src/modular/helpers/namingHelpers.ts +++ b/packages/typespec-ts/src/modular/helpers/namingHelpers.ts @@ -2,7 +2,7 @@ import { NameType, normalizeName, ReservedModelNames -} from "@azure-tools/rlc-common"; +} from "../../rlc-common/index.js"; import { SdkClientType, SdkServiceOperation diff --git a/packages/typespec-ts/src/modular/helpers/operationHelpers.ts b/packages/typespec-ts/src/modular/helpers/operationHelpers.ts index f96c4edca3..97d36978d5 100644 --- a/packages/typespec-ts/src/modular/helpers/operationHelpers.ts +++ b/packages/typespec-ts/src/modular/helpers/operationHelpers.ts @@ -48,7 +48,7 @@ import { getFixmeForMultilineDocs } from "./docsHelpers.js"; import { AzurePollingDependencies } from "../external-dependencies.js"; -import { NameType, normalizeName } from "@azure-tools/rlc-common"; +import { NameType, normalizeName } from "../../rlc-common/index.js"; import { buildModelDeserializer, buildPropertyDeserializer diff --git a/packages/typespec-ts/src/modular/helpers/typeHelpers.ts b/packages/typespec-ts/src/modular/helpers/typeHelpers.ts index 123aca79ee..3b0a8fdd75 100644 --- a/packages/typespec-ts/src/modular/helpers/typeHelpers.ts +++ b/packages/typespec-ts/src/modular/helpers/typeHelpers.ts @@ -1,4 +1,4 @@ -import { NameType, normalizeName } from "@azure-tools/rlc-common"; +import { NameType, normalizeName } from "../../rlc-common/index.js"; import { SdkBodyParameter, SdkCredentialParameter, diff --git a/packages/typespec-ts/src/modular/interfaces.ts b/packages/typespec-ts/src/modular/interfaces.ts index a1bb327070..3567349de0 100644 --- a/packages/typespec-ts/src/modular/interfaces.ts +++ b/packages/typespec-ts/src/modular/interfaces.ts @@ -1,4 +1,4 @@ -import { RLCOptions } from "@azure-tools/rlc-common"; +import { RLCOptions } from "../rlc-common/index.js"; export interface ModularOptions { sourceRoot: string; diff --git a/packages/typespec-ts/src/modular/serialization/buildDeserializerFunction.ts b/packages/typespec-ts/src/modular/serialization/buildDeserializerFunction.ts index ca1fc16134..3bab18c75c 100644 --- a/packages/typespec-ts/src/modular/serialization/buildDeserializerFunction.ts +++ b/packages/typespec-ts/src/modular/serialization/buildDeserializerFunction.ts @@ -19,7 +19,7 @@ import { getAdditionalPropertiesName, normalizeModelName } from "../emitModels.js"; -import { NameType, normalizeName } from "@azure-tools/rlc-common"; +import { NameType, normalizeName } from "../../rlc-common/index.js"; import { isAzureCoreErrorType } from "../../utils/modelUtils.js"; import { getAllDiscriminatedValues, diff --git a/packages/typespec-ts/src/modular/serialization/buildSerializerFunction.ts b/packages/typespec-ts/src/modular/serialization/buildSerializerFunction.ts index e4ef5968a3..51ed4dc512 100644 --- a/packages/typespec-ts/src/modular/serialization/buildSerializerFunction.ts +++ b/packages/typespec-ts/src/modular/serialization/buildSerializerFunction.ts @@ -20,7 +20,7 @@ import { getAdditionalPropertiesName, normalizeModelName } from "../emitModels.js"; -import { NameType, normalizeName } from "@azure-tools/rlc-common"; +import { NameType, normalizeName } from "../../rlc-common/index.js"; import { isAzureCoreErrorType } from "../../utils/modelUtils.js"; import { getAllDiscriminatedValues, diff --git a/packages/typespec-ts/src/modular/serialization/buildXmlSerializerFunction.ts b/packages/typespec-ts/src/modular/serialization/buildXmlSerializerFunction.ts index b2dbb4719c..33243e9a78 100644 --- a/packages/typespec-ts/src/modular/serialization/buildXmlSerializerFunction.ts +++ b/packages/typespec-ts/src/modular/serialization/buildXmlSerializerFunction.ts @@ -18,7 +18,7 @@ import { normalizeModelName, getAdditionalPropertiesName } from "../emitModels.js"; -import { NameType } from "@azure-tools/rlc-common"; +import { NameType } from "../../rlc-common/index.js"; import { isAzureCoreErrorType } from "../../utils/modelUtils.js"; import { isSupportedSerializeType, diff --git a/packages/typespec-ts/src/modular/type-expressions/get-type-expression.ts b/packages/typespec-ts/src/modular/type-expressions/get-type-expression.ts index b4ab31f794..03539dafdd 100644 --- a/packages/typespec-ts/src/modular/type-expressions/get-type-expression.ts +++ b/packages/typespec-ts/src/modular/type-expressions/get-type-expression.ts @@ -8,7 +8,7 @@ import { getCredentialExpression } from "./get-credential-expression.js"; import { getEnumExpression } from "./get-enum-expression.js"; import { getModelExpression } from "./get-model-expression.js"; import { getUnionExpression } from "./get-union-expression.js"; -import { NameType, normalizeName } from "@azure-tools/rlc-common"; +import { NameType, normalizeName } from "../../rlc-common/index.js"; import { SdkContext } from "../../utils/interfaces.js"; import { getNullableExpression } from "./get-nullable-expression.js"; diff --git a/packages/typespec-ts/src/rlc-common/buildClient.ts b/packages/typespec-ts/src/rlc-common/buildClient.ts new file mode 100644 index 0000000000..e13d8b5c8b --- /dev/null +++ b/packages/typespec-ts/src/rlc-common/buildClient.ts @@ -0,0 +1,561 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { + InterfaceDeclarationStructure, + OptionalKind, + Project, + StatementStructures, + StructureKind, + VariableDeclarationKind, + VariableStatementStructure, + WriterFunction +} from "ts-morph"; +import * as path from "path"; +import { NameType, normalizeName } from "./helpers/nameUtils.js"; +import { buildMethodShortcutImplementation } from "./buildMethodShortcuts.js"; +import { RLCModel, File, PathParameter } from "./interfaces.js"; +import { + getClientName, + getImportModuleName +} from "./helpers/nameConstructors.js"; +import { getImportSpecifier } from "./helpers/importsUtil.js"; +import { isAzurePackage } from "./helpers/packageUtil.js"; + +function getClientOptionsInterface( + model: RLCModel, + clientName: string, + optionalUrlParameters?: PathParameter[] +): OptionalKind | undefined { + if ( + (!optionalUrlParameters || optionalUrlParameters.length === 0) && + !model.apiVersionInfo + ) { + return undefined; + } + + const properties = + optionalUrlParameters?.map((param) => { + return { + name: param.name, + type: param.type, + hasQuestionToken: true, + docs: [ + param.description ?? "client level optional parameter " + param.name + ] + }; + }) ?? []; + + if ( + model.apiVersionInfo?.isCrossedVersion === false && + !model.urlInfo?.urlParameters?.find((p) => p.name === "apiVersion") && + (model.apiVersionInfo.defaultValue || !model.apiVersionInfo?.required) + ) { + properties.push({ + name: "apiVersion", + type: "string", + hasQuestionToken: true, + docs: ["The api version option of the client"] + }); + } + return { + name: `${clientName}Options`, + extends: ["ClientOptions"], + isExported: true, + properties, + docs: ["The optional parameters for the client"] + }; +} + +export function buildClient(model: RLCModel): File | undefined { + const name = normalizeName(model.libraryName, NameType.File); + const { srcPath } = model; + const project = new Project(); + const filePath = path.join(srcPath, `${name}.ts`); + const clientFile = project.createSourceFile(filePath, undefined, { + overwrite: true + }); + + // Get all paths + const clientInterfaceName = getClientName(model); + + normalizeUrlInfo(model); + const urlParameters = model?.urlInfo?.urlParameters?.filter( + // Do not include parameters with constant values in the signature, these should go in the options bag + (p) => p.value === undefined + ); + + const optionalUrlParameters = model?.urlInfo?.urlParameters?.filter( + // Do not include parameters with constant values in the signature, these should go in the options bag + (p) => Boolean(p.value) + ); + + const clientOptionsInterface = getClientOptionsInterface( + model, + clientInterfaceName, + optionalUrlParameters + ); + + if (clientOptionsInterface) { + clientFile.addInterface(clientOptionsInterface); + } + + if (!model.options) { + return undefined; + } + const { multiClient, batch } = model.options; + const { + addCredentials, + credentialScopes, + credentialKeyHeaderName, + customHttpAuthHeaderName, + customHttpAuthSharedKeyPrefix + } = model.options; + const credentialTypes = credentialScopes ? ["TokenCredential"] : []; + + if (credentialKeyHeaderName || customHttpAuthHeaderName) { + credentialTypes.push("KeyCredential"); + } + + const commonClientParams = [ + ...(urlParameters ?? []), + ...(addCredentials === false || + !isSecurityInfoDefined( + credentialScopes, + credentialKeyHeaderName, + customHttpAuthHeaderName + ) + ? [] + : [ + { + name: "credentials", + type: credentialTypes.join(" | "), + description: `uniquely identify client credential` + } + ]) + ]; + + let apiVersionStatement: string = ""; + // Set the default api-version when we have a default AND its position is query + if ( + model.apiVersionInfo?.isCrossedVersion === false && + !!model.apiVersionInfo?.defaultValue + ) { + apiVersionStatement = ` + apiVersion = "${model.apiVersionInfo?.defaultValue}"`; + } else if ( + model.apiVersionInfo?.isCrossedVersion === false && + !model.apiVersionInfo.required + ) { + apiVersionStatement = ` + apiVersion`; + } + + const allClientParams = [ + ...commonClientParams, + { + name: + apiVersionStatement === "" + ? "options" + : `{${apiVersionStatement}, ...options}`, + documentName: "options", + type: `${clientOptionsInterface?.name ?? "ClientOptions"} = {}`, + description: "the parameter for all optional parameters" + } + ]; + const functionStatement = { + isExported: true, + name: `createClient`, + parameters: allClientParams, + docs: [ + { + description: + `Initialize a new instance of \`${clientInterfaceName}\`\n` + + allClientParams + .map((param) => { + return `@param ${param.documentName ?? param.name} - ${ + param.description ?? "The parameter " + param.name + }`; + }) + .join("\n") + } + ], + returnType: clientInterfaceName, + isDefaultExport: false, + statements: getClientFactoryBody(model, clientInterfaceName, { + isMultipleCredential: credentialTypes.length > 1 + }) + }; + + if (!multiClient || !batch || batch.length === 1) { + functionStatement.isDefaultExport = true; + } + clientFile.addFunction(functionStatement); + + const paths = srcPath.replace(/\//g, path.sep).split(path.sep); + while (paths.length > 0 && paths[paths.length - 1] === "") { + paths.pop(); + } + const parentPath = + paths.lastIndexOf("src") > -1 + ? paths.length - 1 - paths.lastIndexOf("src") + : 0; + + const loggerPath = `${ + parentPath > 0 ? "../".repeat(parentPath) : "./" + }logger`; + clientFile.addImportDeclarations([ + { + isTypeOnly: true, + namedImports: ["ClientOptions"], + moduleSpecifier: getImportSpecifier( + "restClient", + model.importInfo.runtimeImports + ) + } + ]); + clientFile.addImportDeclarations([ + { + namedImports: ["getClient"], + moduleSpecifier: getImportSpecifier( + "restClient", + model.importInfo.runtimeImports + ) + } + ]); + if (isAzurePackage(model)) { + clientFile.addImportDeclarations([ + { + namedImports: ["logger"], + moduleSpecifier: getImportModuleName( + { + cjsName: loggerPath, + esModulesName: `${loggerPath}.js` + }, + model + ) + } + ]); + } + + const includeKeyCredentialHelper = + customHttpAuthHeaderName && + customHttpAuthSharedKeyPrefix && + credentialTypes.length > 1 && + credentialTypes.includes("KeyCredential"); + if ( + addCredentials && + isSecurityInfoDefined( + credentialScopes, + credentialKeyHeaderName, + customHttpAuthHeaderName + ) + ) { + clientFile.addImportDeclarations([ + { + isTypeOnly: true, + namedImports: credentialTypes, + moduleSpecifier: getImportSpecifier( + "coreAuth", + model.importInfo.runtimeImports + ) + } + ]); + if (includeKeyCredentialHelper) { + clientFile.addImportDeclarations([ + { + namedImports: ["isKeyCredential"], + moduleSpecifier: getImportSpecifier( + "coreAuth", + model.importInfo.runtimeImports + ) + } + ]); + } + } + clientFile.addImportDeclarations([ + { + isTypeOnly: true, + namedImports: [`${clientInterfaceName}`], + moduleSpecifier: getImportModuleName( + { + cjsName: "./clientDefinitions", + esModulesName: "./clientDefinitions.js" + }, + model + ) + } + ]); + if ( + (model.importInfo.internalImports?.rlcClientFactory?.importsSet?.size ?? + 0) > 0 + ) { + clientFile.addImportDeclarations([ + { + isTypeOnly: true, + namedImports: Array.from( + model.importInfo.internalImports.rlcClientFactory.importsSet! + ), + moduleSpecifier: getImportModuleName( + { + cjsName: `./models`, + esModulesName: `./models.js` + }, + model + ) + } + ]); + } + return { path: filePath, content: clientFile.getFullText() }; +} + +function isSecurityInfoDefined( + credentialScopes?: string[], + credentialKeyHeaderName?: string, + customHttpAuthHeaderName?: string +) { + return ( + credentialScopes || credentialKeyHeaderName || customHttpAuthHeaderName + ); +} + +interface GetClientFactoryOptions { + isMultipleCredential: boolean; +} + +export function getClientFactoryBody( + model: RLCModel, + clientTypeName: string, + options: GetClientFactoryOptions = { isMultipleCredential: false } +): string | WriterFunction | (string | WriterFunction | StatementStructures)[] { + if (!model.options || !model.options.packageDetails || !model.urlInfo) { + return ""; + } + const { includeShortcuts, packageDetails, addCredentials } = model.options; + let clientPackageName = + packageDetails!.nameWithoutScope ?? packageDetails?.name ?? ""; + const packageVersion = packageDetails.version; + const { endpoint, urlParameters } = model.urlInfo; + + const optionalUrlParameters: string[] = []; + + for (const param of urlParameters ?? []) { + if (param.name === "apiVersion") { + continue; + } + if (param.value) { + const value = + typeof param.value === "string" ? `"${param.value}"` : param.value; + optionalUrlParameters.push( + `const ${param.name} = options.${param.name} ?? ${value};` + ); + } + } + + let endpointUrl: string; + if (urlParameters && endpoint) { + let parsedEndpoint = endpoint; + urlParameters.forEach((urlParameter) => { + parsedEndpoint = parsedEndpoint.replace( + `{${urlParameter.name}}`, + `\${${urlParameter.name}}` + ); + }); + endpointUrl = `options.endpoint ?? \`${parsedEndpoint}\``; + } else { + endpointUrl = `options.endpoint ?? "${endpoint}"`; + } + + if (!model.options.isModularLibrary && !clientPackageName.endsWith("-rest")) { + clientPackageName += "-rest"; + } + + const userAgentInfoStatement = + "const userAgentInfo = `azsdk-js-" + + clientPackageName + + "/" + + packageVersion + + "`;"; + const userAgentPrefix = + "options.userAgentOptions && options.userAgentOptions.userAgentPrefix ? `${options.userAgentOptions.userAgentPrefix} ${userAgentInfo}`: `${userAgentInfo}`;"; + const userAgentStatement: VariableStatementStructure = { + kind: StructureKind.VariableStatement, + declarationKind: VariableDeclarationKind.Const, + declarations: [{ name: "userAgentPrefix", initializer: userAgentPrefix }] + }; + + const customHeaderOptions = model.telemetryOptions?.customRequestIdHeaderName + ? `, + telemetryOptions: { + clientRequestIdHeaderName: + options.telemetryOptions?.clientRequestIdHeaderName ?? + "${model.telemetryOptions?.customRequestIdHeaderName}" + }` + : ""; + + const endpointUrlStatement: VariableStatementStructure = { + kind: StructureKind.VariableStatement, + declarationKind: VariableDeclarationKind.Const, + declarations: [{ name: "endpointUrl", initializer: endpointUrl }] + }; + + const { credentialScopes, credentialKeyHeaderName } = model.options; + const scopesString = credentialScopes + ? credentialScopes.map((cs) => `"${cs}"`).join(", ") || + "`${endpointUrl}/.default`" + : ""; + const scopes = scopesString + ? `scopes: options.credentials?.scopes ?? [${scopesString}],` + : ""; + + const apiKeyHeaderName = credentialKeyHeaderName + ? `apiKeyHeaderName: options.credentials?.apiKeyHeaderName ?? "${credentialKeyHeaderName}",` + : ""; + const loggerOptions = isAzurePackage(model) + ? `, + loggingOptions: { + logger: options.loggingOptions?.logger ?? logger.info + }` + : ""; + + const credentialsOptions = + (scopes || apiKeyHeaderName) && addCredentials + ? `, + credentials: { + ${scopes} + ${apiKeyHeaderName} + }` + : ""; + const overrideOptionsStatement = `options = { + ...options, + userAgentOptions: { + userAgentPrefix + }${loggerOptions}${customHeaderOptions}${credentialsOptions} + };`; + const getClient = `const client = getClient( + endpointUrl, ${credentialsOptions ? "credentials," : ""} options + ) as ${clientTypeName}; + `; + const { customHttpAuthHeaderName, customHttpAuthSharedKeyPrefix } = + model.options; + let customHttpAuthStatement = ""; + if (customHttpAuthHeaderName && customHttpAuthSharedKeyPrefix) { + if (options.isMultipleCredential) { + customHttpAuthStatement = `if (isKeyCredential(credentials)) { + client.pipeline.addPolicy({ + name: "customKeyCredentialPolicy", + async sendRequest(request, next) { + request.headers.set("Authorization", "Bearer " + credentials.key); + return next(request); + }, + }); + }`; + } else { + customHttpAuthStatement = ` + client.pipeline.addPolicy({ + name: "customKeyCredentialPolicy", + async sendRequest(request, next) { + request.headers.set("${customHttpAuthHeaderName}", "${customHttpAuthSharedKeyPrefix} " + credentials.key); + return next(request); + } + });`; + } + } + + let apiVersionPolicyStatement = `client.pipeline.removePolicy({ name: "ApiVersionPolicy" });`; + if ( + isAzurePackage(model) && + model.apiVersionInfo?.isCrossedVersion !== false + ) { + apiVersionPolicyStatement += ` + if (options.apiVersion) { + logger.warning("This client does not support client api-version, please change it at the operation level"); + }`; + } else if ( + isAzurePackage(model) && + !model.apiVersionInfo?.defaultValue && + model.apiVersionInfo?.required + ) { + apiVersionPolicyStatement += ` + if (options.apiVersion) { + logger.warning("This client does not support to set api-version in options, please change it at positional argument"); + }`; + } + if ( + model.apiVersionInfo?.isCrossedVersion === false && + model.apiVersionInfo?.definedPosition === "query" + ) { + apiVersionPolicyStatement += ` + client.pipeline.addPolicy({ + name: 'ClientApiVersionPolicy', + sendRequest: (req, next) => { + // Use the apiVersion defined in request url directly + // Append one if there is no apiVersion and we have one at client options + const url = new URL(req.url); + if (!url.searchParams.get("api-version") && apiVersion) { + req.url = \`\${req.url}\${ + Array.from(url.searchParams.keys()).length > 0 ? "&" : "?" + }api-version=\${apiVersion}\`; + } + + return next(req); + }, + });`; + } + let returnStatement = `return client;`; + + if (includeShortcuts) { + const shortcutImplementations = buildMethodShortcutImplementation( + model.paths + ); + const shortcutBody = Object.keys(shortcutImplementations).map((key) => { + // If the operation group has an empty name, it means its operations are client + // level operations so we need to spread the definitions. Otherwise they are + // within an operation group so we add them as key: value + const shortcuts = shortcutImplementations[key]; + return `${ + key && key !== "client" ? `"${key}":` : "..." + } {${shortcuts ? shortcuts.join() : ""}}`; + }); + returnStatement = `return { ...client, ${shortcutBody.join()} };`; + } + + return [ + ...optionalUrlParameters, + endpointUrlStatement, + userAgentInfoStatement, + userAgentStatement, + overrideOptionsStatement, + getClient, + apiVersionPolicyStatement, + customHttpAuthStatement, + returnStatement + ]; +} + +function normalizeUrlInfo(model: RLCModel) { + if ( + !model || + !model.urlInfo || + !model.urlInfo.endpoint || + !model.urlInfo.urlParameters || + model.urlInfo.urlParameters.length === 0 + ) { + return; + } + + let parsedEndpoint = model.urlInfo.endpoint; + const urlParameters = model.urlInfo.urlParameters; + urlParameters.forEach((urlParameter) => { + const name = urlParameter.name; + const normalizedName = normalizeName(name, NameType.Parameter); + if (name !== normalizedName) { + urlParameter.name = normalizedName; + parsedEndpoint = parsedEndpoint.replace( + `{${name}}`, + `{${normalizedName}}` + ); + } + }); + model.urlInfo.endpoint = parsedEndpoint; +} diff --git a/packages/typespec-ts/src/rlc-common/buildClientDefinitions.ts b/packages/typespec-ts/src/rlc-common/buildClientDefinitions.ts new file mode 100644 index 0000000000..de2c9024bb --- /dev/null +++ b/packages/typespec-ts/src/rlc-common/buildClientDefinitions.ts @@ -0,0 +1,278 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { + CallSignatureDeclarationStructure, + InterfaceDeclarationStructure, + OptionalKind, + Project, + SourceFile, + StructureKind, + Writers +} from "ts-morph"; +import * as path from "path"; + +import { + buildMethodDefinitions, + getGeneratedWrapperTypes, + getPathParamDefinitions +} from "./helpers/operationHelpers.js"; +import { PathMetadata, Paths, RLCModel } from "./interfaces.js"; +import { generateMethodShortcuts } from "./helpers/shortcutMethods.js"; +import { REST_CLIENT_RESERVED } from "./buildMethodShortcuts.js"; +import { + CasingConvention, + NameType, + normalizeName +} from "./helpers/nameUtils.js"; +import { pascalCase } from "./helpers/nameUtils.js"; +import { + getClientName, + getImportModuleName +} from "./helpers/nameConstructors.js"; +import { getImportSpecifier } from "./helpers/importsUtil.js"; + +export function buildClientDefinitions(model: RLCModel) { + const options = { + importedParameters: new Set(), + importedResponses: new Set(), + clientImports: new Set() + }; + const project = new Project(); + const srcPath = model.srcPath; + const filePath = path.join(srcPath, `clientDefinitions.ts`); + const clientDefinitionsFile = project.createSourceFile(filePath, undefined, { + overwrite: true + }); + + // Get all paths + const pathDictionary = model.paths; + let shortcuts: OptionalKind[] = []; + // There may be operations without an operation group, those shortcut + // methods need to be handled differently. + let shortcutsInOperationGroup: { name: string; type: string }[] = []; + + if (model.options?.includeShortcuts) { + shortcuts = generateMethodShortcuts(model.paths); + clientDefinitionsFile.addInterfaces(shortcuts); + shortcutsInOperationGroup = shortcuts + .filter((s) => s.name !== "ClientOperations") + .map((s) => getShortcutName(s.name)); + } + + clientDefinitionsFile.addInterface({ + name: "Routes", + isExported: true, + callSignatures: getPathFirstRoutesInterfaceDefinition( + pathDictionary, + clientDefinitionsFile, + options + ) + }); + + const clientInterfaceName = getClientName(model); + clientDefinitionsFile.addTypeAlias({ + isExported: true, + name: clientInterfaceName, + type: Writers.intersectionType( + "Client", + Writers.objectType({ + properties: [ + { name: "path", type: "Routes" }, + ...shortcutsInOperationGroup + ] + }), + // If the length of shortcuts in operation group and all shortcutsInOperationGroup + // is the same, then we don't have any operations at the client level + // Otherwise we need to make the client interface name an union with the + // definition of all client level shortcut methods + ...(shortcutsInOperationGroup.length !== shortcuts.length + ? [`ClientOperations`] + : []) + ) + }); + + if (options.importedParameters.size) { + clientDefinitionsFile.addImportDeclaration({ + isTypeOnly: true, + namedImports: [...options.importedParameters], + moduleSpecifier: getImportModuleName( + { cjsName: "./parameters", esModulesName: "./parameters.js" }, + model + ) + }); + } + + if (options.importedResponses.size) { + clientDefinitionsFile.addImportDeclaration({ + isTypeOnly: true, + namedImports: [...options.importedResponses], + moduleSpecifier: getImportModuleName( + { cjsName: "./responses", esModulesName: "./responses.js" }, + model + ) + }); + } + + if ( + (model.importInfo.internalImports.rlcClientDefinition.importsSet?.size ?? + 0) > 0 + ) { + clientDefinitionsFile.addImportDeclaration({ + isTypeOnly: true, + namedImports: Array.from( + model.importInfo.internalImports.rlcClientDefinition.importsSet! + ), + moduleSpecifier: getImportModuleName( + { cjsName: "./models", esModulesName: "./models.js" }, + model + ) + }); + } + + options.clientImports.add("Client"); + options.clientImports.add("StreamableMethod"); + clientDefinitionsFile.addImportDeclarations([ + { + isTypeOnly: true, + namedImports: [...options.clientImports], + moduleSpecifier: getImportSpecifier( + "restClient", + model.importInfo.runtimeImports + ) + } + ]); + + return { path: filePath, content: clientDefinitionsFile.getFullText() }; +} + +function getPathFirstRoutesInterfaceDefinition( + paths: Paths, + sourcefile: SourceFile, + options: { + importedParameters: Set; + importedResponses: Set; + clientImports: Set; + } +): CallSignatureDeclarationStructure[] { + const operationGroupCount = getOperationGroupCount(paths); + + const signatures: CallSignatureDeclarationStructure[] = []; + for (const key of Object.keys(paths)) { + const pathMetadata = paths[key]; + if (!pathMetadata) { + continue; + } + for (const verb of Object.keys(pathMetadata.methods)) { + const methods = pathMetadata.methods[verb]; + if (!methods) { + continue; + } + for (const method of methods) { + options.importedParameters.add(method.optionsName); + method.returnType + .split(" | ") + .forEach((item) => options.importedResponses.add(item)); + } + } + generatePathFirstRouteMethodsDefinition( + pathMetadata, + operationGroupCount, + sourcefile + ); + const pathParams = pathMetadata.pathParameters; + getGeneratedWrapperTypes(pathParams).forEach((p) => + options.importedParameters.add(p.name ?? p.type) + ); + signatures.push({ + docs: [ + `Resource for '${key + .replace(/}/g, "\\}") + .replace( + /{/g, + "\\{" + )}' has methods for the following verbs: ${Object.keys( + pathMetadata.methods + ).join(", ")}` + ], + parameters: [ + { name: "path", type: `"${key}"` }, + ...getPathParamDefinitions(pathParams) + ], + returnType: getOperationReturnTypeName( + pathMetadata, + getOperationGroupCount(paths) + ), + kind: StructureKind.CallSignature + }); + } + return signatures; +} + +function getOperationGroupCount(paths: Paths) { + const operationGroups = Object.keys(paths) + .map((p) => paths[p]?.operationGroupName) + .filter((p) => p && p !== "Client"); + const uniqueNames = new Set(operationGroups); + + return uniqueNames.size; +} + +function getOperationReturnTypeName( + { operationGroupName, name }: PathMetadata, + operationGroupCount: number +) { + if ( + operationGroupCount > 1 && + operationGroupName && + operationGroupName !== "Client" + ) { + return normalizeName( + `${pascalCase(operationGroupName)}${pascalCase(name)}`, + NameType.Interface + ); + } + + return pascalCase(name); +} + +function generatePathFirstRouteMethodsDefinition( + path: PathMetadata, + operationGroupCount: number, + file: SourceFile +): void { + const methodDefinitions = buildMethodDefinitions(path.methods); + const interfaceDef = { + methods: methodDefinitions, + name: getOperationReturnTypeName(path, operationGroupCount), + isExported: true + }; + file.addInterface(interfaceDef); +} + +function getShortcutName(interfaceName: string) { + const endIndex = shouldKeepSuffix(interfaceName) + ? undefined + : interfaceName.length - "Operations".length; + const clientProperty = normalizeName( + interfaceName.substring(0, endIndex), + NameType.OperationGroup, + true, + REST_CLIENT_RESERVED, + CasingConvention.Camel + ); + + return { + name: clientProperty, + type: interfaceName + }; +} + +function shouldKeepSuffix(name: string) { + const reservedNames = [ + "pipelineOperations", + "pathOperations", + "pathUncheckedOperations" + ]; + return reservedNames.some((r) => r.toLowerCase() === name.toLowerCase()); +} diff --git a/packages/typespec-ts/src/rlc-common/buildIndexFile.ts b/packages/typespec-ts/src/rlc-common/buildIndexFile.ts new file mode 100644 index 0000000000..7d7d32dbfe --- /dev/null +++ b/packages/typespec-ts/src/rlc-common/buildIndexFile.ts @@ -0,0 +1,344 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { Project, SourceFile } from "ts-morph"; +import { NameType, normalizeName } from "./helpers/nameUtils.js"; +import { + hasCsvCollection, + hasInputModels, + hasMultiCollection, + hasOutputModels, + hasPagingOperations, + hasPipeCollection, + hasPollingOperations, + hasSsvCollection, + hasTsvCollection, + hasUnexpectedHelper +} from "./helpers/operationHelpers.js"; +import { isAzurePackage } from "./helpers/packageUtil.js"; +import { RLCModel } from "./interfaces.js"; +import * as path from "path"; +import { getImportModuleName } from "./helpers/nameConstructors.js"; + +export function buildIndexFile(model: RLCModel) { + const multiClient = Boolean(model.options?.multiClient), + batch = model.options?.batch; + const project = new Project(); + const { srcPath } = model; + const filePath = path.join(srcPath, `index.ts`); + const indexFile = project.createSourceFile(filePath, undefined, { + overwrite: true + }); + + if (!multiClient || !batch || batch?.length === 1) { + // if we are generate single client package for RLC + generateRLCIndex(indexFile, model); + } else { + generateRLCIndexForMultiClient(indexFile, model); + } + return { + path: filePath, + content: indexFile.getFullText() + }; +} + +// to generate a index.ts for each single module inside the multi client RLC package +function generateRLCIndexForMultiClient(file: SourceFile, model: RLCModel) { + const clientName = model.libraryName; + const createClientFuncName = `createClient`; + const moduleName = normalizeName(clientName, NameType.File); + + file.addImportDeclaration({ + namespaceImport: "Parameters", + moduleSpecifier: getImportModuleName( + { cjsName: "./parameters", esModulesName: "./parameters.js" }, + model + ) + }); + + file.addImportDeclaration({ + namespaceImport: "Responses", + moduleSpecifier: getImportModuleName( + { cjsName: "./responses", esModulesName: "./responses.js" }, + model + ) + }); + + file.addImportDeclaration({ + namespaceImport: "Client", + moduleSpecifier: getImportModuleName( + { + cjsName: "./clientDefinitions", + esModulesName: "./clientDefinitions.js" + }, + model + ) + }); + + const exports = ["Parameters", "Responses", "Client"]; + if (hasInputModels(model)) { + file.addImportDeclaration({ + namespaceImport: "Models", + moduleSpecifier: getImportModuleName( + { + cjsName: "./models", + esModulesName: "./models.js" + }, + model + ) + }); + exports.push("Models"); + } + + if (hasOutputModels(model)) { + file.addImportDeclaration({ + namespaceImport: "OutputModels", + moduleSpecifier: getImportModuleName( + { + cjsName: "./outputModels", + esModulesName: "./outputModels.js" + }, + model + ) + }); + exports.push("OutputModels"); + } + + if (hasPagingOperations(model)) { + file.addImportDeclaration({ + namespaceImport: "PaginateHelper", + moduleSpecifier: getImportModuleName( + { + cjsName: "./paginateHelper", + esModulesName: "./paginateHelper.js" + }, + model + ) + }); + exports.push("PaginateHelper"); + } + + if (hasUnexpectedHelper(model)) { + file.addImportDeclaration({ + namespaceImport: "UnexpectedHelper", + moduleSpecifier: getImportModuleName( + { + cjsName: "./isUnexpected", + esModulesName: "./isUnexpected.js" + }, + model + ) + }); + exports.push("UnexpectedHelper"); + } + + if (hasPollingOperations(model)) { + file.addImportDeclaration({ + namespaceImport: "PollingHelper", + moduleSpecifier: getImportModuleName( + { + cjsName: "./pollingHelper", + esModulesName: "./pollingHelper.js" + }, + model + ) + }); + exports.push("PollingHelper"); + } + + if ( + hasMultiCollection(model) || + hasSsvCollection(model) || + hasPipeCollection(model) || + hasTsvCollection(model) || + hasCsvCollection(model) + ) { + file.addImportDeclaration({ + namespaceImport: "SerializeHelper", + moduleSpecifier: getImportModuleName( + { + cjsName: "./serializeHelper", + esModulesName: "./serializeHelper.js" + }, + model + ) + }); + exports.push("SerializeHelper"); + } + + file.addExportDeclarations([ + { + moduleSpecifier: getImportModuleName( + { + cjsName: `./${moduleName}`, + esModulesName: `./${moduleName}.js` + }, + model + ), + namedExports: [`${createClientFuncName}`, `${clientName}ClientOptions`] + }, + { + namedExports: [...exports] + } + ]); +} + +function generateRLCIndex(file: SourceFile, model: RLCModel) { + const clientName = model.libraryName; + const createClientFuncName = `${clientName}`; + const moduleName = normalizeName(clientName, NameType.File); + + file.addImportDeclaration({ + moduleSpecifier: getImportModuleName( + { + cjsName: `./${moduleName}`, + esModulesName: `./${moduleName}.js` + }, + model + ), + defaultImport: createClientFuncName + }); + + file.addExportDeclarations([ + { + moduleSpecifier: getImportModuleName( + { + cjsName: `./${moduleName}`, + esModulesName: `./${moduleName}.js` + }, + model + ) + }, + { + moduleSpecifier: getImportModuleName( + { + cjsName: `./parameters`, + esModulesName: `./parameters.js` + }, + model + ) + }, + { + moduleSpecifier: getImportModuleName( + { + cjsName: `./responses`, + esModulesName: `./responses.js` + }, + model + ) + }, + { + moduleSpecifier: getImportModuleName( + { + cjsName: `./clientDefinitions`, + esModulesName: `./clientDefinitions.js` + }, + model + ) + } + ]); + + if (hasUnexpectedHelper(model)) { + file.addExportDeclarations([ + { + moduleSpecifier: getImportModuleName( + { + cjsName: `./isUnexpected`, + esModulesName: `./isUnexpected.js` + }, + model + ) + } + ]); + } + + if (hasInputModels(model)) { + file.addExportDeclarations([ + { + moduleSpecifier: getImportModuleName( + { + cjsName: `./models`, + esModulesName: `./models.js` + }, + model + ) + } + ]); + } + + if (hasOutputModels(model)) { + file.addExportDeclarations([ + { + moduleSpecifier: getImportModuleName( + { + cjsName: `./outputModels`, + esModulesName: `./outputModels.js` + }, + model + ) + } + ]); + } + + if (hasPagingOperations(model)) { + file.addExportDeclarations([ + { + moduleSpecifier: getImportModuleName( + { + cjsName: `./paginateHelper`, + esModulesName: `./paginateHelper.js` + }, + model + ) + } + ]); + } + + if (hasPollingOperations(model)) { + file.addExportDeclarations([ + { + moduleSpecifier: getImportModuleName( + { + cjsName: `./pollingHelper`, + esModulesName: `./pollingHelper.js` + }, + model + ) + } + ]); + } + + if ( + hasMultiCollection(model) || + hasSsvCollection(model) || + hasPipeCollection(model) || + hasTsvCollection(model) || + hasCsvCollection(model) + ) { + file.addExportDeclarations([ + { + moduleSpecifier: getImportModuleName( + { + cjsName: `./serializeHelper`, + esModulesName: `./serializeHelper.js` + }, + model + ) + } + ]); + } + + if (isAzurePackage(model)) { + file.addExportDeclarations([ + { + moduleSpecifier: "@azure/core-rest-pipeline", + namedExports: ["RestError", "isRestError"] + } + ]); + } + + file.addExportAssignment({ + expression: createClientFuncName, + isExportEquals: false + }); +} diff --git a/packages/typespec-ts/src/rlc-common/buildIsUnexpectedHelper.ts b/packages/typespec-ts/src/rlc-common/buildIsUnexpectedHelper.ts new file mode 100644 index 0000000000..953a1abeb0 --- /dev/null +++ b/packages/typespec-ts/src/rlc-common/buildIsUnexpectedHelper.ts @@ -0,0 +1,255 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { RLCModel } from "./interfaces.js"; +import * as path from "path"; +import { + FunctionDeclarationOverloadStructure, + OptionalKind, + Project, + VariableDeclarationKind +} from "ts-morph"; +import { hasUnexpectedHelper } from "./helpers/operationHelpers.js"; +import { getImportModuleName } from "./helpers/nameConstructors.js"; +export function buildIsUnexpectedHelper(model: RLCModel) { + if (!hasUnexpectedHelper(model)) { + return; + } + const project = new Project(); + const srcPath = model.srcPath; + const filePath = path.join(srcPath, `isUnexpected.ts`); + const isErrorHelper = project.createSourceFile(filePath, undefined, { + overwrite: true + }); + + let map: Record = {}; + const allResponseTypes: Set = new Set(); + const allErrorTypes: Set = new Set(); + const overloads: OptionalKind[] = []; + const pathDictionary = model.paths; + + for (const [path, details] of Object.entries(pathDictionary)) { + for (const [methodName, methodDetails] of Object.entries(details.methods)) { + const originalMethod = methodName.toUpperCase(); + const operation = `${originalMethod} ${path}`; + const successCodeSet = new Set(map["operation"] ?? []); + for (const detail of methodDetails) { + detail.successStatus.forEach(successCodeSet.add, successCodeSet); + // LROs may call the same path but with GET + // to get the operation status. + if ( + detail.operationHelperDetail?.lroDetails?.isLongRunning && + originalMethod !== "GET" + ) { + const operation = `GET ${path}`; + const logicalSuccessCodes = detail.operationHelperDetail?.lroDetails + ?.logicalResponseTypes?.success + ? ["200"] + : []; + const initialSuccessCodes = + (pathDictionary[path]?.methods["get"] && + pathDictionary[path]?.methods["get"]?.[0]?.successStatus) ?? + detail.successStatus; + map = { + ...map, + ...{ + [operation]: Array.from( + new Set(logicalSuccessCodes.concat(initialSuccessCodes)) + ) + } + }; + } + + const successTypes = [...detail.responseTypes.success]; + const errorTypes = [...detail.responseTypes.error]; + + if ( + model.helperDetails?.clientLroOverload && + detail.operationHelperDetail?.lroDetails?.logicalResponseTypes + ?.success + ) { + successTypes.push( + ...(detail.operationHelperDetail.lroDetails.logicalResponseTypes + .success ?? []) + ); + } + + if (!successTypes.length || !errorTypes.length || !errorTypes[0]) { + continue; + } + + successTypes.forEach((t) => allResponseTypes.add(t)); + errorTypes.forEach((t) => { + allResponseTypes.add(t); + allErrorTypes.add(t); + }); + + overloads.push({ + isExported: true, + parameters: [ + { + name: "response", + type: [...successTypes, ...errorTypes].join(" | ") + } + ], + returnType: `response is ${errorTypes[0]}` + }); + } + map = { ...map, ...{ [operation]: Array.from(successCodeSet) } }; + } + } + isErrorHelper.addImportDeclaration({ + isTypeOnly: true, + namedImports: [...allResponseTypes], + moduleSpecifier: getImportModuleName( + { + cjsName: `./responses`, + esModulesName: `./responses.js` + }, + model + ) + }); + + isErrorHelper.addVariableStatement({ + declarations: [ + { + name: "responseMap", + initializer: JSON.stringify(map), + type: "Record" + } + ], + declarationKind: VariableDeclarationKind.Const + }); + + if (allErrorTypes.size) { + isErrorHelper.addFunction({ + overloads, + isExported: true, + name: "isUnexpected", + parameters: [ + { + name: "response", + type: [...allResponseTypes].join(" | ") + } + ], + returnType: `response is ${[...allErrorTypes].join(" | ")}`, + statements: [ + ` + const lroOriginal = response.headers["x-ms-original-url"]; + const url = new URL(lroOriginal ?? response.request.url); + const method = response.request.method; + let pathDetails = responseMap[\`\${method} \${url.pathname}\`]; + if (!pathDetails) { + pathDetails = getParametrizedPathSuccess(method, url.pathname); + } + return !pathDetails.includes(response.status); + ` + ] + }); + isErrorHelper.addFunction({ + isExported: false, + name: "getParametrizedPathSuccess", + parameters: [ + { + name: "method", + type: "string" + }, + { + name: "path", + type: "string" + } + ], + returnType: `string[]`, + statements: [ + ` + const pathParts = path.split("/"); + + // Traverse list to match the longest candidate + // matchedLen: the length of candidate path + // matchedValue: the matched status code array + let matchedLen = -1, + matchedValue: string[] = []; + + // Iterate the responseMap to find a match + for (const [key, value] of Object.entries(responseMap)) { + // Extracting the path from the map key which is in format + // GET /path/foo + if (!key.startsWith(method)) { + continue; + } + const candidatePath = getPathFromMapKey(key); + // Get each part of the url path + const candidateParts = candidatePath.split("/"); + + // track if we have found a match to return the values found. + let found = true; + for ( + let i = candidateParts.length - 1, j = pathParts.length - 1; + i >= 1 && j >= 1; + i--, j-- + ) { + if ( + candidateParts[i]?.startsWith("{") && + candidateParts[i]?.indexOf("}") !== -1 + ) { + const start = candidateParts[i]!.indexOf("}") + 1, + end = candidateParts[i]?.length; + // If the current part of the candidate is a "template" part + // Try to use the suffix of pattern to match the path + // {guid} ==> $ + // {guid}:export ==> :export$ + const isMatched = new RegExp( + \`\${candidateParts[i]?.slice(start, end)}\` + ).test(pathParts[j] || ''); + + if (!isMatched) { + found = false; + break; + } + continue; + } + + // If the candidate part is not a template and + // the parts don't match mark the candidate as not found + // to move on with the next candidate path. + if (candidateParts[i] !== pathParts[j]) { + found = false; + break; + } + } + + // We finished evaluating the current candidate parts + // Update the matched value if and only if we found the longer pattern + if (found && candidatePath.length > matchedLen) { + matchedLen = candidatePath.length; + matchedValue = value; + } + } + + return matchedValue; + ` + ] + }); + + isErrorHelper.addFunction({ + isExported: false, + name: "getPathFromMapKey", + parameters: [ + { + name: "mapKey", + type: "string" + } + ], + returnType: `string`, + statements: [ + `const pathStart = mapKey.indexOf("/"); + return mapKey.slice(pathStart);` + ] + }); + } + + return { + path: filePath, + content: isErrorHelper.getFullText() + }; +} diff --git a/packages/typespec-ts/src/rlc-common/buildLogger.ts b/packages/typespec-ts/src/rlc-common/buildLogger.ts new file mode 100644 index 0000000000..4451f97331 --- /dev/null +++ b/packages/typespec-ts/src/rlc-common/buildLogger.ts @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { RLCModel } from "./interfaces.js"; +import { Project } from "ts-morph"; +import * as path from "path"; + +export function buildLogger(model: RLCModel) { + if (!model.options) { + return undefined; + } + // Disable logger for non-Azure packages + if (model.options.flavor !== "azure") { + return undefined; + } + const project = new Project(); + const { srcPath, rlcSourceDir } = model; + const { packageDetails } = model.options; + const filePath = path.join( + model.options.sourceFrom == "Swagger" + ? srcPath.substring( + 0, + srcPath.includes("generated") && !srcPath.includes("src") + ? srcPath.lastIndexOf("generated") + 10 + : srcPath.lastIndexOf("src") + 4 + ) + : rlcSourceDir!, + `logger.ts` + ); + const loggerFile = project.createSourceFile("logger.ts", undefined, { + overwrite: true + }); + loggerFile.addImportDeclaration({ + namedImports: ["createClientLogger"], + moduleSpecifier: `@azure/logger` + }); + loggerFile.addStatements( + `export const logger = createClientLogger("${ + packageDetails!.nameWithoutScope ?? packageDetails?.name ?? "" + }")` + ); + return { path: filePath, content: loggerFile.getFullText() }; +} diff --git a/packages/typespec-ts/src/rlc-common/buildMethodShortcuts.ts b/packages/typespec-ts/src/rlc-common/buildMethodShortcuts.ts new file mode 100644 index 0000000000..6efec0024d --- /dev/null +++ b/packages/typespec-ts/src/rlc-common/buildMethodShortcuts.ts @@ -0,0 +1,83 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { + CasingConvention, + NameType, + normalizeName, + ReservedName +} from "./helpers/nameUtils.js"; +import { Paths, PathParameter, PathMetadata } from "./interfaces.js"; + +export const REST_CLIENT_RESERVED: ReservedName[] = [ + { name: "path", reservedFor: [NameType.Property, NameType.OperationGroup] }, + { + name: "pathUnchecked", + reservedFor: [NameType.Property, NameType.OperationGroup] + }, + { + name: "pipeline", + reservedFor: [NameType.Property, NameType.OperationGroup] + } +]; + +export function buildMethodShortcutImplementation(paths: Paths) { + const keys: Record = {}; + for (const path of Object.keys(paths)) { + const pathMetadata = paths[path]; + if (!pathMetadata) { + continue; + } + const groupName = normalizeName( + pathMetadata.operationGroupName, + NameType.OperationGroup, + true, + REST_CLIENT_RESERVED, + CasingConvention.Camel + ); + + if (keys[groupName]) { + keys[groupName].push(...buildOperationDeclarations(path, pathMetadata)); + } else { + keys[groupName] = buildOperationDeclarations(path, pathMetadata); + } + } + return keys; +} + +function buildOperationDeclarations(path: string, pathMetadata: PathMetadata) { + let ops: string[] = []; + for (const method of Object.keys(pathMetadata.methods)) { + const methodOps = pathMetadata.methods[method]; + if (!methodOps) { + continue; + } + for (const op of methodOps) { + const pathParams = pathMetadata?.pathParameters; + const name = normalizeName(op.operationName, NameType.Property); + const methodDefinitions = generateOperationDeclaration( + path, + name, + method, + pathParams + ); + ops = [...ops, methodDefinitions]; + } + } + + return ops; +} + +function generateOperationDeclaration( + path: string, + operationName: string, + method: string, + pathParams: PathParameter[] = [] +): string { + const pathParamNames = `${ + pathParams.length > 0 ? `${pathParams.map((p) => p.name)},` : "" + }`; + return `"${operationName}": (${pathParamNames} options) => { + return client.path("${path}", ${pathParamNames}).${method}(options); + }`; +} diff --git a/packages/typespec-ts/src/rlc-common/buildObjectTypes.ts b/packages/typespec-ts/src/rlc-common/buildObjectTypes.ts new file mode 100644 index 0000000000..3111fd6054 --- /dev/null +++ b/packages/typespec-ts/src/rlc-common/buildObjectTypes.ts @@ -0,0 +1,680 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { + InterfaceDeclarationStructure, + OptionalKind, + PropertySignatureStructure, + StructureKind, + TypeAliasDeclarationStructure +} from "ts-morph"; +import { NameType, normalizeName } from "./helpers/nameUtils.js"; +import { + isArraySchema, + isDictionarySchema, + isObjectSchema +} from "./helpers/schemaHelpers.js"; +import { + ArraySchema, + ObjectSchema, + Parameter, + Property, + RLCModel, + Schema, + SchemaContext +} from "./interfaces.js"; +import { getMultipartPartTypeName } from "./helpers/nameConstructors.js"; + +/** + * Generates interfaces for ObjectSchemas + */ +export function buildObjectInterfaces( + model: RLCModel, + importedModels: Set, + schemaUsage: SchemaContext[] +): InterfaceDeclarationStructure[] { + const objectSchemas: ObjectSchema[] = (model.schemas ?? []).filter( + (o) => + isObjectSchema(o) && + (o as ObjectSchema).usage?.some((u) => schemaUsage.includes(u)) + ); + const objectInterfaces: InterfaceDeclarationStructure[] = []; + + for (const objectSchema of objectSchemas) { + if ( + objectSchema.alias || + objectSchema.outputAlias || + objectSchema.fromCore + ) { + continue; + } + + // FIXME: disabling new multipart generation for modular while we figure out the story + if (objectSchema.isMultipartBody && !model.options?.isModularLibrary) { + objectInterfaces.push( + ...buildMultipartPartDefinitions( + objectSchema, + importedModels, + schemaUsage + ) + ); + continue; + } + + const baseName = getObjectBaseName(objectSchema, schemaUsage); + const interfaceDeclaration = getObjectInterfaceDeclaration( + model, + baseName, + objectSchema, + schemaUsage, + importedModels + ); + + objectInterfaces.push(interfaceDeclaration); + } + return objectInterfaces; +} + +const MULTIPART_FILE_METADATA_PROPERTIES: OptionalKind[] = + [ + { + name: "filename", + hasQuestionToken: true, + type: "string" + }, + { + name: "contentType", + hasQuestionToken: true, + type: "string" + } + ]; + +function buildMultipartPartDefinitions( + schema: ObjectSchema, + importedModels: Set, + schemaUsage: SchemaContext[] +): InterfaceDeclarationStructure[] { + if (!schema.isMultipartBody) { + return []; + } + + // Transform property signatures into individual models + const propertySignatures = getPropertySignatures( + schema.properties ?? {}, + schemaUsage, + importedModels, + { flattenBinaryArrays: true } + ); + + const structures: InterfaceDeclarationStructure[] = []; + + for (const signature of propertySignatures) { + const name = signature.name; + const propertySchema = schema.properties?.[name]; + const typeName = getMultipartPartTypeName(schema.name, name); + + const isFileUpload = signature.type?.toString().includes("File") ?? false; + + const additionalProperties: any[] = []; + + if ((propertySchema as any).multipartOptions) { + const multipartOptions: any = (propertySchema as any).multipartOptions; + if (multipartOptions.filenameSchema) { + additionalProperties.push( + getPropertySignature( + { name: "filename", ...multipartOptions.filenameSchema }, + [SchemaContext.Input], + importedModels + ) + ); + } + if (multipartOptions.contentTypeSchema) { + additionalProperties.push( + getPropertySignature( + { name: "contentType", ...multipartOptions.contentTypeSchema }, + [SchemaContext.Input], + importedModels + ) + ); + } + } else if (isFileUpload) { + // default additional file metadata properties (legacy) + additionalProperties.push(...MULTIPART_FILE_METADATA_PROPERTIES); + } + + structures.push({ + kind: StructureKind.Interface, + isExported: true, + name: typeName, + properties: [ + { + name: "name", + type: name + }, + { + name: "body", + type: signature.type + }, + ...additionalProperties + ] + }); + } + + return structures; +} + +export function buildObjectAliases( + model: RLCModel, + importedModels: Set, + schemaUsage: SchemaContext[] +) { + const objectSchemas: ObjectSchema[] = (model.schemas ?? []).filter( + (o) => + isObjectSchema(o) && + (o as ObjectSchema).usage?.some((u) => schemaUsage.includes(u)) + ); + const objectAliases: TypeAliasDeclarationStructure[] = []; + + for (const objectSchema of objectSchemas) { + // FIXME: disabling new multipart generation for modular while we figure out the story + if (objectSchema.isMultipartBody && !model.options?.isModularLibrary) { + const propertySignatures = getPropertySignatures( + objectSchema.properties ?? {}, + schemaUsage, + importedModels, + { flattenBinaryArrays: true } + ); + + const objectTypeNames = propertySignatures.map((sig) => + getMultipartPartTypeName(objectSchema.name, sig.name) + ); + + objectAliases.push({ + kind: StructureKind.TypeAlias, + ...(objectSchema.description && { + docs: [{ description: objectSchema.description }] + }), + name: objectSchema.typeName!, + type: `FormData | Array<${objectTypeNames.join("|") || "unknown"}>`, + isExported: true + }); + } + + if (objectSchema.alias || objectSchema.outputAlias) { + const description = objectSchema.description; + const modelName = schemaUsage.includes(SchemaContext.Input) + ? `${objectSchema.typeName}` + : `${objectSchema.outputTypeName}`; + objectAliases.push({ + kind: StructureKind.TypeAlias, + ...(description && { docs: [{ description }] }), + name: modelName, + type: schemaUsage.includes(SchemaContext.Input) + ? `${objectSchema.alias}` + : `${objectSchema.outputAlias}`, + isExported: true, + docs: [description ?? "Alias for " + modelName] + }); + } + } + return objectAliases; +} + +export function buildPolymorphicAliases( + model: RLCModel, + schemaUsage: SchemaContext[] +) { + // We'll add aliases for polymorphic objects + const objectAliases: TypeAliasDeclarationStructure[] = []; + const objectSchemas: ObjectSchema[] = (model.schemas ?? []).filter( + (o) => + isObjectSchema(o) && + (o as ObjectSchema).usage?.some((u) => schemaUsage.includes(u)) + ); + for (const objectSchema of objectSchemas) { + const baseName = getObjectBaseName(objectSchema, schemaUsage); + const typeAlias = getPolymorphicTypeAlias( + baseName, + objectSchema, + schemaUsage + ); + if (typeAlias) { + objectAliases.push(typeAlias); + } + } + + return objectAliases; +} + +/** + * Gets a base name for an object schema this is tipically used with suffixes when building interface or type names + */ +function getObjectBaseName( + objectSchema: ObjectSchema, + schemaUsage: SchemaContext[] +) { + const nameSuffix = schemaUsage.includes(SchemaContext.Output) ? "Output" : ""; + const name = normalizeName( + objectSchema.name, + NameType.Interface, + true /** guard name */ + ); + + return `${name}${nameSuffix}`; +} + +/** + * If the current object is a Polymorphic parent, we need to create + * a type alias with the union of its children to enable polymorphism + */ +function getPolymorphicTypeAlias( + baseName: string, + objectSchema: ObjectSchema, + schemaUsage: SchemaContext[] +): TypeAliasDeclarationStructure | undefined { + if (!isPolymorphicParent(objectSchema)) { + return undefined; + } + + const unionTypes: string[] = []; + + // If the object itself has a discriminatorValue add its base to the union + if (objectSchema.discriminatorValue) { + unionTypes.push(`${baseName}Parent`); + } + + for (const child of objectSchema.children?.all ?? []) { + const nameSuffix = schemaUsage.includes(SchemaContext.Output) + ? "Output" + : ""; + const name = normalizeName( + child.name, + NameType.Interface, + true /** shouldGuard */ + ); + + unionTypes.push(`${name}${nameSuffix}`); + } + + const description = objectSchema.description; + + return { + kind: StructureKind.TypeAlias, + ...(description && { docs: [{ description }] }), + name: `${baseName}`, + type: unionTypes.join(" | "), + isExported: true + }; +} + +/** + * Builds the interface for the current object schema. If it is a polymorphic + * root node it will suffix it with Base. + */ +export function getObjectInterfaceDeclaration( + model: RLCModel, + baseName: string, + objectSchema: ObjectSchema, + schemaUsage: SchemaContext[], + importedModels: Set +): InterfaceDeclarationStructure { + let interfaceName = `${baseName}`; + if (isPolymorphicParent(objectSchema)) { + interfaceName = `${baseName}Parent`; + } + + const properties = objectSchema.properties ?? {}; + + let propertySignatures = getPropertySignatures( + properties, + schemaUsage, + importedModels + ); + + // Add the polymorphic property if exists + propertySignatures = addDiscriminatorProperty( + model, + objectSchema, + propertySignatures, + schemaUsage + ); + + // Calculate the parents of the current object + const extendFrom = getImmediateParentsNames(objectSchema, schemaUsage); + + const description = objectSchema.description; + return { + kind: StructureKind.Interface, + ...(description && { docs: [{ description }] }), + name: interfaceName, + isExported: true, + properties: propertySignatures, + ...(extendFrom && { extends: extendFrom }) + }; +} + +function isPolymorphicParent(objectSchema: ObjectSchema) { + return objectSchema.isPolyParent ? true : false; +} + +function addDiscriminatorProperty( + model: RLCModel, + objectSchema: ObjectSchema, + properties: PropertySignatureStructure[], + schemaUsage: SchemaContext[] +): PropertySignatureStructure[] { + const polymorphicProperty = getDiscriminatorProperty( + model, + objectSchema, + schemaUsage + ); + + if (polymorphicProperty) { + // It is possible that the polymorphic property needs to override an existing property. + // This is usually the case on the top level parent where the property already has a type of string + // we need to replace it with the polymorphic values of its children + const filteredProperties = properties.filter( + (p) => p.name !== polymorphicProperty.name + ); + return [...filteredProperties, polymorphicProperty]; + } + + return properties; +} + +/** + * Finds the name of the property used as discriminator and the discriminator value. + */ +function getDiscriminatorProperty( + model: RLCModel, + objectSchema: ObjectSchema, + schemaUsage: SchemaContext[] +): PropertySignatureStructure | undefined { + const discriminatorValue = objectSchema.discriminatorValue; + if (!discriminatorValue && !objectSchema.discriminator) { + return undefined; + } + + const discriminators = getDiscriminatorValue(objectSchema); + const discriminatorPropertyName = getDiscriminatorPropertyName(objectSchema); + + if (discriminators) { + if (discriminatorPropertyName === undefined) { + throw new Error( + `getDiscriminatorProperty: Expected object ${objectSchema.name} to have a discriminator in its hierarchy but found none` + ); + } + const inputTypeName = + objectSchema.discriminator?.typeName ?? objectSchema.discriminator?.type; + return { + kind: StructureKind.PropertySignature, + name: `"${discriminatorPropertyName}"`, + type: + model.options?.sourceFrom === "Swagger" + ? discriminators + : schemaUsage.includes(SchemaContext.Output) + ? (objectSchema.discriminator?.outputTypeName ?? inputTypeName) + : inputTypeName + }; + } + + return undefined; +} + +/** + * Finds the closest discriminator property + */ +function getDiscriminatorPropertyName(objectSchema: ObjectSchema) { + if (objectSchema.discriminator !== undefined) { + return objectSchema.discriminator.name; + } + + const allParents = objectSchema.parents?.all ?? []; + + for (const parent of allParents) { + if (isObjectSchema(parent) && parent.discriminator) { + return parent.discriminator.name; + } + } + return undefined; +} + +/** + * Calculates the discriminator values that a given object needs + */ +function getDiscriminatorValue(objectSchema: ObjectSchema): string | undefined { + const discriminatorValue = objectSchema.discriminatorValue + ? objectSchema.discriminatorValue + : objectSchema.discriminator + ? objectSchema.name + : undefined; + const children = objectSchema.children?.immediate ?? []; + + // If the current object has a discriminatorValue but doesn't have any children + // it is a leaf node and the only discriminator value needed is itself + if (discriminatorValue && !children.length) { + return `"${discriminatorValue}"`; + } + + // when the current object has both discriminator and discriminatorValue + if (children) { + const discriminatorProperty = objectSchema.discriminator; + // Even when there are children, if no discriminatorProperty is present this is a leaf in the polymorphism tree + if (!discriminatorProperty) { + return `"${discriminatorValue}"`; + } + + // the current object has discriminated children we need to find all the discriminatorValues for each of its children + const allChildren = objectSchema.children?.all ?? []; + + // Top level parents may not have a discriminator of their own. + const selfDiscriminator = discriminatorValue + ? [`"${discriminatorValue}"`] + : []; + + const childValues = getChildDiscriminatorValues(allChildren).map( + (v) => `"${v}"` + ); + + return [...selfDiscriminator, ...childValues].join(" | "); + } + + return undefined; +} + +/** + * Looks into the children and grabs all possible discriminatorValues + */ +function getChildDiscriminatorValues(children: ObjectSchema[]): string[] { + const discriminatorValues = new Set(); + for (const child of children) { + if (isObjectSchema(child) && child.discriminatorValue) { + discriminatorValues.add(child.discriminatorValue); + } + } + + return [...discriminatorValues]; +} + +/** + * Gets a list of types a given object may extend from + */ +export function getImmediateParentsNames( + objectSchema: ObjectSchema, + schemaUsage: SchemaContext[] +): string[] { + if (!objectSchema.parents?.immediate) { + return []; + } + + const extendFrom: string[] = []; + + // If an immediate parent is an empty DictionarySchema, that means that the object has been marked + // with additional properties. We need to add Record to the extend list and + if ( + objectSchema.parents.immediate.find((im) => + isDictionarySchema(im, { filterEmpty: true }) + ) + ) { + extendFrom.push("Record"); + } + + // Get the rest of the parents excluding any DictionarySchemas + const parents = objectSchema.parents.immediate + .filter((p) => !isDictionarySchema(p, { filterEmpty: true })) + .map((parent) => { + const nameSuffix = schemaUsage.includes(SchemaContext.Output) + ? "Output" + : ""; + const name = isDictionarySchema(parent) + ? Object.entries(objectSchema.properties!)?.some((prop) => { + const typeName = prop[1].typeName ?? prop[1].type; + return ( + `Record` !== parent.typeName && + !(parent as any).additionalProperties?.typeName?.includes( + typeName + ) + ); + }) + ? schemaUsage.includes(SchemaContext.Output) + ? "Record" + : "Record" + : `${ + (schemaUsage.includes(SchemaContext.Output) + ? parent.outputTypeName + : parent.typeName) ?? parent.name + }` + : `${normalizeName( + parent.name, + NameType.Interface, + true /** shouldGuard */ + )}${nameSuffix}`; + + return isObjectSchema(parent) && isPolymorphicParent(parent) + ? `${name}Parent` + : name; + }); + + return [...parents, ...extendFrom]; +} + +interface GetPropertySignatureOptions { + flattenBinaryArrays?: boolean; +} + +function getPropertySignatures( + properties: { [key: string]: Property }, + schemaUsage: SchemaContext[], + importedModels: Set, + options: GetPropertySignatureOptions = {} +) { + let validProperties = Object.keys(properties); + const readOnlyFilter = (name: string) => { + const prop = properties[name]; + return !(schemaUsage.includes(SchemaContext.Input) && prop?.readOnly); + }; + const neverFilter = (name: string) => { + const prop = properties[name]; + return prop?.type !== "never"; + }; + validProperties = validProperties.filter(readOnlyFilter).filter(neverFilter); + return validProperties.map((p) => { + const prop = properties[p]; + if (!prop) { + throw new Error(`Property '${p}' not found`); + } + return getPropertySignature( + { ...prop, name: p } as Schema, + schemaUsage, + importedModels, + options + ); + }); +} + +function isBinaryArray(schema: Schema): boolean { + return Boolean( + isArraySchema(schema) && + (schema.items?.typeName?.includes("NodeJS.ReadableStream") || + schema.items?.outputTypeName?.includes("NodeJS.ReadableStream")) + ); +} + +/** + * Builds a Typescript property or parameter signature + * @param schema - Property or parameter to get the Typescript signature for + * @param importedModels - Set to track the models that need to be imported + * @returns a PropertySignatureStructure for the property. + */ +export function getPropertySignature( + property: Property | Parameter, + schemaUsage: SchemaContext[], + importedModels: Set, + options: GetPropertySignatureOptions = {} +): PropertySignatureStructure { + let schema: Schema; + if (options.flattenBinaryArrays && isBinaryArray(property)) { + schema = { + ...((property as ArraySchema).items ?? property), + name: property.name + }; + } else { + schema = property; + } + + const propertyName = schema.name; + const description = schema.description; + let type; + const hasCoreInArray = + schema.type === "array" && + (schema as any).items && + (schema as any).items.fromCore; + const hasCoreInRecord = + schema.type === "dictionary" && + (schema as any).additionalProperties && + (schema as any).additionalProperties.fromCore; + if (hasCoreInArray && schema.typeName) { + type = schema.typeName; + importedModels.add( + (schema as any).items.typeName ?? (schema as any).items.name + ); + } else if (hasCoreInRecord && schema.typeName) { + type = schema.typeName; + importedModels.add( + (schema as any).additionalProperties.typeName ?? + (schema as any).additionalProperties.name + ); + } else { + type = + generateForOutput(schemaUsage, schema.usage) && schema.outputTypeName + ? schema.outputTypeName + : schema.typeName + ? schema.typeName + : schema.type; + if (schema.typeName && schema.fromCore) { + importedModels.add(schema.typeName); + type = schema.typeName; + } + } + + return { + name: propertyName, + ...(description && { docs: [{ description }] }), + hasQuestionToken: !schema.required, + isReadonly: generateForOutput(schemaUsage, schema.usage) && schema.readOnly, + type, + kind: StructureKind.PropertySignature + }; +} + +function generateForOutput( + schemaUsage: SchemaContext[], + propertyUsage?: SchemaContext[] +) { + return ( + (schemaUsage.includes(SchemaContext.Output) && + propertyUsage?.includes(SchemaContext.Output)) || + (schemaUsage.includes(SchemaContext.Exception) && + propertyUsage?.includes(SchemaContext.Exception)) + ); +} diff --git a/packages/typespec-ts/src/rlc-common/buildPaginateHelper.ts b/packages/typespec-ts/src/rlc-common/buildPaginateHelper.ts new file mode 100644 index 0000000000..c40461534e --- /dev/null +++ b/packages/typespec-ts/src/rlc-common/buildPaginateHelper.ts @@ -0,0 +1,34 @@ +import { RLCModel } from "./interfaces.js"; +import * as path from "path"; +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: to fix the handlebars issue +import hbs from "handlebars"; +import { paginateContent } from "./static/paginateContent.js"; + +export function buildPaginateHelper(model: RLCModel) { + const pagingInfo = model.helperDetails; + // return directly if no paging info + if (!pagingInfo || pagingInfo.hasPaging !== true || !pagingInfo.pageDetails) { + return; + } + + hbs.registerHelper( + "quoteWrap", + function (value: string | number | boolean | string[]) { + if (Array.isArray(value)) { + return value.map((element) => `"${element}"`).join(); + } + + return `"${value}"`; + } + ); + + const { srcPath } = model; + const paginateHelperContents = hbs.compile(paginateContent, { + noEscape: true + }); + return { + path: path.join(srcPath, "paginateHelper.ts"), + content: paginateHelperContents(pagingInfo.pageDetails) + }; +} diff --git a/packages/typespec-ts/src/rlc-common/buildParameterTypes.ts b/packages/typespec-ts/src/rlc-common/buildParameterTypes.ts new file mode 100644 index 0000000000..7aa35c77f4 --- /dev/null +++ b/packages/typespec-ts/src/rlc-common/buildParameterTypes.ts @@ -0,0 +1,575 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { + InterfaceDeclarationStructure, + Project, + PropertySignatureStructure, + SourceFile, + StructureKind +} from "ts-morph"; +import * as path from "path"; +import { + ObjectSchema, + ParameterMetadata, + ParameterMetadatas, + RLCModel, + Schema, + SchemaContext +} from "./interfaces.js"; +import { + getImportModuleName, + getParameterBaseName, + getParameterTypeName +} from "./helpers/nameConstructors.js"; +import { getImportSpecifier } from "./helpers/importsUtil.js"; +import { getObjectInterfaceDeclaration } from "./buildObjectTypes.js"; +import { getGeneratedWrapperTypes } from "./helpers/operationHelpers.js"; + +export function buildParameterTypes(model: RLCModel) { + const project = new Project(); + const srcPath = model.srcPath; + const filePath = path.join(srcPath, `parameters.ts`); + const partialBodyTypeNames = new Set(); + const parametersFile = project.createSourceFile(filePath, undefined, { + overwrite: true + }); + let hasHeaders = false; + + if (!model.parameters) { + return; + } + for (const requestParameter of model.parameters) { + const baseParameterName = getParameterBaseName( + requestParameter.operationGroup, + requestParameter.operationName + ); + const requestCount = requestParameter?.parameters?.length ?? 0; + const topParamName = getParameterTypeName(baseParameterName); + const subParamNames: string[] = []; + + // We need to loop the requests. An operation with multiple requests means that + // the operation can get different values for content-type and each value may + // have a different type associated to it. + for (let i = 0; i < requestCount; i++) { + const parameter = requestParameter.parameters[i]; + if (!parameter) { + continue; + } + const internalReferences = new Set(); + // In case we have more than one request to model we need to add a suffix to differentiate + const nameSuffix = i > 0 ? `${i}` : ""; + const parameterInterfaceName = + requestCount > 1 + ? `${baseParameterName}RequestParameters${nameSuffix}` + : topParamName; + const queryParameterDefinitions = buildQueryParameterDefinition( + model, + parameter, + baseParameterName, + internalReferences, + i + ); + const pathParameterDefinitions = buildPathParameterDefinitions( + model, + parameter, + baseParameterName, + parametersFile, + internalReferences, + i + ); + + const headerParameterDefinitions = buildHeaderParameterDefinitions( + parameter, + baseParameterName, + parametersFile, + internalReferences, + i + ); + + const contentTypeParameterDefinition = + buildContentTypeParametersDefinition( + parameter, + baseParameterName, + internalReferences, + i + ); + + const bodyParameterDefinition = buildBodyParametersDefinition( + parameter, + baseParameterName, + internalReferences, + i + ); + + const bodyTypeAlias = buildBodyTypeAlias(parameter, partialBodyTypeNames); + if (bodyTypeAlias) { + parametersFile.addTypeAlias(bodyTypeAlias); + } + + // Add interfaces for body and query parameters + parametersFile.addInterfaces([ + ...(bodyParameterDefinition ?? []), + ...(queryParameterDefinitions ?? []), + ...(pathParameterDefinitions ?? []), + ...(headerParameterDefinitions ? [headerParameterDefinitions] : []), + ...(contentTypeParameterDefinition + ? [contentTypeParameterDefinition] + : []) + ]); + + // Add Operation parameters type alias which is composed of the types we generated above + // plus the common type RequestParameters + parametersFile.addTypeAlias({ + name: parameterInterfaceName, + isExported: true, + type: [...internalReferences, "RequestParameters"].join(" & ") + }); + + subParamNames.push(parameterInterfaceName); + + if (headerParameterDefinitions !== undefined) { + hasHeaders = true; + } + } + // Add Operation parameters type alias which is composed of the types we generated above + // plus the common type RequestParameters + if (requestCount > 1) { + parametersFile.addTypeAlias({ + name: topParamName, + isExported: true, + type: [...subParamNames].join(" | ") + }); + } + } + + if (hasHeaders) { + parametersFile.addImportDeclarations([ + { + isTypeOnly: true, + namedImports: ["RawHttpHeadersInput"], + moduleSpecifier: getImportSpecifier( + "restPipeline", + model.importInfo.runtimeImports + ) + } + ]); + } + parametersFile.addImportDeclarations([ + { + isTypeOnly: true, + namedImports: ["RequestParameters"], + moduleSpecifier: getImportSpecifier( + "restClient", + model.importInfo.runtimeImports + ) + } + ]); + if ( + (model.importInfo.internalImports?.parameter?.importsSet?.size ?? 0) > 0 + ) { + parametersFile.addImportDeclarations([ + { + isTypeOnly: true, + namedImports: Array.from( + model.importInfo.internalImports.parameter.importsSet! + ), + moduleSpecifier: getImportModuleName( + { + cjsName: `./models`, + esModulesName: `./models.js` + }, + model + ) + } + ]); + } + return { path: filePath, content: parametersFile.getFullText() }; +} + +function buildQueryParameterDefinition( + model: RLCModel, + parameters: ParameterMetadatas, + baseName: string, + internalReferences: Set, + requestIndex: number +): InterfaceDeclarationStructure[] | undefined { + const queryParameters = (parameters?.parameters || []).filter( + (p) => p.type === "query" + ); + + if (!queryParameters.length) { + return undefined; + } + + const nameSuffix = requestIndex > 0 ? `${requestIndex}` : ""; + const queryParameterInterfaceName = `${baseName}QueryParam${nameSuffix}`; + const queryParameterPropertiesName = `${baseName}QueryParamProperties`; + + // Get the property signature for each query parameter + const propertiesDefinition = queryParameters.map((qp) => + getPropertyFromSchema(qp.param) + ); + // Get wrapper types for query parameters + const wrapperTypesDefinition = getGeneratedWrapperTypes(queryParameters).map( + (wrapObj) => { + return getObjectInterfaceDeclaration( + model, + wrapObj.name, + wrapObj, + [SchemaContext.Input], + new Set() + ); + } + ); + + const hasRequiredParameters = propertiesDefinition.some( + (p) => !p.hasQuestionToken + ); + + const propertiesInterface: InterfaceDeclarationStructure = { + kind: StructureKind.Interface, + isExported: true, + name: queryParameterPropertiesName, + properties: propertiesDefinition + }; + + const parameterInterface: InterfaceDeclarationStructure = { + kind: StructureKind.Interface, + isExported: true, + name: queryParameterInterfaceName, + properties: [ + { + name: "queryParameters", + type: queryParameterPropertiesName, + // Mark as optional if there are no required parameters + hasQuestionToken: !hasRequiredParameters + } + ] + }; + + // Mark the queryParameter interface for importing + internalReferences.add(queryParameterInterfaceName); + + return [...wrapperTypesDefinition, propertiesInterface, parameterInterface]; +} + +function getPropertyFromSchema(schema: Schema): PropertySignatureStructure { + const description = schema.description; + return { + name: schema.name, + ...(description && { docs: [{ description }] }), + type: schema.type, + hasQuestionToken: !schema.required, + kind: StructureKind.PropertySignature + }; +} + +function buildPathParameterDefinitions( + model: RLCModel, + parameters: ParameterMetadatas, + baseName: string, + parametersFile: SourceFile, + internalReferences: Set, + requestIndex: number +): InterfaceDeclarationStructure[] | undefined { + const pathParameters = (parameters.parameters || []).filter( + (p) => p.type === "path" + ); + if (!pathParameters.length) { + return undefined; + } + const allDefinitions: InterfaceDeclarationStructure[] = []; + + buildClientPathParameters(); + buildMethodWrapParameters(); + return allDefinitions; + function buildClientPathParameters() { + // we only have client-level path parameters if the source is from swagger + if (model.options?.sourceFrom === "TypeSpec") { + return; + } + const clientPathParams = pathParameters.length > 0 ? pathParameters : []; + const nameSuffix = requestIndex > 0 ? `${requestIndex}` : ""; + const pathParameterInterfaceName = `${baseName}PathParam${nameSuffix}`; + + const pathInterface = getPathInterfaceDefinition( + clientPathParams, + baseName + ); + + if (pathInterface) { + parametersFile.addInterface(pathInterface); + } + + internalReferences.add(pathParameterInterfaceName); + + allDefinitions.push({ + isExported: true, + kind: StructureKind.Interface, + name: pathParameterInterfaceName, + properties: [ + { + name: "pathParameters", + type: `${baseName}PathParameters`, + kind: StructureKind.PropertySignature + } + ] + }); + } + + function buildMethodWrapParameters() { + if (model.options?.sourceFrom === "Swagger") { + return; + } + // we only have method-level path parameters if the source is from typespec + const methodPathParams = pathParameters.length > 0 ? pathParameters : []; + + // we only need to build the wrapper types if the path parameters are objects + const wrapperTypesDefinition = getGeneratedWrapperTypes( + methodPathParams + ).map((wrap) => { + return getObjectInterfaceDeclaration( + model, + wrap.name, + wrap, + [SchemaContext.Input], + new Set() + ); + }); + allDefinitions.push(...wrapperTypesDefinition); + } +} + +function getPathInterfaceDefinition( + pathParameters: ParameterMetadata[], + baseName: string +): undefined | InterfaceDeclarationStructure { + const pathInterfaceName = `${baseName}PathParameters`; + return { + kind: StructureKind.Interface, + isExported: true, + name: pathInterfaceName, + properties: pathParameters.map((p: ParameterMetadata) => + getPropertyFromSchema(p.param) + ) + }; +} + +function buildHeaderParameterDefinitions( + parameters: ParameterMetadatas, + baseName: string, + parametersFile: SourceFile, + internalReferences: Set, + requestIndex: number +): InterfaceDeclarationStructure | undefined { + const headerParameters = (parameters.parameters || []).filter( + (p) => p.type === "header" && p.name !== "contentType" + ); + if (!headerParameters.length) { + return undefined; + } + + const nameSuffix = requestIndex > 0 ? `${requestIndex}` : ""; + const headerParameterInterfaceName = `${baseName}HeaderParam${nameSuffix}`; + + const headersInterface = getRequestHeaderInterfaceDefinition( + headerParameters, + baseName + ); + + let isOptional = true; + if (headersInterface) { + parametersFile.addInterface(headersInterface); + isOptional = !(headersInterface.properties || []).some( + (prop) => prop.hasQuestionToken === false + ); + } + + internalReferences.add(headerParameterInterfaceName); + + return { + isExported: true, + kind: StructureKind.Interface, + name: headerParameterInterfaceName, + properties: [ + { + name: "headers", + type: `RawHttpHeadersInput & ${baseName}Headers`, + kind: StructureKind.PropertySignature, + hasQuestionToken: isOptional + } + ] + }; +} + +function getRequestHeaderInterfaceDefinition( + headerParameters: ParameterMetadata[], + baseName: string +): undefined | InterfaceDeclarationStructure { + const headersInterfaceName = `${baseName}Headers`; + return { + kind: StructureKind.Interface, + isExported: true, + name: headersInterfaceName, + properties: headerParameters.map((h: ParameterMetadata) => + getPropertyFromSchema(h.param) + ) + }; +} + +function buildContentTypeParametersDefinition( + parameters: ParameterMetadatas, + baseName: string, + internalReferences: Set, + requestIndex: number +): InterfaceDeclarationStructure | undefined { + const mediaTypeParameters = (parameters.parameters || []).filter( + (p) => p.type === "header" && p.name === "contentType" + ); + if (!mediaTypeParameters.length) { + return undefined; + } + + const nameSuffix = requestIndex > 0 ? `${requestIndex}` : ""; + const mediaTypesParameterInterfaceName = `${baseName}MediaTypesParam${nameSuffix}`; + + // Mark the queryParameter interface for importing + internalReferences.add(mediaTypesParameterInterfaceName); + const firstMediaType = mediaTypeParameters[0]; + if (!firstMediaType) { + return undefined; + } + const mediaParam = firstMediaType.param; + + return { + isExported: true, + kind: StructureKind.Interface, + name: mediaTypesParameterInterfaceName, + properties: [getPropertyFromSchema(mediaParam)] + }; +} + +function buildBodyParametersDefinition( + parameters: ParameterMetadatas, + baseName: string, + internalReferences: Set, + requestIndex: number +): InterfaceDeclarationStructure[] { + const bodyParameters = parameters.body; + if ( + !bodyParameters || + !bodyParameters?.body || + !bodyParameters?.body.length + ) { + return []; + } + + const nameSuffix = requestIndex > 0 ? `${requestIndex}` : ""; + const bodyParameterInterfaceName = `${baseName}BodyParam${nameSuffix}`; + internalReferences.add(bodyParameterInterfaceName); + + // In case of formData we'd get multiple properties in body marked as partialBody + if (bodyParameters.isPartialBody) { + let allOptionalParts = true; + const propertiesDefinitions: PropertySignatureStructure[] = []; + for (const param of bodyParameters.body) { + if (param.required) { + allOptionalParts = false; + } + + propertiesDefinitions.push(getPropertyFromSchema(param)); + } + + const formBodyName = `${baseName}FormBody`; + const formBodyInterface: InterfaceDeclarationStructure = { + isExported: true, + kind: StructureKind.Interface, + name: formBodyName, + properties: propertiesDefinitions + }; + + return [ + { + isExported: true, + kind: StructureKind.Interface, + name: bodyParameterInterfaceName, + properties: [ + { + name: "body", + type: formBodyName, + hasQuestionToken: allOptionalParts + } + ] + }, + formBodyInterface + ]; + } else { + const firstBody = bodyParameters.body[0]; + if (!firstBody) { + return []; + } + const bodySignature = getPropertyFromSchema(firstBody); + + return [ + { + isExported: true, + kind: StructureKind.Interface, + name: bodyParameterInterfaceName, + properties: [ + { + docs: bodySignature.docs, + name: "body", + type: bodySignature.type, + hasQuestionToken: bodySignature.hasQuestionToken + } + ] + } + ]; + } +} + +export function buildBodyTypeAlias( + parameters: ParameterMetadatas, + partialBodyTypeNames: Set +) { + const bodyParameters = parameters.body; + if ( + !bodyParameters || + !bodyParameters?.body || + !bodyParameters?.body.length + ) { + return undefined; + } + const schema = bodyParameters.body[0] as ObjectSchema; + const headerParameters = (parameters.parameters || []).filter( + (p) => p.type === "header" && p.name === "contentType" + ); + if (!headerParameters.length || headerParameters.length > 1) { + return undefined; + } + + const firstHeader = headerParameters[0]; + if (!firstHeader) { + return undefined; + } + const contentType = firstHeader.param.type; + const description = `${schema.description}`; + const typeName = `${schema.typeName}ResourceMergeAndPatch`; + if (partialBodyTypeNames.has(typeName)) { + return null; + } else { + partialBodyTypeNames.add(typeName); + } + if (contentType.includes("application/merge-patch+json")) { + const type = `Partial<${schema.typeName}>`; + return { + // kind: StructureKind.TypeAlias, + ...(description && { docs: [{ description }] }), + name: `${typeName}`, + type, + isExported: true + }; + } + return undefined; +} diff --git a/packages/typespec-ts/src/rlc-common/buildPollingHelper.ts b/packages/typespec-ts/src/rlc-common/buildPollingHelper.ts new file mode 100644 index 0000000000..85ab1337e1 --- /dev/null +++ b/packages/typespec-ts/src/rlc-common/buildPollingHelper.ts @@ -0,0 +1,83 @@ +import { OPERATION_LRO_HIGH_PRIORITY, RLCModel } from "./interfaces.js"; +import * as path from "path"; +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: to fix the handlebars issue +import hbs from "handlebars"; +import { hasPollingOperations } from "./helpers/operationHelpers.js"; +import { pollingContent } from "./static/pollingContent.js"; + +interface LroDetail { + clientOverload?: boolean; + overloadMap?: ResponseMap[]; + importedResponses?: string[]; + isEsm?: boolean; +} + +interface ResponseMap { + initialResponses: string; + finalResponses: string; + precedence?: number; +} + +export function buildPollingHelper(model: RLCModel) { + if (!hasPollingOperations(model)) { + return; + } + + const lroDetail: LroDetail = buildLroHelperDetail(model); + const readmeFileContents = hbs.compile(pollingContent, { noEscape: true }); + const { srcPath } = model; + return { + path: path.join(srcPath, "pollingHelper.ts"), + content: readmeFileContents(lroDetail) + }; +} + +function buildLroHelperDetail(model: RLCModel): LroDetail { + if (!model.helperDetails?.clientLroOverload) { + return { + clientOverload: false, + isEsm: model.options?.moduleKind === "esm" + }; + } + const mapDetail = []; + const pathDictionary = model.paths; + const responses = new Set(); + for (const details of Object.values(pathDictionary)) { + for (const methodDetails of Object.values(details.methods)) { + const firstMethod = methodDetails[0]; + if (!firstMethod) { + continue; + } + const lroDetail = firstMethod.operationHelperDetail?.lroDetails; + if (lroDetail?.isLongRunning) { + const initialResponses = firstMethod.responseTypes.success.concat( + firstMethod.responseTypes.error + ); + + const finalResponse = lroDetail.logicalResponseTypes?.success.concat( + firstMethod.responseTypes.error + ); + + if (initialResponses && finalResponse) { + initialResponses.forEach((n) => responses.add(n)); + finalResponse.forEach((n) => responses.add(n)); + mapDetail!.push({ + initialResponses: initialResponses.join("|"), + finalResponses: finalResponse.join("|"), + precedence: lroDetail.precedence ?? OPERATION_LRO_HIGH_PRIORITY + }); + } + } + } + } + + // Sorted by the precedence + mapDetail.sort((d1, d2) => d1.precedence - d2.precedence); + return { + clientOverload: responses.size > 0 && mapDetail.length > 0, + importedResponses: Array.from(responses), + overloadMap: mapDetail, + isEsm: model.options?.moduleKind === "esm" + }; +} diff --git a/packages/typespec-ts/src/rlc-common/buildResponseTypes.ts b/packages/typespec-ts/src/rlc-common/buildResponseTypes.ts new file mode 100644 index 0000000000..89e438638f --- /dev/null +++ b/packages/typespec-ts/src/rlc-common/buildResponseTypes.ts @@ -0,0 +1,203 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { + InterfaceDeclarationStructure, + OptionalKind, + Project, + PropertySignatureStructure, + StructureKind +} from "ts-morph"; +import { + ResponseHeaderSchema, + ResponseMetadata, + RLCModel +} from "./interfaces.js"; +import * as path from "path"; +import { + getImportModuleName, + getResponseBaseName, + getResponseTypeName +} from "./helpers/nameConstructors.js"; +import { getImportSpecifier } from "./helpers/importsUtil.js"; + +let hasErrorResponse = false; +export function buildResponseTypes(model: RLCModel) { + const project = new Project(); + const srcPath = model.srcPath; + const filePath = path.join(srcPath, `responses.ts`); + hasErrorResponse = false; + const responsesFile = project.createSourceFile(filePath, undefined, { + overwrite: true + }); + // Set used to track down which models need to be imported + // Track if we need to import RawHttpHeaders + let hasHeaders = false; + if (!model.responses) { + return; + } + for (const operationResponse of model.responses) { + for (const response of operationResponse.responses) { + // Building the response type base name + const baseResponseName = getResponseBaseName( + operationResponse.operationGroup, + operationResponse.operationName, + response.statusCode + ); + + // Build the response header + const headersInterface: InterfaceDeclarationStructure | undefined = + getResponseHeaderInterfaceDefinition(response, baseResponseName); + if (headersInterface) { + hasHeaders = true; + responsesFile.addInterface(headersInterface); + } + + // Get the information to build the Response Interface + const responseTypeName = + response.predefinedName ?? getResponseTypeName(baseResponseName); + const responseProperties = getResponseInterfaceProperties( + response, + headersInterface?.name + ); + + const responseInterfaceDefinition: OptionalKind = + { + name: responseTypeName, + properties: responseProperties, + isExported: true, + extends: ["HttpResponse"] + }; + + // Only add a description if one was provided in the Swagger + // otherwise skip to avoid having empty TSDoc lines + if (response.description) { + responseInterfaceDefinition.docs = [ + { description: response.description } + ]; + } + + // Add the response interface to the responses file + responsesFile.addInterface(responseInterfaceDefinition); + } + } + + if (hasHeaders) { + responsesFile.addImportDeclarations([ + { + isTypeOnly: true, + namedImports: ["RawHttpHeaders"], + moduleSpecifier: getImportSpecifier( + "restPipeline", + model.importInfo.runtimeImports + ) + } + ]); + } + const namedImports = ["HttpResponse"]; + if (hasErrorResponse) { + namedImports.push("ErrorResponse"); + } + responsesFile.addImportDeclarations([ + { + isTypeOnly: true, + namedImports, + moduleSpecifier: getImportSpecifier( + "restClient", + model.importInfo.runtimeImports + ) + } + ]); + + if ((model.importInfo.internalImports.response?.importsSet?.size ?? 0) > 0) { + const modelNamedImports = Array.from( + model.importInfo.internalImports.response!.importsSet! + ).filter((modelName) => { + return !(modelName === "ErrorResponseOutput" && hasErrorResponse); + }); + responsesFile.addImportDeclarations([ + { + isTypeOnly: true, + namedImports: modelNamedImports, + moduleSpecifier: getImportModuleName( + { + cjsName: `./outputModels`, + esModulesName: `./outputModels.js` + }, + model + ) + } + ]); + } + return { path: filePath, content: responsesFile.getFullText() }; +} + +function getResponseHeaderInterfaceDefinition( + response: ResponseMetadata, + baseName: string +): undefined | InterfaceDeclarationStructure { + // Check if there are any required headers + if (!response.headers) { + return; + } + const headersInterfaceName = `${baseName}Headers`; + return { + kind: StructureKind.Interface, + isExported: true, + name: headersInterfaceName, + properties: response?.headers.map((h: ResponseHeaderSchema) => { + const description = h.description; + return { + name: h.name, + ...(description && { docs: [{ description }] }), + type: h.type, + hasQuestionToken: !h.required + }; + }) + }; +} + +/** + * Gets the properties that need to be part of the response interface + */ +function getResponseInterfaceProperties( + response: ResponseMetadata, + headersInterfaceName?: string +) { + const statusCode = response.statusCode; + const responseProperties: PropertySignatureStructure[] = [ + { + name: "status", + type: statusCode === "default" ? `string` : `"${statusCode}"`, + kind: StructureKind.PropertySignature + } + ]; + + if (response.body) { + const description = response.body.description; + let type = response.body.type; + if ( + response.body.type === "ErrorResponseOutput" && + response.body.fromCore + ) { + type = "ErrorResponse"; + hasErrorResponse = true; + } + responseProperties.push({ + name: "body", + type, + kind: StructureKind.PropertySignature, + ...(description && { docs: [{ description }] }) + }); + } + + if (headersInterfaceName) { + responseProperties.push({ + name: "headers", + type: `RawHttpHeaders & ${headersInterfaceName}`, + kind: StructureKind.PropertySignature + }); + } + + return responseProperties; +} diff --git a/packages/typespec-ts/src/rlc-common/buildSamples.ts b/packages/typespec-ts/src/rlc-common/buildSamples.ts new file mode 100644 index 0000000000..65f2c54ec8 --- /dev/null +++ b/packages/typespec-ts/src/rlc-common/buildSamples.ts @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { RLCModel, RLCSampleGroup, File as RLCFile } from "./interfaces.js"; +import { sampleTemplate } from "./static/sampleTemplate.js"; +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: to fix the handlebars issue +import hbs from "handlebars"; +import * as path from "path"; + +// Build sample files for the model based on the sample groups +export function buildSamples(model: RLCModel) { + if (!model.options || !model.options.packageDetails) { + return; + } + const sampleGroups: RLCSampleGroup[] | undefined = model.sampleGroups; + if (!sampleGroups || sampleGroups.length === 0) { + return; + } + const sampleFiles: RLCFile[] = []; + for (const sampleGroup of sampleGroups) { + const sampleGroupFileContents = hbs.compile(sampleTemplate, { + noEscape: true + }); + const filePath = path.join("samples-dev", `${sampleGroup.filename}.ts`); + sampleFiles.push({ + path: filePath, + content: sampleGroupFileContents(sampleGroup) + }); + } + return sampleFiles; +} diff --git a/packages/typespec-ts/src/rlc-common/buildSchemaType.ts b/packages/typespec-ts/src/rlc-common/buildSchemaType.ts new file mode 100644 index 0000000000..b4ffa3b1b3 --- /dev/null +++ b/packages/typespec-ts/src/rlc-common/buildSchemaType.ts @@ -0,0 +1,81 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { Project } from "ts-morph"; +import * as path from "path"; +import { + buildObjectAliases, + buildObjectInterfaces, + buildPolymorphicAliases +} from "./buildObjectTypes.js"; +import { RLCModel, SchemaContext } from "./interfaces.js"; +import { getImportSpecifier } from "./helpers/importsUtil.js"; + +/** + * Generates types to represent schema definitions in the swagger + */ +export function buildSchemaTypes(model: RLCModel) { + const { srcPath } = model; + const project = new Project(); + let filePath = path.join(srcPath, `models.ts`); + const inputModelFile = generateModelFiles(model, project, filePath, [ + SchemaContext.Input + ]); + filePath = path.join(srcPath, `outputModels.ts`); + const outputModelFile = generateModelFiles(model, project, filePath, [ + SchemaContext.Output, + SchemaContext.Exception + ]); + return { inputModelFile, outputModelFile }; +} + +export function generateModelFiles( + model: RLCModel, + project: Project, + filePath: string, + schemaContext: SchemaContext[] +) { + // Track models that need to be imported + const importedModels = new Set(); + const objectsDefinitions = buildObjectInterfaces( + model, + importedModels, + schemaContext + ); + + const objectTypeAliases = buildPolymorphicAliases(model, schemaContext); + + const objectAliases = buildObjectAliases( + model, + importedModels, + schemaContext + ); + + if ( + objectTypeAliases.length || + objectsDefinitions.length || + objectAliases.length + ) { + const modelsFile = project.createSourceFile(filePath, undefined, { + overwrite: true + }); + + modelsFile.addInterfaces(objectsDefinitions); + modelsFile.addTypeAliases(objectTypeAliases); + modelsFile.addTypeAliases(objectAliases); + if (importedModels.size > 0) { + modelsFile.addImportDeclarations([ + { + isTypeOnly: true, + namedImports: [...Array.from(importedModels || [])], + moduleSpecifier: getImportSpecifier( + "restClient", + model.importInfo.runtimeImports + ) + } + ]); + } + return { path: filePath, content: modelsFile.getFullText() }; + } + return undefined; +} diff --git a/packages/typespec-ts/src/rlc-common/buildSerializeHelper.ts b/packages/typespec-ts/src/rlc-common/buildSerializeHelper.ts new file mode 100644 index 0000000000..8f77c16ea4 --- /dev/null +++ b/packages/typespec-ts/src/rlc-common/buildSerializeHelper.ts @@ -0,0 +1,49 @@ +import { RLCModel } from "./interfaces.js"; +import * as path from "path"; +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: to fix the handlebars issue +import hbs from "handlebars"; +import { + hasCsvCollection, + hasMultiCollection, + hasPipeCollection, + hasSsvCollection, + hasTsvCollection +} from "./helpers/operationHelpers.js"; +import { + buildCsvCollectionContent, + buildMultiCollectionContent, + buildPipeCollectionContent, + buildSsvCollectionContent, + buildTsvCollectionContent +} from "./static/serializeHelper.js"; + +export function buildSerializeHelper(model: RLCModel) { + let serializeHelperContent = ""; + if (hasMultiCollection(model)) { + serializeHelperContent += "\n" + buildMultiCollectionContent; + } + if (hasPipeCollection(model)) { + serializeHelperContent += "\n" + buildPipeCollectionContent; + } + if (hasSsvCollection(model)) { + serializeHelperContent += "\n" + buildSsvCollectionContent; + } + if (hasTsvCollection(model)) { + serializeHelperContent += "\n" + buildTsvCollectionContent; + } + if (hasCsvCollection(model)) { + serializeHelperContent += "\n" + buildCsvCollectionContent; + } + if (serializeHelperContent !== "") { + const readmeFileContents = hbs.compile(serializeHelperContent, { + noEscape: true + }); + const { srcPath } = model; + return { + path: path.join(srcPath, "serializeHelper.ts"), + content: readmeFileContents({}) + }; + } + return undefined; +} diff --git a/packages/typespec-ts/src/rlc-common/buildTopLevelIndexFile.ts b/packages/typespec-ts/src/rlc-common/buildTopLevelIndexFile.ts new file mode 100644 index 0000000000..981d476895 --- /dev/null +++ b/packages/typespec-ts/src/rlc-common/buildTopLevelIndexFile.ts @@ -0,0 +1,65 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { NameType, normalizeName } from "./helpers/nameUtils.js"; +import { RLCModel } from "./interfaces.js"; +import { Project } from "ts-morph"; +import * as path from "path"; +import { getRelativePartFromSrcPath } from "./helpers/pathUtils.js"; +import { getImportModuleName } from "./helpers/nameConstructors.js"; + +const batchOutputFolder: [string, string, string][] = []; + +export function buildTopLevelIndex(model: RLCModel) { + if (!model.options) { + return undefined; + } + const project = new Project(); + const { srcPath } = model; + const { multiClient } = model.options; + const batch = model.options.batch; + if (srcPath) { + const clientName = model.libraryName; + const moduleName = normalizeName(clientName, NameType.File); + const relativePath = + "./" + + getRelativePartFromSrcPath(srcPath, model.options.isModularLibrary); + batchOutputFolder.push([relativePath, clientName, moduleName]); + } + if ( + multiClient && + batch && + batch.length > 1 && + batchOutputFolder.length === batch.length + ) { + const indexFile = project.createSourceFile("index.ts", undefined, { + overwrite: true + }); + const allModules: string[] = []; + batchOutputFolder.forEach((item) => { + indexFile.addImportDeclaration({ + isTypeOnly: true, + namespaceImport: item[1], + moduleSpecifier: getImportModuleName( + { + cjsName: `${item[0]}`, + esModulesName: `${item[0]}/index.js` + }, + model + ) + }); + allModules.push(item[1]); + }); + indexFile.addExportDeclaration({ + namedExports: [...allModules] + }); + const content = indexFile.getFullText(); + const filePath = path.join( + srcPath.substring(0, srcPath.lastIndexOf("src") + 4), + model.options.isModularLibrary ? "rest" : "", + `index.ts` + ); + return { path: filePath, content }; + } + return undefined; +} diff --git a/packages/typespec-ts/src/rlc-common/helpers/apiVersionUtil.ts b/packages/typespec-ts/src/rlc-common/helpers/apiVersionUtil.ts new file mode 100644 index 0000000000..cd9104eedb --- /dev/null +++ b/packages/typespec-ts/src/rlc-common/helpers/apiVersionUtil.ts @@ -0,0 +1,53 @@ +import { ApiVersionInfo, ApiVersionPosition, UrlInfo } from "../interfaces.js"; + +/** + * Extract the path api-version detail from UrlInfo, return undefined if no valid api-version parameter + * @param urlInfo UrlInfo detail + * @returns path api-version detail + */ +export function extractPathApiVersion( + urlInfo?: UrlInfo +): ApiVersionInfo | undefined { + if (!urlInfo) { + return; + } + const param = urlInfo.urlParameters?.filter( + (p) => + p.name.toLowerCase() === "api-version" || + p.name.toLowerCase() === "apiversion" + ); + if (!param || param?.length < 1) { + return; + } + const detail: ApiVersionInfo = { + definedPosition: "path", + isCrossedVersion: Boolean(param?.length > 1), + defaultValue: + param.length === 1 ? (param[0]?.value as string | undefined) : undefined, + required: true + }; + return detail; +} + +/** + * Extract the final position value from api-version in query and path defined. + * it could be in either the url or the operation level, + * and in operation level, it could be either path or query. + * @param operationApiVersion api-version detail in both query and path + * @param urlVersionDetail api-version detail in parameterized host + * @returns calculated combined position info + */ +export function extractDefinedPosition( + operationApiVersion?: ApiVersionInfo, + urlVersionDetail?: ApiVersionInfo +): ApiVersionPosition { + let pos: ApiVersionPosition = "none"; + if (operationApiVersion && urlVersionDetail) { + pos = "duplicate"; + } else if (operationApiVersion?.definedPosition) { + pos = operationApiVersion.definedPosition!; + } else if (urlVersionDetail) { + pos = "baseurl"; + } + return pos; +} diff --git a/packages/typespec-ts/src/rlc-common/helpers/importsUtil.ts b/packages/typespec-ts/src/rlc-common/helpers/importsUtil.ts new file mode 100644 index 0000000000..58e620e613 --- /dev/null +++ b/packages/typespec-ts/src/rlc-common/helpers/importsUtil.ts @@ -0,0 +1,187 @@ +import { SourceFile } from "ts-morph"; +import { ImportType, Imports, PackageFlavor } from "../interfaces.js"; + +/** + * Build the common imports for generated SDK + * @param flavor flavor of SDK to generate, if any. When set to "azure", Azure Core packages will be used. When unset, the generic `ts-http-runtime` package will be used. + * @returns + */ +export function buildRuntimeImports(flavor?: PackageFlavor): Imports { + if (flavor === "azure") { + return { + restClient: { + type: "restClient", + specifier: "@azure-rest/core-client", + version: "^2.0.0" + }, + coreAuth: { + type: "coreAuth", + specifier: "@azure/core-auth", + version: "^1.6.0" + }, + restPipeline: { + type: "restPipeline", + specifier: "@azure/core-rest-pipeline", + version: "^1.14.0" + }, + coreUtil: { + type: "coreUtil", + specifier: "@azure/core-util", + version: "^1.4.0" + }, + coreLogger: { + type: "coreLogger", + specifier: "@azure/logger", + version: "^1.0.4" + }, + azureEslintPlugin: { + type: "azureEslintPlugin", + specifier: "@azure/eslint-plugin-azure-sdk", + version: "^3.0.0" + }, + azureTestRecorder: { + type: "azureTestRecorder", + specifier: "@azure-tools/test-recorder", + version: "^3.0.0" + }, + azureCoreLro: { + type: "azureCoreLro", + specifier: "@azure/core-lro" + } + } as Imports; + } else { + // In non-azure branded scope we only have one dependency that is ts-http-runtime + return { + commonFallback: { + type: "commonFallback", + specifier: "@typespec/ts-http-runtime", + version: "0.1.0" + } + } as Imports; + } +} + +/** + * Initialize the inner imports for parameter and response, the import set would be used for referred models + * @returns + */ +export function initInternalImports(): Imports { + return { + parameter: { + type: "parameter", + importsSet: new Set() + }, + response: { + type: "response", + importsSet: new Set() + }, + rlcIndex: { + type: "rlcIndex", + importsSet: new Set() + }, + modularModel: { + type: "modularModel", + importsSet: new Set() + }, + rlcClientFactory: { + type: "rlcClientFactory", + importsSet: new Set() + }, + rlcClientDefinition: { + type: "rlcClientDefinition", + importsSet: new Set() + }, + serializerHelpers: { + type: "serializerHelpers", + importsSet: new Set() + } + } as Imports; +} + +export function getImportSpecifier( + importType: ImportType, + imports?: Imports, + includeFallback = true +): string { + imports = imports ?? ({} as Imports); + const defaultPackageMap: Record = { + restClient: "@azure-rest/core-client", + coreAuth: "@azure/core-auth", + restPipeline: "@azure/core-rest-pipeline", + coreUtil: "@azure/core-util", + coreLogger: "@azure/logger", + azureCoreLro: "@azure/core-lro" + } as any; + if (!includeFallback) { + return imports[importType]?.specifier ?? ""; + } + return ( + (imports[importType] ?? imports.commonFallback)?.specifier ?? + defaultPackageMap[importType] ?? + "" + ); +} + +export function addImportToSpecifier( + importType: ImportType, + runtimeImports: Imports, + importedName: string +): void { + const specifier = getImportSpecifier(importType, runtimeImports); + const importSet = runtimeImports[importType]?.importsSet; + if (!importSet) { + runtimeImports[importType] = { + type: importType, + specifier, + importsSet: new Set().add(importedName) + }; + } else { + importSet.add(importedName); + } +} + +export function clearImportSets(runtimeImports: Imports): void { + for (const importType of Object.values(runtimeImports)) { + importType.importsSet?.clear(); + } +} + +export function addImportsToFiles( + runtimeImports: Imports, + file: SourceFile, + internalSpecifierMap?: Record +): void { + Object.values(runtimeImports) + .filter((importType) => { + return importType.importsSet?.size; + }) + .forEach((importType) => { + const specifier = + internalSpecifierMap?.[importType.type] ?? importType.specifier!; + let hasModifier = false; + if (!specifier) { + return; + } + file + .getImportDeclarations() + .filter((importDeclaration) => { + return importDeclaration.getModuleSpecifierValue() === specifier; + }) + .forEach((importDeclaration) => { + hasModifier = true; + importDeclaration.addNamedImports([ + ...importType.importsSet!.values() + ]); + }); + + if (!hasModifier) { + file.addImportDeclaration({ + isTypeOnly: true, + moduleSpecifier: specifier, + namedImports: [...importType.importsSet!.values()] + }); + return; + } + }); + clearImportSets(runtimeImports); +} diff --git a/packages/typespec-ts/src/rlc-common/helpers/nameConstructors.ts b/packages/typespec-ts/src/rlc-common/helpers/nameConstructors.ts new file mode 100644 index 0000000000..15cea9942e --- /dev/null +++ b/packages/typespec-ts/src/rlc-common/helpers/nameConstructors.ts @@ -0,0 +1,144 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { RLCModel } from "../interfaces.js"; +import { NameType, normalizeName } from "./nameUtils.js"; + +/** + * Get the response type name by baseName or operatioName & statusCode + * @param baseResponseName + */ +export function getResponseTypeName(baseResponseName: string): string; +export function getResponseTypeName( + operationGroup: string, + operationName: string, + statusCode: string +): string; +export function getResponseTypeName( + baseNameOrOperationGroup: string, + operationName?: string, + statusCode?: string +): string { + if (operationName) { + baseNameOrOperationGroup = getResponseBaseName( + baseNameOrOperationGroup, + operationName!, + statusCode || "" + ); + } + return normalizeName( + `${baseNameOrOperationGroup}Response`, + NameType.Interface + ); +} + +export function getLroLogicalResponseName( + operationGroup: string, + operationName: string +) { + return normalizeName( + `${operationGroup}_${operationName}_Logical_Response`, + NameType.Interface + ); +} + +/** + * The prefix of all response types + * @param operationGroup operation group name e.g string_PutEmpty + * @param operationName operation name D e.g string_PutEmpty + * @param statusCode 2XX, 4XX, 5XX, default etc. + * @returns normolized base name e.g StringPutEmpty200 + */ +export function getResponseBaseName( + operationGroup: string, + operationName: string, + statusCode: string +) { + return normalizeName( + `${operationGroup}_${normalizeName( + operationName, + NameType.Interface, + true + )}_${statusCode}`, + NameType.Interface + ); +} + +/** + * The prefix of all parameter relevant types + * @param operationName is composed with operationGroup and operationID e.g string_PutEmpty + * @returns + */ +export function getParameterBaseName( + operationGroup: string, + operationName: string +) { + return normalizeName( + `${operationGroup}_${operationName}`, + NameType.Interface + ); +} + +/** + * Get the top-layer parameter name + * @param operationGroup operation group name + * @param operationName is composed with operationGroup and operationID e.g string_PutEmpty + * @returns top-layer parameter name e.g StringPutEmptParameters + */ +export function getParameterTypeName(baseName: string): string; +export function getParameterTypeName( + operationGroup: string, + operationName: string +): string; +export function getParameterTypeName( + baseNameOrOperationGroup: string, + operationName?: string +) { + if (operationName) { + baseNameOrOperationGroup = getParameterBaseName( + baseNameOrOperationGroup, + operationName! + ); + } + + return normalizeName( + `${baseNameOrOperationGroup}_Parameters`, + NameType.Interface + ); +} + +export interface ModuleName { + esModulesName: string; + cjsName: string; +} +/** + * This is a helper function that gets the right import module depending on the type of + * library being generated + */ +export function getImportModuleName(name: ModuleName, codeModel: RLCModel) { + if (codeModel.options?.moduleKind === "cjs") { + return name.cjsName; + } + return name.esModulesName; +} + +export function getClientName(model: RLCModel) { + const clientName = model.libraryName; + const clientInterfaceName = model.options?.isModularLibrary + ? model.libraryName + : clientName.endsWith("Client") + ? `${clientName}` + : `${clientName}Client`; + + return clientInterfaceName; +} + +export function getMultipartPartTypeName(schemaName: string, partName: string) { + const name = normalizeName(partName, NameType.Interface); + const bodyParamName = normalizeName(schemaName, NameType.Interface); + + return normalizeName( + `${bodyParamName}_${name}_PartDescriptor`, + NameType.Interface + ); +} diff --git a/packages/typespec-ts/src/rlc-common/helpers/nameUtils.ts b/packages/typespec-ts/src/rlc-common/helpers/nameUtils.ts new file mode 100644 index 0000000000..5249911c31 --- /dev/null +++ b/packages/typespec-ts/src/rlc-common/helpers/nameUtils.ts @@ -0,0 +1,319 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +export interface NormalizeNameOption { + shouldGuard?: boolean; + customReservedNames?: ReservedName[]; + casingOverride?: CasingConvention; + numberPrefixOverride?: string; +} + +export interface ReservedName { + name: string; + reservedFor: NameType[]; +} + +export enum NameType { + Class, + File, + Interface, + Property, + Parameter, + Operation, + OperationGroup, + Method, + EnumMemberName +} + +const Newable = [NameType.Class, NameType.Interface, NameType.OperationGroup]; + +export const ReservedModelNames: ReservedName[] = [ + { name: "any", reservedFor: [NameType.Parameter] }, + { name: "as", reservedFor: [NameType.Parameter] }, + { name: "assert", reservedFor: [NameType.Parameter] }, + { name: "async", reservedFor: [NameType.Parameter] }, + { name: "await", reservedFor: [NameType.Parameter, NameType.Method] }, + { name: "boolean", reservedFor: [NameType.Parameter, ...Newable] }, + { name: "break", reservedFor: [NameType.Parameter, NameType.Method] }, + { name: "case", reservedFor: [NameType.Parameter, NameType.Method] }, + { name: "catch", reservedFor: [NameType.Parameter, NameType.Method] }, + { name: "class", reservedFor: [NameType.Parameter, NameType.Method] }, + { name: "const", reservedFor: [NameType.Parameter, NameType.Method] }, + { name: "constructor", reservedFor: [NameType.Parameter] }, + { name: "continue", reservedFor: [NameType.Parameter, NameType.Method] }, + { name: "date", reservedFor: [NameType.Parameter, ...Newable] }, + { name: "debugger", reservedFor: [NameType.Parameter, NameType.Method] }, + { name: "declare", reservedFor: [NameType.Parameter] }, + { name: "default", reservedFor: [NameType.Parameter, NameType.Method] }, + { + name: "delete", + reservedFor: [NameType.Parameter, NameType.Operation, NameType.Method] + }, + { name: "do", reservedFor: [NameType.Parameter, NameType.Method] }, + { name: "else", reservedFor: [NameType.Parameter, NameType.Method] }, + { name: "enum", reservedFor: [NameType.Parameter, NameType.Method] }, + { name: "error", reservedFor: [NameType.Parameter, ...Newable] }, + { + name: "export", + reservedFor: [NameType.Parameter, NameType.Operation, NameType.Method] + }, + { name: "extends", reservedFor: [NameType.Parameter, NameType.Method] }, + { name: "false", reservedFor: [NameType.Parameter, NameType.Method] }, + { name: "finally", reservedFor: [NameType.Parameter, NameType.Method] }, + { name: "for", reservedFor: [NameType.Parameter, NameType.Method] }, + { name: "from", reservedFor: [NameType.Parameter] }, + { + name: "function", + reservedFor: [NameType.Parameter, ...Newable, NameType.Method] + }, + { name: "get", reservedFor: [NameType.Parameter] }, + { name: "if", reservedFor: [NameType.Parameter, NameType.Method] }, + { name: "implements", reservedFor: [NameType.Parameter] }, + { name: "import", reservedFor: [NameType.Parameter, NameType.Method] }, + { name: "in", reservedFor: [NameType.Parameter, NameType.Method] }, + { name: "instanceof", reservedFor: [NameType.Parameter, NameType.Method] }, + { name: "interface", reservedFor: [NameType.Parameter] }, + { name: "let", reservedFor: [NameType.Parameter, NameType.Method] }, + { name: "module", reservedFor: [NameType.Parameter] }, + { name: "new", reservedFor: [NameType.Parameter, NameType.Method] }, + { name: "null", reservedFor: [NameType.Parameter, NameType.Method] }, + { name: "number", reservedFor: [NameType.Parameter, ...Newable] }, + { name: "of", reservedFor: [NameType.Parameter] }, + { name: "package", reservedFor: [NameType.Parameter] }, + { name: "private", reservedFor: [NameType.Parameter] }, + { name: "protected", reservedFor: [NameType.Parameter] }, + { + name: "public", + reservedFor: [NameType.Parameter, NameType.Operation, NameType.Method] + }, + { name: "requestoptions", reservedFor: [NameType.Parameter] }, + { name: "require", reservedFor: [NameType.Parameter, NameType.Method] }, + { name: "return", reservedFor: [NameType.Parameter, NameType.Method] }, + { name: "set", reservedFor: [NameType.Parameter, ...Newable] }, + { name: "static", reservedFor: [NameType.Parameter, NameType.Method] }, + { name: "string", reservedFor: [NameType.Parameter, ...Newable] }, + { name: "super", reservedFor: [NameType.Parameter, NameType.Method] }, + { name: "switch", reservedFor: [NameType.Parameter, NameType.Method] }, + { name: "symbol", reservedFor: [NameType.Parameter, ...Newable] }, + { name: "this", reservedFor: [NameType.Parameter, NameType.Method] }, + { name: "throw", reservedFor: [NameType.Parameter, NameType.Method] }, + { name: "true", reservedFor: [NameType.Parameter, NameType.Method] }, + { name: "try", reservedFor: [NameType.Parameter, NameType.Method] }, + { name: "type", reservedFor: [NameType.Parameter] }, + { name: "typeof", reservedFor: [NameType.Parameter, NameType.Method] }, + { name: "var", reservedFor: [NameType.Parameter, NameType.Method] }, + { name: "void", reservedFor: [NameType.Parameter, NameType.Method] }, + { name: "while", reservedFor: [NameType.Parameter, NameType.Method] }, + { name: "with", reservedFor: [NameType.Parameter, NameType.Method] }, + { name: "yield", reservedFor: [NameType.Parameter, NameType.Method] }, + { name: "arguments", reservedFor: [NameType.Parameter, NameType.Method] }, + { name: "global", reservedFor: [...Newable] }, + // reserve client for codegen + { name: "client", reservedFor: [NameType.Parameter] }, + { name: "endpoint", reservedFor: [NameType.Parameter] }, + { name: "apiVersion", reservedFor: [NameType.Parameter] } +]; + +export enum CasingConvention { + Pascal, + Camel +} + +export function guardReservedNames( + name: string, + nameType: NameType, + customReservedNames: ReservedName[] = [] +): string { + const [prefix, suffix] = getAffix(nameType); + return [...ReservedModelNames, ...customReservedNames] + .filter((r) => r.reservedFor.includes(nameType)) + .find((r) => r.name === name.toLowerCase()) + ? `${prefix}${name}${suffix}` + : name; +} + +function getAffix(nameType?: NameType): [string, string] { + switch (nameType) { + case NameType.File: + case NameType.Operation: + return ["", ""]; + case NameType.Property: + return ["", "Property"]; + case NameType.OperationGroup: + return ["", "Operations"]; + case NameType.Parameter: + return ["", "Param"]; + case NameType.Method: + return ["$", ""]; + case NameType.Class: + case NameType.Interface: + default: + return ["", "Model"]; + } +} + +export function normalizeName( + name: string, + nameType: NameType, + shouldGuard?: boolean, + customReservedNames?: ReservedName[], + casingOverride?: CasingConvention, + oriName?: string +): string; +export function normalizeName( + name: string, + nameType: NameType, + options?: NormalizeNameOption +): string; +export function normalizeName( + name: string, + nameType: NameType, + optionsOrShouldGuard?: NormalizeNameOption | boolean, + optionalCustomReservedNames?: ReservedName[], + optionalCasingOverride?: CasingConvention, + oriName?: string +): string { + let shouldGuard: boolean | undefined, + customReservedNames: ReservedName[], + casingOverride: CasingConvention | undefined, + numberPrefixOverride: string | undefined; + if (typeof optionsOrShouldGuard === "boolean") { + shouldGuard = optionsOrShouldGuard; + customReservedNames = optionalCustomReservedNames ?? []; + casingOverride = optionalCasingOverride; + } else { + shouldGuard = optionsOrShouldGuard?.shouldGuard; + customReservedNames = optionsOrShouldGuard?.customReservedNames ?? []; + casingOverride = optionsOrShouldGuard?.casingOverride; + numberPrefixOverride = optionsOrShouldGuard?.numberPrefixOverride; + } + if ((oriName ?? name).startsWith("$DO_NOT_NORMALIZE$")) { + return (oriName ?? name).replace("$DO_NOT_NORMALIZE$", ""); + } + const casingConvention = casingOverride ?? getCasingConvention(nameType); + const parts = deconstruct(name); + if (parts.length === 0) { + return name; + } + const [firstPart, ...otherParts] = parts; + const normalizedFirstPart = toCasing(firstPart ?? "", casingConvention, true); + const normalizedParts = (otherParts || []) + .map((part) => toCasing(part, CasingConvention.Pascal)) + .join(""); + + const normalized = `${normalizedFirstPart}${normalizedParts}`; + const result = shouldGuard + ? guardReservedNames(normalized, nameType, customReservedNames) + : normalized; + return fixLeadingNumber(result, nameType, numberPrefixOverride); +} + +export function fixLeadingNumber( + name: string, + nameType: NameType, + prefix: string = "_" +): string { + const casingConvention = getCasingConvention(nameType); + if (!name || !name.match(/^[-.]?\d/)) { + return name; + } + return `${toCasing(prefix, casingConvention)}${name}`; +} + +function isFullyUpperCase( + identifier: string, + maxUppercasePreserve: number = 3 +) { + const len = identifier.length; + if (len > 1) { + if ( + len <= maxUppercasePreserve && + identifier === identifier.toUpperCase() + ) { + return true; + } + + if (len <= maxUppercasePreserve + 1 && identifier.endsWith("s")) { + const i = identifier.substring(0, len - 1); + if (i.toUpperCase() === i) { + return true; + } + } + } + return false; +} + +function deconstruct(identifier: string): Array { + return `${identifier}` + .replace(/([a-z]+)([A-Z])/g, "$1 $2") // Add a space in between camelCase words(e.g. fooBar => foo Bar) + .replace(/(\d+)/g, " $1 ") // Adds a space after numbers(e.g. foo123Bar => foo123 bar) + .replace(/_/g, " ") // Replace underscores with spaces + .replace(/\b([A-Z]+)([A-Z])s([^a-z])(.*)/g, "$1$2« $3$4") // Add a space after a plural upper cased word(e.g. MBsFoo => MBs Foo) + .replace(/\b([A-Z]+)([A-Z])([a-z]+)/g, "$1 $2$3") // Add a space between an upper case word(2 char+) and the last captial case.(e.g. SQLConnection -> SQL Connection) + .replace(/«/g, "s") + .trim() + .split(/[\W|_]+/) + .map((each) => (isFullyUpperCase(each) ? each : each.toLowerCase())) + .filter((part) => !!part); +} + +export function getModelsName(title: string): string { + const spaceRemovedTitle = title.replace(/ /g, ""); + return `${spaceRemovedTitle.replace("Client", "")}Models`; +} + +export function getMappersName(title: string): string { + const spaceRemovedTitle = title.replace(/ /g, ""); + return `${spaceRemovedTitle.replace("Client", "")}Mappers`; +} + +function getCasingConvention(nameType: NameType) { + switch (nameType) { + case NameType.Class: + case NameType.Interface: + case NameType.OperationGroup: + case NameType.EnumMemberName: + return CasingConvention.Pascal; + case NameType.File: + case NameType.Property: + case NameType.Operation: + case NameType.Parameter: + case NameType.Method: + return CasingConvention.Camel; + } +} + +function toCasing( + str: string, + casing: CasingConvention, + keepConsistent = false +): string { + const firstChar = + casing === CasingConvention.Pascal + ? str.charAt(0).toUpperCase() + : str.charAt(0).toLowerCase(); + const allLowerCases = + casing !== CasingConvention.Pascal && + keepConsistent && + str.toUpperCase() === str; + return allLowerCases ? str.toLowerCase() : `${firstChar}${str.substring(1)}`; +} + +export function pascalCase(str: string) { + return str.charAt(0).toUpperCase() + str.slice(1); +} + +export function camelCase( + str: string, + options: { uppercaseThreshold?: number } = {} +) { + const { uppercaseThreshold = 4 } = options; + const thresholdRegex = new RegExp( + `^(?[] { + const methodDefinitions: OptionalKind[] = []; + for (const key of Object.keys(methods)) { + const verbMethods = methods[key]; + if (!verbMethods) { + continue; + } + for (const method of verbMethods) { + const description = method.description; + const areAllOptional = method.hasOptionalOptions; + + methodDefinitions.push({ + name: key, + ...(description && { docs: [{ description }] }), + parameters: [ + ...getPathParamDefinitions(pathParams), + { + name: "options", + hasQuestionToken: areAllOptional, + type: pascalCase(method.optionsName) + } + ], + returnType: `StreamableMethod<${method.returnType}>` + }); + } + } + + return methodDefinitions; +} + +export function getPathParamDefinitions( + pathParams: PathParameter[] +): OptionalKind[] { + return pathParams.map(({ name, type, description }) => { + return { + name: normalizeName(name, NameType.Parameter), + type, + description + }; + }); +} + +export function hasPagingOperations(model: RLCModel) { + return Boolean(model.helperDetails?.hasPaging); +} + +export function hasPollingOperations(model: RLCModel) { + return Boolean(model.helperDetails?.hasLongRunning); +} + +export function hasMultiCollection(model: RLCModel) { + return Boolean(model.helperDetails?.hasMultiCollection); +} + +export function hasPipeCollection(model: RLCModel) { + return Boolean(model.helperDetails?.hasPipeCollection); +} + +export function hasSsvCollection(model: RLCModel) { + return Boolean(model.helperDetails?.hasSsvCollection); +} + +export function hasTsvCollection(model: RLCModel) { + return Boolean(model.helperDetails?.hasTsvCollection); +} + +export function hasCsvCollection(model: RLCModel) { + return Boolean(model.helperDetails?.hasCsvCollection); +} + +export function hasUnexpectedHelper(model: RLCModel) { + const pathDictionary = model.paths; + for (const details of Object.values(pathDictionary)) { + for (const methodDetails of Object.values(details.methods)) { + const firstMethod = methodDetails[0]; + if (!firstMethod) { + continue; + } + const successTypes = firstMethod.responseTypes.success; + const errorTypes = firstMethod.responseTypes.error; + + if (successTypes.length > 0 && errorTypes.length > 0 && !!errorTypes[0]) { + return true; + } + } + } + return false; +} + +export function hasInputModels(model: RLCModel) { + return hasSchemaContextObject(model, [SchemaContext.Input]); +} +export function hasOutputModels(model: RLCModel) { + return hasSchemaContextObject(model, [ + SchemaContext.Output, + SchemaContext.Exception + ]); +} + +function hasSchemaContextObject(model: RLCModel, schemaUsage: SchemaContext[]) { + const objectSchemas: ObjectSchema[] = (model.schemas ?? []).filter( + (o) => + isObjectSchema(o) && + (o as ObjectSchema).usage?.some((u) => schemaUsage.includes(u)) + ); + + return objectSchemas.length > 0; +} + +export function getGeneratedWrapperTypes( + params: ParameterMetadata[] | PathParameter[] +): Schema[] { + const wrapperTypes = params + .map((qp) => + isParameterMetadata(qp) ? qp.param.wrapperType : qp.wrapperType + ) + .filter((v) => v !== undefined); + const wrapperFromObjects = wrapperTypes.filter( + (wrap) => wrap.type === "object" + ); + const wrapperFromUnions = wrapperTypes + .filter((wrap) => wrap.type === "union") + .flatMap((wrapperType) => wrapperType?.enum ?? []) + .filter((v) => v.type === "object"); + return [...wrapperFromUnions, ...wrapperFromObjects]; +} + +function isParameterMetadata( + param: ParameterMetadata | PathParameter +): param is ParameterMetadata { + return (param as any).param !== undefined; +} diff --git a/packages/typespec-ts/src/rlc-common/helpers/packageUtil.ts b/packages/typespec-ts/src/rlc-common/helpers/packageUtil.ts new file mode 100644 index 0000000000..1a922f02d2 --- /dev/null +++ b/packages/typespec-ts/src/rlc-common/helpers/packageUtil.ts @@ -0,0 +1,13 @@ +import { RLCModel, RLCOptions } from "../interfaces.js"; + +export function isAzurePackage(model: { options?: RLCOptions }): boolean { + return Boolean(model.options?.flavor === "azure"); +} + +export function isAzureMonorepoPackage(model: RLCModel): boolean { + return Boolean(model.options?.azureSdkForJs) && isAzurePackage(model); +} + +export function isAzureStandalonePackage(model: RLCModel): boolean { + return isAzurePackage(model) && !model.options?.azureSdkForJs; +} diff --git a/packages/typespec-ts/src/rlc-common/helpers/pathUtils.ts b/packages/typespec-ts/src/rlc-common/helpers/pathUtils.ts new file mode 100644 index 0000000000..d23effe48e --- /dev/null +++ b/packages/typespec-ts/src/rlc-common/helpers/pathUtils.ts @@ -0,0 +1,15 @@ +import * as path from "path"; + +export function getRelativePartFromSrcPath( + srcPath: string, + isModularLibrary: boolean = false +) { + const sep = srcPath.includes(path.sep + "src") ? path.sep : "/"; + let relativePart = srcPath.substring(srcPath.indexOf(sep + "src") + 4); + if (isModularLibrary) { + relativePart = relativePart.substring(srcPath.indexOf(sep + "rest"), +5); + } + return relativePart.startsWith(sep) + ? relativePart.substring(1) + : relativePart; +} diff --git a/packages/typespec-ts/src/rlc-common/helpers/schemaHelpers.ts b/packages/typespec-ts/src/rlc-common/helpers/schemaHelpers.ts new file mode 100644 index 0000000000..2709fc571e --- /dev/null +++ b/packages/typespec-ts/src/rlc-common/helpers/schemaHelpers.ts @@ -0,0 +1,61 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { + ArraySchema, + ObjectSchema, + RLCModel, + Schema, + SchemaContext +} from "../interfaces.js"; + +export interface IsDictionaryOptions { + filterEmpty?: boolean; +} + +export function isArraySchema(schema: Schema): schema is ArraySchema { + return ( + schema.type === "array" || typeof (schema as any).items !== "undefined" + ); +} + +export function isDictionarySchema( + schema: Schema, + options: IsDictionaryOptions = {} +) { + if (schema.type === "dictionary") { + if (!options.filterEmpty || (options.filterEmpty && !schema.typeName)) { + return true; + } + } + return false; +} + +export function isObjectSchema(schema: Schema): schema is ObjectSchema { + if (schema.type === "object") { + return true; + } + return false; +} + +export function isConstantSchema(schema: Schema) { + if (schema.type === "constant") { + return true; + } + return false; +} + +export function buildSchemaObjectMap(model: RLCModel) { + // include interfaces + const map = new Map(); + const allSchemas = (model.schemas ?? []).filter( + (o) => + isObjectSchema(o) && + (o as ObjectSchema).usage?.some((u) => [SchemaContext.Input].includes(u)) + ); + allSchemas.forEach((o) => { + map.set(o.name, o); + }); + + return map; +} diff --git a/packages/typespec-ts/src/rlc-common/helpers/shortcutMethods.ts b/packages/typespec-ts/src/rlc-common/helpers/shortcutMethods.ts new file mode 100644 index 0000000000..085a31b663 --- /dev/null +++ b/packages/typespec-ts/src/rlc-common/helpers/shortcutMethods.ts @@ -0,0 +1,67 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { + OptionalKind, + MethodSignatureStructure, + InterfaceDeclarationStructure +} from "ts-morph"; +import { PathMetadata, Paths } from "../interfaces.js"; +import { buildMethodDefinitions } from "./operationHelpers.js"; +import { NameType, normalizeName } from "./nameUtils.js"; + +export function generateMethodShortcuts( + paths: Paths +): OptionalKind[] { + const keys: Record[]> = {}; + for (const path in paths) { + const pathMetadata = paths[path]; + if (!pathMetadata) { + continue; + } + const groupName = pathMetadata.operationGroupName; + const definitions = buildOperationDefinitions(pathMetadata); + if (!keys[groupName]) { + keys[groupName] = definitions; + } else { + keys[groupName] = [...keys[groupName], ...definitions]; + } + } + + const interfaces: OptionalKind[] = []; + + for (const interfaceName in keys) { + const methods = keys[interfaceName]; + interfaces.push({ + name: `${interfaceName}Operations`, + methods: methods, + isExported: true, + docs: [`Contains operations for ${interfaceName} operations`] + }); + } + + return interfaces; +} + +function buildOperationDefinitions( + path: PathMetadata +): OptionalKind[] { + let ops: OptionalKind[] = []; + + for (const verb in path.methods) { + const methods = path.methods[verb]; + if (!methods) { + continue; + } + for (const method of methods) { + const name = normalizeName(method.operationName, NameType.Property); + const pathParams = path.pathParameters; + const methodDefinitions = buildMethodDefinitions( + { [name]: [method] }, + pathParams + ); + ops = [...ops, ...methodDefinitions]; + } + } + return ops; +} diff --git a/packages/typespec-ts/src/rlc-common/helpers/typeUtil.ts b/packages/typespec-ts/src/rlc-common/helpers/typeUtil.ts new file mode 100644 index 0000000000..dfff6ac444 --- /dev/null +++ b/packages/typespec-ts/src/rlc-common/helpers/typeUtil.ts @@ -0,0 +1,173 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { Schema } from "../interfaces.js"; + +export function isRecord(type: string) { + return /^Record<([a-zA-Z]+),(\s*)(?.+)>$/.test(type); +} + +export function getRecordType(type: string) { + return /^Record<([a-zA-Z]+),(\s*)(?.+)>$/.exec(type)?.groups?.["type"]; +} + +export function isArray(type: string) { + return isArrayObject(type) || isNativeArray(type); +} + +export function isArrayObject(type: string) { + return /(^Array<(.+)>$)/g.test(type); +} + +export function getArrayObjectType(type: string) { + return /^Array<(?.+)>$/g.exec(type)?.groups?.["type"]; +} + +export function isNativeArray(type: string) { + return /(^.+)\[\]$/g.test(type); +} + +export function getNativeArrayType(type: string) { + return /(?.+)\[\]/g.exec(type)?.groups?.["type"]; +} + +export function isUnion(type: string) { + const members = type.split("|").map((m) => m.trim()); + return members.length > 1 && !isStringLiteral(type) && !isRecord(type); +} + +export function getUnionType(type: string) { + const firstMember = type.split("|").map((m) => m.trim())[0]; + if (firstMember === undefined) { + return type; + } + return leaveBracket(firstMember); +} + +export function leaveBracket(type: string) { + if (!type || type.length < 2) { + return type; + } else if (type.startsWith("(") && type.endsWith(")")) { + return type.slice(1, type.length - 1); + } + return type; +} + +export function leaveStringQuotes(str: string) { + if (isStringLiteral(str)) { + return str.slice(1, str.length - 1); + } + return str; +} + +export enum TypeScriptType { + string, + date, + number, + boolean, + constant, + record, + array, + object, + union, + unknown, + enum +} + +export function toTypeScriptTypeFromSchema( + schema: Schema +): TypeScriptType | undefined { + schema.type = schema.type.trim(); + if ( + schema.type === "string" && + schema.typeName === "Date | string" && + schema.outputTypeName === "string" + ) { + return TypeScriptType.date; + } else if (schema.enum && schema.enum.length > 0) { + if (schema.type === "union" || schema.type === "object") { + // Include both union and named union + return TypeScriptType.union; + } else { + // Include both extensible and fixed enum + return TypeScriptType.enum; + } + } else if ( + schema.isConstant === true || + isConstant(schema.typeName ?? schema.type) + ) { + return TypeScriptType.constant; + } else if (schema.type === "number") { + return TypeScriptType.number; + } else if (schema.type === "boolean") { + return TypeScriptType.boolean; + } else if (schema.type === "string") { + return TypeScriptType.string; + } else if (schema.type === "unknown") { + return TypeScriptType.unknown; + } else if (schema.type === "dictionary") { + return TypeScriptType.record; + } else if (schema.type === "array") { + return TypeScriptType.array; + } else if (schema.type === "object") { + return TypeScriptType.object; + } + return undefined; +} + +export function toTypeScriptTypeFromName( + typeName: string +): TypeScriptType | undefined { + typeName = typeName.trim(); + if (typeName === "Date") { + return TypeScriptType.date; + } else if (typeName === "string") { + return TypeScriptType.string; + } else if (typeName === "number") { + return TypeScriptType.number; + } else if (typeName === "boolean") { + return TypeScriptType.boolean; + } else if (typeName === "unknown") { + return TypeScriptType.unknown; + } else if (isConstant(typeName)) { + return TypeScriptType.constant; + } else if (isRecord(typeName)) { + return TypeScriptType.record; + } else if (isArray(typeName)) { + return TypeScriptType.array; + } else if (isUnion(typeName)) { + return TypeScriptType.union; + } + return undefined; +} + +export function isConstant(typeName: string) { + return ( + ["never", "null"].includes(typeName) || + isBoolLiteral(typeName) || + isNumericLiteral(typeName) || + isStringLiteral(typeName) + ); +} + +export function isNumericLiteral(str: string) { + if (typeof str !== "string") return false; + return !isNaN(Number(str)) && !isNaN(parseFloat(str)); +} + +export function isBoolLiteral(str: string) { + return str === "true" || str === "false"; +} + +export function isStringLiteral(type: string) { + if (type.length < 2) { + return false; + } + const first = type[0]; + const lastPos = type.length - 1; + return ( + first === type[lastPos] && + (first === '"' || first === "'") && + type.indexOf(first, 1) === lastPos + ); +} diff --git a/packages/typespec-ts/src/rlc-common/helpers/valueGenerationUtil.ts b/packages/typespec-ts/src/rlc-common/helpers/valueGenerationUtil.ts new file mode 100644 index 0000000000..46110f9cef --- /dev/null +++ b/packages/typespec-ts/src/rlc-common/helpers/valueGenerationUtil.ts @@ -0,0 +1,257 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { getImmediateParentsNames } from "../buildObjectTypes.js"; +import { + ArraySchema, + DictionarySchema, + ObjectSchema, + Schema, + SchemaContext +} from "../interfaces.js"; +import { + TypeScriptType, + getArrayObjectType, + getNativeArrayType, + getRecordType, + getUnionType, + isArrayObject, + isNativeArray, + leaveBracket, + leaveStringQuotes, + toTypeScriptTypeFromName, + toTypeScriptTypeFromSchema +} from "./typeUtil.js"; + +/** + * Generate parameter type value for the given type and parameter name + * @param type the typescript type + * @param parameterName the parameter name + * @param schemaMap the schema info to help generate the value + * @param path optional path to help detect self reference + * @param _allowMockValue the flag to indicate whether to allow mock value, currently we always generate mock value + * @returns + */ +export function generateParameterTypeValue( + type: string, + parameterName: string, + schemaMap: Map, + path: Set = new Set(), + _allowMockValue = true +): string | undefined { + type = leaveBracket(type?.trim()); + let tsType: TypeScriptType | undefined; + // Give priority to suggest the ts-type from schema + if (schemaMap.has(type)) { + tsType = toTypeScriptTypeFromSchema(schemaMap.get(type)!); + } + // Fallback to suggest ts-type from the type iteself + if (!tsType) { + tsType = toTypeScriptTypeFromName(type); + } + switch (tsType) { + case TypeScriptType.string: + return `"{Your ${leaveStringQuotes(parameterName)}}"`; + case TypeScriptType.number: + return "123"; + case TypeScriptType.boolean: + return "true"; + case TypeScriptType.date: + return "new Date()"; + case TypeScriptType.unknown: + return `"Unknown Type"`; + case TypeScriptType.object: { + return generateObjectValues(type, parameterName, schemaMap, path); + } + case TypeScriptType.array: { + return generateArrayValues(type, parameterName, schemaMap, path); + } + case TypeScriptType.record: { + return generateRecordValues(type, parameterName, schemaMap, path); + } + case TypeScriptType.enum: { + return mockEnumValues(type, parameterName, schemaMap, path); + } + case TypeScriptType.union: { + return mockUnionValues(type, parameterName, schemaMap, path); + } + case TypeScriptType.constant: + return type; + } + return `undefined /**FIXME */`; +} + +function mockEnumValues( + type: string, + parameterName: string, + schemaMap: Map, + path: Set = new Set() +) { + const schema = schemaMap.get(type); + if (schema && schema.enum && schema.enum.length > 0) { + return schema.enum[0].type; + } + return generateParameterTypeValue( + getUnionType(type), + parameterName, + schemaMap, + path + ); +} + +function mockUnionValues( + type: string, + parameterName: string, + schemaMap: Map, + path: Set = new Set() +) { + const schema = schemaMap.get(type); + if (schema && schema.enum && schema.enum.length > 0) { + addToSchemaMap(schemaMap, schema.enum[0]); + return generateParameterTypeValue( + getAccurateTypeName(schema.enum[0]) ?? schema.enum[0], + parameterName, + schemaMap, + path + ); + } + return generateParameterTypeValue( + getUnionType(type), + parameterName, + schemaMap, + path + ); +} + +function generateRecordValues( + type: string, + parameterName: string, + schemaMap: Map, + path: Set = new Set() +) { + let recordType = getRecordType(type); + const schema = schemaMap.get(type) as DictionarySchema; + if (schema && schema.additionalProperties) { + recordType = getAccurateTypeName(schema.additionalProperties); + addToSchemaMap(schemaMap, schema.additionalProperties); + } + + return recordType + ? `{"key": ${generateParameterTypeValue( + recordType, + parameterName, + schemaMap, + path + )}}` + : `{}`; +} + +function generateArrayValues( + type: string, + parameterName: string, + schemaMap: Map, + path: Set = new Set() +) { + let arrayType; + const schema = schemaMap.get(type) as ArraySchema; + if (schema && schema.items) { + arrayType = getAccurateTypeName(schema.items); + addToSchemaMap(schemaMap, schema.items); + } else if (isArrayObject(type)) { + arrayType = getArrayObjectType(type); + } else if (isNativeArray(type)) { + arrayType = getNativeArrayType(type); + } + const itemValue = arrayType + ? generateParameterTypeValue(arrayType, parameterName, schemaMap, path) + : undefined; + return itemValue ? `[${itemValue}]` : `[]`; +} + +function generateObjectValues( + type: string, + _parameterName: string, + schemaMap: Map, + path: Set = new Set() +) { + if (path.has(type)) { + // skip generating if self referenced + return `{} as any /**FIXME */`; + } + path.add(type); + // Extract all properties from the schema + const allProperties = getAllProperties(schemaMap.get(type), schemaMap); + const values = extractObjectProperties(allProperties, schemaMap, path); + + path.delete(type); + return `{${values.join(", ")}}`; +} + +function getAllProperties( + schema?: ObjectSchema, + schemaMap: Map = new Map() +): Map { + const propertiesMap: Map = new Map(); + if (!schema) { + return new Map(); + } + getImmediateParentsNames(schema, [SchemaContext.Input])?.forEach((p) => { + const parentProperties = getAllProperties(schemaMap.get(p), schemaMap); + for (const prop of parentProperties.keys()) { + propertiesMap.set(prop, parentProperties.get(prop)!); + } + }); + for (const prop in schema.properties) { + const propValue = schema.properties[prop]; + if (propValue) { + propertiesMap.set(prop, propValue); + } + } + return propertiesMap; +} + +function extractObjectProperties( + properties: Map, + schemaMap: Map = new Map(), + path: Set = new Set() +) { + const values: string[] = []; + for (const name of properties.keys()) { + const property = properties.get(name); + if (!property || property.readOnly || property.type === "never") { + continue; + } + addToSchemaMap(schemaMap, property); + values.push( + `${name}: ` + + generateParameterTypeValue( + getAccurateTypeName(property), + name, + schemaMap, + path + ) + ); + } + return values; +} + +function getAccurateTypeName(schema: Schema) { + // For extensible enum, fallback to use the type + if (schema.typeName === "string" && schema.enum && schema.enum.length > 0) { + return schema.type; + } + return schema.typeName ?? schema.type; +} + +function addToSchemaMap(schemaMap: Map, schema: Schema) { + const type = getAccurateTypeName(schema); + if (!type) { + return; + } + if ( + !schemaMap.has(type) && + !["string", "number", "boolean"].includes(schema.type) + ) { + schemaMap.set(type, schema); + } +} diff --git a/packages/typespec-ts/src/rlc-common/index.ts b/packages/typespec-ts/src/rlc-common/index.ts new file mode 100644 index 0000000000..580dc6340c --- /dev/null +++ b/packages/typespec-ts/src/rlc-common/index.ts @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +export * from "./interfaces.js"; +export * from "./buildClientDefinitions.js"; +export * from "./buildSchemaType.js"; +export * from "./buildClient.js"; +export * from "./helpers/nameConstructors.js"; +export * from "./buildResponseTypes.js"; +export * from "./helpers/shortcutMethods.js"; +export * from "./helpers/nameUtils.js"; +export * from "./helpers/operationHelpers.js"; +export * from "./helpers/valueGenerationUtil.js"; +export * from "./helpers/schemaHelpers.js"; +export * from "./buildParameterTypes.js"; +export * from "./buildIsUnexpectedHelper.js"; +export * from "./buildTopLevelIndexFile.js"; +export * from "./buildIndexFile.js"; +export * from "./buildPaginateHelper.js"; +export * from "./buildPollingHelper.js"; +export * from "./test/buildKarmaConfig.js"; +export * from "./test/buildRecordedClient.js"; +export * from "./test/buildSampleTest.js"; +export * from "./test/buildSnippets.js"; +export * from "./metadata/buildReadmeFile.js"; +export * from "./metadata/buildApiExtractorConfig.js"; +export * from "./metadata/buildPackageFile.js"; +export * from "./metadata/buildRollupConfig.js"; +export * from "./metadata/buildTsConfig.js"; +export * from "./metadata/buildWarpConfig.js"; +export * from "./metadata/buildESLintConfig.js"; +export * from "./metadata/buildLicenseFile.js"; +export * from "./metadata/buildChangelogFile.js"; +export * from "./metadata/buildVitestConfig.js"; +export * from "./metadata/buildSampleEnvFile.js"; +export * from "./buildSerializeHelper.js"; +export * from "./helpers/apiVersionUtil.js"; +export * from "./buildLogger.js"; +export * from "./buildSamples.js"; +export * from "./transformSampleGroups.js"; +export * from "./helpers/importsUtil.js"; +export * from "./metadata/buildTestConfig.js"; +export * from "./helpers/packageUtil.js"; diff --git a/packages/typespec-ts/src/rlc-common/interfaces.ts b/packages/typespec-ts/src/rlc-common/interfaces.ts new file mode 100644 index 0000000000..926f780550 --- /dev/null +++ b/packages/typespec-ts/src/rlc-common/interfaces.ts @@ -0,0 +1,448 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +export interface RLCModel { + libraryName: string; + srcPath: string; + paths: Paths; + importInfo: ImportInfo; + options?: RLCOptions; + schemas: Schema[]; + apiVersionInfo?: ApiVersionInfo; + parameters?: OperationParameter[]; + responses?: OperationResponse[]; + helperDetails?: HelperFunctionDetails; + urlInfo?: UrlInfo; + telemetryOptions?: TelemetryInfo; + sampleGroups?: RLCSampleGroup[]; + rlcSourceDir?: string; +} + +export interface ImportInfo { + internalImports: Imports; + runtimeImports: Imports; +} + +export type Imports = Record; + +export type ImportType = + /**inner models' imports for parameter and response */ + | "parameter" + | "response" + | "rlcIndex" + | "modularModel" + | "rlcClientFactory" + | "rlcClientDefinition" + /**common third party imports */ + | "restClient" + | "coreAuth" + | "restPipeline" + | "coreUtil" + | "coreLogger" + // this is a fallback import if above imports are not available + // mainly used in non-branded scope + | "commonFallback" + /**azure specific imports */ + | "azureEslintPlugin" + | "azureTestRecorder" + | "azureDevTool" + | "azureAbortController" + | "azureCoreLro" + | "azureCorePaging" + /**Internal helper imports */ + | "serializerHelpers"; + +export interface ImportMetadata { + type: ImportType; + specifier?: string; + version?: string; + importsSet?: Set; +} + +/** + * A group of samples in operation_id level and they are used to generate in a sample file + */ +export interface RLCSampleGroup { + filename: string; + clientPackageName: string; + defaultFactoryName: string; + samples: RLCSampleDetail[]; + importedTypes?: string[]; +} + +/** + * An independent sample detail and it will be wrapped as a func + */ +export interface RLCSampleDetail { + /** + * metadata for comments + */ + description: string; + originalFileLocation?: string; + name: string; + path: string; + defaultFactoryName: string; + clientParamAssignments: string[]; + pathParamAssignments: string[]; + methodParamAssignments: string[]; + clientParamNames: string; + pathParamNames: string; + methodParamNames: "options" | "" | string; + method: string; + isLRO: boolean; + isPaging: boolean; + useLegacyLro: boolean; +} + +export interface TelemetryInfo { + customRequestIdHeaderName?: string; +} + +export interface PathTemplateApiVersion { + value: string; + templateName: string; +} + +export interface UrlInfo { + endpoint?: string; + urlParameters?: PathParameter[]; + apiVersionInfo?: ApiVersionInfo; +} + +export interface ApiVersionInfo { + definedPosition?: ApiVersionPosition; + defaultValue?: string; + isCrossedVersion?: boolean; + required?: boolean; +} + +export type ApiVersionPosition = + | "path" + | "query" + | "baseurl" + | "duplicate" + | "none"; +export interface HelperFunctionDetails { + hasPaging?: boolean; + hasLongRunning?: boolean; + clientLroOverload?: boolean; + pageDetails?: PagingDetails; + hasMultiCollection?: boolean; + hasPipeCollection?: boolean; + hasSsvCollection?: boolean; + hasTsvCollection?: boolean; + hasCsvCollection?: boolean; +} + +export interface PagingDetails { + itemNames: string[]; + nextLinkNames: string[]; + isComplexPaging: boolean; +} + +export type Methods = { + // could be more than one method if overloading + [key: string]: OperationMethod[]; +}; + +export interface ResponseTypes { + success: string[]; + error: string[]; +} + +export interface OperationMethod { + optionsName: string; + description: string; + hasOptionalOptions: boolean; + returnType: string; + successStatus: string[]; + responseTypes: ResponseTypes; + operationName: string; + operationHelperDetail?: OperationHelperDetail; +} +export interface PathMetadata { + name: string; + pathParameters: PathParameter[]; + methods: Methods; + operationGroupName: string; + description: string; +} + +export type Paths = Record; + +export type PathParameter = { + oriName?: string; + name: string; + documentName?: string; + type: string; + description?: string; + value?: string | number | boolean; + wrapperType?: Schema; +}; + +export interface OperationHelperDetail { + lroDetails?: OperationLroDetail; + isPaging?: boolean; +} + +export const OPERATION_LRO_HIGH_PRIORITY = 0, + OPERATION_LRO_LOW_PRIORITY = 1; +export interface OperationLroDetail { + isLongRunning?: boolean; + logicalResponseTypes?: ResponseTypes; + operationLroOverload?: boolean; + /** + * This is used to sort the overload order, sorted in descending order + */ + precedence?: number; +} + +/** + * Flavor of the package to generate. If "azure", an Azure-branded package should be generated. If left undefined, a package without Azure branding will be generated. + */ +export type PackageFlavor = "azure" | undefined; + +export interface RLCOptions { + /** + * Whether to include response headers in the generated response types. If true, the generated response types will include headers as properties. + */ + includeHeadersInResponse?: boolean; + includeShortcuts?: boolean; + multiClient?: boolean; + batch?: any[]; + packageDetails?: PackageDetails; + addCredentials?: boolean; + /** Three possiblie values: + * - undefined, no credentialScopes and relevant settings would be generated + * - [], which means we would generate TokenCredential but no credentialScopes and relevant settings + * - ["..."], which means we would generate credentialScopes and relevant settings with the given values + */ + credentialScopes?: string[]; + credentialKeyHeaderName?: string; + customHttpAuthHeaderName?: string; + customHttpAuthSharedKeyPrefix?: string; + /** + * Three possible values: + * - undefined, the default behavior which means we would generate metadata if the package.json file is absent + * - true, which means we would always generate new files or override existing files + * - false, which means we would not generate any files no matter there exists or not + */ + generateMetadata?: boolean; + /** + * Three possible values: + * - undefined, the default behavior which means we would generate test if there is no `test` folder + * - true, which means we would always generate new files or override existing files + * - false, which means we would not generate any files no matter there exists or not + */ + generateTest?: boolean; + generateSample?: boolean; + azureSdkForJs?: boolean; + azureOutputDirectory?: string; + isTypeSpecTest?: boolean; + title?: string; + dependencyInfo?: DependencyInfo; + productDocLink?: string; + serviceInfo?: ServiceInfo; + azureArm?: boolean; + sourceFrom?: "TypeSpec" | "Swagger"; + isModularLibrary?: boolean; + moduleKind?: "esm" | "cjs"; + enableOperationGroup?: boolean; + flavor?: PackageFlavor; + enableModelNamespace?: boolean; + hierarchyClient?: boolean; + compatibilityMode?: boolean; + experimentalExtensibleEnums?: boolean; + clearOutputFolder?: boolean; + ignorePropertyNameNormalize?: boolean; + ignoreEnumMemberNameNormalize?: boolean; + compatibilityQueryMultiFormat?: boolean; + typespecTitleMap?: Record; + hasSubscriptionId?: boolean; + compatibilityLro?: boolean; + ignoreNullableOnOptional?: boolean; + isMultiService?: boolean; + /** + * When set to true, non-model return types (arrays, scalars, enums, bytes with binary content type) + * will be wrapped in an XxxResponse type to maintain backward compatibility with HLC. + * This option defaults to true for Azure flavor and to false otherwise, unless explicitly set. + */ + wrapNonModelReturn?: boolean; + /** + * When set to true, HEAD operations with void response return `{ body: boolean }` instead of void. + * `body: true` = 2xx (resource exists), `body: false` = non-2xx (e.g., 404 Not Found). + * Requires `wrapNonModelReturn` to also be enabled. + * Defaults to false. + */ + headAsBoolean?: boolean; + /** + * When enabled, every regular (non-LRO, non-paging) operation return type is augmented with a + * `_response` property containing `rawResponse`, `parsedBody`, and `parsedHeaders`. + */ + enableStorageCompat?: boolean; + /** + * When set to true, TypeSpec `unknown` type will be translated to `Record` + * instead of `any` in the generated Modular SDK. This is useful when migrating from HLC + * where `unknown` in swagger mapped to `Record`. + */ + treatUnknownAsRecord?: boolean; + /** + * When set to true, generates React Native build targets (tsconfig, warp target, package.json exports). + * Defaults to false. Only applicable when azureSdkForJs is true. + */ + generateReactNativeTarget?: boolean; +} + +export interface ServiceInfo { + title?: string; + description?: string; +} + +export interface DependencyInfo { + link: string; + description: string; +} + +export interface File { + path: string; + content: string; +} + +export enum SchemaContext { + /** Schema is used as an input to an operation. */ + Input = "input", + /** Schema is used as an output from an operation. */ + Output = "output", + /** Schema is used as an exception from an operation. */ + Exception = "exception" +} + +export interface Schema { + name: string; + type: string; + typeName?: string; + outputTypeName?: string; + description?: string; + required?: boolean; + default?: any; + readOnly?: boolean; + usage?: SchemaContext[]; + alias?: string; + outputAlias?: string; + fromCore?: boolean; + enum?: any[]; + isConstant?: boolean; +} + +export interface ObjectSchema extends Schema { + properties?: Record; + discriminatorValue?: string; + discriminator?: Schema; + isPolyParent?: boolean; + isMultipartBody?: boolean; + children?: { + all?: ObjectSchema[]; + immediate?: ObjectSchema[]; + }; + parents?: { + all?: ObjectSchema[]; + immediate?: ObjectSchema[]; + }; +} + +export interface DictionarySchema extends Schema { + valueTypeName?: string; + outputValueTypeName?: string; + additionalProperties?: Schema; +} + +export interface ArraySchema extends Schema { + items?: Schema; +} + +export type Property = Schema; + +export type Parameter = Schema; + +export interface PackageDetails { + name: string; + scopeName?: string; + nameWithoutScope?: string; + description?: string; + version?: string; + isVersionUserProvided?: boolean; +} +export interface OperationParameter { + operationGroup: string; + operationName: string; + /** + * An operation with multiple request parameters means that + * the operation can get different values for content-type and each value + * may have a different type associated to it. + */ + parameters: ParameterMetadatas[]; +} + +export interface ParameterMetadatas { + parameters?: ParameterMetadata[]; + body?: ParameterBodyMetadata; +} + +export interface ParameterBodyMetadata { + /** + * In case of formData we'd get multiple properties in body marked as partialBody + * If yes, rlc-common would prepare the whole part shape; + * usually false in typespec source because rlc-common doesn't have to prepare the whole part shape + */ + isPartialBody?: boolean; + body?: ParameterBodySchema[]; +} + +export interface ParameterBodySchema extends Schema { + oriSchema?: Schema; +} +export interface ParameterMetadata { + type: "query" | "path" | "header"; + name: string; + param: ParameterSchema; +} + +export interface ParameterSchema extends Schema { + // the detailed wrapper type for the parameter and codegen needs to build this type directly + wrapperType?: Schema; +} + +export interface OperationResponse { + operationGroup: string; + operationName: string; + path: string; + responses: ResponseMetadata[]; + // Check if the default response is one of superset of non-default responses + isDefaultSupersetOfOthers?: boolean; +} +export interface ResponseMetadata { + statusCode: string; + description?: string; + headers?: ResponseHeaderSchema[]; + body?: ResponseBodySchema; + predefinedName?: string; +} + +export type ResponseHeaderSchema = Schema; +export type ResponseBodySchema = Schema; + +export type ContentBuilder = { + (model: RLCModel): File | File[] | undefined; +}; + +export type SampleParameterPosition = "client" | "path" | "method"; + +export type SampleParameters = Record< + SampleParameterPosition, + SampleParameter[] +>; + +export interface SampleParameter { + name: string; + assignment?: string; + value?: string; +} diff --git a/packages/typespec-ts/src/rlc-common/metadata/buildApiExtractorConfig.ts b/packages/typespec-ts/src/rlc-common/metadata/buildApiExtractorConfig.ts new file mode 100644 index 0000000000..8d4018d365 --- /dev/null +++ b/packages/typespec-ts/src/rlc-common/metadata/buildApiExtractorConfig.ts @@ -0,0 +1,71 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { Project } from "ts-morph"; +import { RLCModel } from "../interfaces.js"; + +export function buildApiExtractorConfig(model: RLCModel) { + const { packageDetails, isModularLibrary, generateTest, azureSdkForJs } = + model.options || {}; + const project = new Project(); + + let mainEntryPointFilePath = "dist/esm/index.d.ts"; + + if (model.options?.moduleKind === "cjs") { + mainEntryPointFilePath = `./types${ + generateTest || isModularLibrary ? "/src" : "" + }/index.d.ts`; + } + + const config = azureSdkForJs + ? { + extends: "../../../api-extractor-base.json" + } + : { + $schema: + "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", + mainEntryPointFilePath, + docModel: { + enabled: true + }, + apiReport: { + enabled: true, + reportFolder: "./review" + }, + dtsRollup: { + enabled: true, + untrimmedFilePath: "", + publicTrimmedFilePath: `dist/${ + packageDetails?.nameWithoutScope ?? packageDetails?.name + }.d.ts` + }, + messages: { + tsdocMessageReporting: { + default: { + logLevel: "none" + } + }, + extractorMessageReporting: { + "ae-missing-release-tag": { + logLevel: "none" + }, + "ae-unresolved-link": { + logLevel: "none" + } + } + } + }; + + const filePath = "api-extractor.json"; + const configFile = project.createSourceFile( + filePath, + JSON.stringify(config), + { + overwrite: true + } + ); + return { + path: filePath, + content: configFile.getFullText() + }; +} diff --git a/packages/typespec-ts/src/rlc-common/metadata/buildChangelogFile.ts b/packages/typespec-ts/src/rlc-common/metadata/buildChangelogFile.ts new file mode 100644 index 0000000000..65ad09a5d5 --- /dev/null +++ b/packages/typespec-ts/src/rlc-common/metadata/buildChangelogFile.ts @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { RLCModel } from "../interfaces.js"; + +function getPackageVersion(model: RLCModel): string { + return model.options?.packageDetails?.version ?? "1.0.0-beta.1"; +} + +export function buildChangelogFile(model: RLCModel) { + const version = getPackageVersion(model); + const content = `# Release History + +## ${version} (Unreleased) + +### Features Added + +### Breaking Changes + +### Bugs Fixed + +### Other Changes +`; + + return { + path: "CHANGELOG.md", + content + }; +} diff --git a/packages/typespec-ts/src/rlc-common/metadata/buildESLintConfig.ts b/packages/typespec-ts/src/rlc-common/metadata/buildESLintConfig.ts new file mode 100644 index 0000000000..e27b4a9e7e --- /dev/null +++ b/packages/typespec-ts/src/rlc-common/metadata/buildESLintConfig.ts @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { Project } from "ts-morph"; +import { RLCModel } from "../interfaces.js"; + +const eslintConfig = `import azsdkEslint from "@azure/eslint-plugin-azure-sdk"; + +export default azsdkEslint.config([ + { + rules: { + "@azure/azure-sdk/ts-modules-only-named": "warn", + "@azure/azure-sdk/ts-package-json-types": "warn", + "@azure/azure-sdk/ts-package-json-engine-is-present": "warn", + "tsdoc/syntax": "warn" + } + } +]); +`; + +const esLintConfigEsm = `import azsdkEslint from "@azure/eslint-plugin-azure-sdk"; + +export default azsdkEslint.config([ + { + rules: { + "@azure/azure-sdk/ts-modules-only-named": "warn", + "@azure/azure-sdk/ts-package-json-types": "warn", + "@azure/azure-sdk/ts-package-json-engine-is-present": "warn", + "@azure/azure-sdk/ts-package-json-files-required": "off", + "@azure/azure-sdk/ts-package-json-main-is-cjs": "off", + "tsdoc/syntax": "warn" + } + } +]); +`; + +export function buildEsLintConfig(model: RLCModel) { + if (model.options?.flavor !== "azure") { + return; + } + const project = new Project(); + const filePath = "eslint.config.mjs"; + + const configFile = project.createSourceFile( + "eslint.config.mjs", + model.options?.moduleKind === "esm" ? esLintConfigEsm : eslintConfig, + { + overwrite: true + } + ); + return { + path: filePath, + content: configFile.getFullText() + }; +} diff --git a/packages/typespec-ts/src/rlc-common/metadata/buildLicenseFile.ts b/packages/typespec-ts/src/rlc-common/metadata/buildLicenseFile.ts new file mode 100644 index 0000000000..c835dcc188 --- /dev/null +++ b/packages/typespec-ts/src/rlc-common/metadata/buildLicenseFile.ts @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +const mitLicenseText = ` +Copyright (c) Microsoft Corporation. + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +`; + +export function buildLicenseFile() { + return { + path: "LICENSE", + content: mitLicenseText.trim() + }; +} diff --git a/packages/typespec-ts/src/rlc-common/metadata/buildPackageFile.ts b/packages/typespec-ts/src/rlc-common/metadata/buildPackageFile.ts new file mode 100644 index 0000000000..4a3a1e313d --- /dev/null +++ b/packages/typespec-ts/src/rlc-common/metadata/buildPackageFile.ts @@ -0,0 +1,235 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { NameType, normalizeName } from "../helpers/nameUtils.js"; +import { hasPollingOperations } from "../helpers/operationHelpers.js"; +import { + isAzureMonorepoPackage, + isAzurePackage, + isAzureStandalonePackage +} from "../helpers/packageUtil.js"; +import { + PackageCommonInfoConfig, + getTshyConfig, + resolveWarpExports +} from "./packageJson/packageCommon.js"; +import { Project, SourceFile } from "ts-morph"; +import { RLCModel } from "../interfaces.js"; +import { buildAzureMonorepoPackage } from "./packageJson/buildAzureMonorepoPackage.js"; +import { buildAzureStandalonePackage } from "./packageJson/buildAzureStandalonePackage.js"; +import { buildFlavorlessPackage } from "./packageJson/buildFlavorlessPackage.js"; +import { getRelativePartFromSrcPath } from "../helpers/pathUtils.js"; +import { getPackageName } from "./utils.js"; + +interface PackageFileOptions { + exports?: Record; + dependencies?: Record; + clientContextPaths?: string[]; +} + +export function buildPackageFile( + model: RLCModel, + { exports, dependencies, clientContextPaths }: PackageFileOptions = {} +) { + const config: PackageCommonInfoConfig = { + description: getDescription(model), + moduleKind: model.options?.moduleKind ?? "esm", + name: getPackageName(model), + version: getPackageVersion(model), + withSamples: model.options?.generateSample === true, + withTests: model.options?.generateTest === true, + nameWithoutScope: model.options?.packageDetails?.nameWithoutScope, + exports, + azureArm: model.options?.azureArm, + isModularLibrary: model.options?.isModularLibrary ?? false, + azureSdkForJs: model.options?.azureSdkForJs, + generateReactNativeTarget: model.options?.generateReactNativeTarget + }; + + let packageInfo: Record = buildFlavorlessPackage(config); + + const extendedConfig = { + ...config, + clientFilePaths: [getClientFilePath(model)], + hasLro: hasPollingOperations(model), + monorepoPackageDirectory: model.options?.azureOutputDirectory, + specSource: model.options?.sourceFrom ?? "TypeSpec", + dependencies, + clientContextPaths + }; + + if (isAzureMonorepoPackage(model)) { + packageInfo = buildAzureMonorepoPackage(extendedConfig); + } + + if (isAzureStandalonePackage(model)) { + packageInfo = buildAzureStandalonePackage(extendedConfig); + } + + const project = new Project(); + const filePath = "package.json"; + + if (!packageInfo) { + return; + } + + const packageFile = project.createSourceFile( + filePath, + JSON.stringify(packageInfo, null, 2), + { + overwrite: true + } + ); + return { + path: filePath, + content: packageFile.getFullText() + }; +} + +/** + * Automatically updates the package.json for an existing Azure SDK package. + * - Migrates `@azure/core-client` → `@azure-rest/core-client` when found in dependencies. + * - Updates `@azure/core-lro` from `^2.x` to `^3.1.0`. + * - Adds LRO dependencies (`@azure/core-lro`, `@azure/abort-controller`) when the package has + * polling operations (for non-monorepo Azure packages). + * - Updates exports (tshy or warp) when `exports` is provided. + * - Updates `//metadata.constantPaths` when `clientContextPaths` is provided. + */ +export function updatePackageFile( + model: RLCModel, + existingFilePathOrContent: string | Record, + { exports, clientContextPaths }: PackageFileOptions = {} +) { + const hasLro = hasPollingOperations(model); + const isAzure = isAzurePackage(model); + const needsLroUpdate = isAzure && hasLro; + const needsExportsUpdate = exports; + const needsConstantPathsUpdate = + clientContextPaths && clientContextPaths.length > 0; + const needsPlatformImportsUpdate = + model.options?.azureSdkForJs && model.options?.moduleKind === "esm"; + + let packageInfo; + if (typeof existingFilePathOrContent === "string") { + let packageFile: SourceFile; + try { + const project = new Project(); + packageFile = project.addSourceFileAtPath(existingFilePathOrContent); + } catch (_e) { + // If the file doesn't exist, we don't need to update it. + return; + } + packageInfo = JSON.parse(packageFile.getFullText()); + } else { + packageInfo = existingFilePathOrContent; + } + + // Migrate AutoRest-specific dependency names and versions to their TypeSpec equivalents. + const deps: Record = { ...(packageInfo.dependencies ?? {}) }; + let needsCoreClientUpdate = false; + + // @azure/core-client is AutoRest-only; TypeSpec uses @azure-rest/core-client. + if ("@azure/core-client" in deps) { + needsCoreClientUpdate = true; + } + + // Early return if nothing needs to be updated + if ( + !needsLroUpdate && + !needsExportsUpdate && + !needsConstantPathsUpdate && + !needsPlatformImportsUpdate && + !needsCoreClientUpdate + ) { + return; + } + + // Ensure warp packages have #platform/* imports for polyfill resolution + if (needsPlatformImportsUpdate) { + packageInfo.imports = { + "#platform/*.js": { + browser: "./src/*-browser.mjs", + "react-native": "./src/*-react-native.mjs", + default: "./src/*.js" + } + }; + } + + // Update exports based on build system (warp for monorepo, tshy for others) + if (needsExportsUpdate) { + if (model.options?.azureSdkForJs) { + // Warp: update resolved exports in package.json + packageInfo.exports = resolveWarpExports( + exports, + model.options?.generateReactNativeTarget + ); + } else if (packageInfo.tshy) { + // Tshy: update tshy.exports in package.json + const newTshy = getTshyConfig({ + exports, + azureSdkForJs: model.options?.azureSdkForJs, + generateReactNativeTarget: model.options?.generateReactNativeTarget + } as PackageCommonInfoConfig); + packageInfo.tshy["exports"] = newTshy["exports"]; + } + } + + // Update Core Client dependency + if (needsCoreClientUpdate) { + delete deps["@azure/core-client"]; + if (!("@azure-rest/core-client" in deps)) { + deps["@azure-rest/core-client"] = "^2.3.1"; + } + packageInfo.dependencies = deps; + } + + // Update LRO dependencies for Azure packages + if (needsLroUpdate) { + packageInfo.dependencies = { + ...packageInfo.dependencies, + "@azure/core-lro": "^3.1.0", + "@azure/abort-controller": "^2.1.2" + }; + } + + // Update constantPaths metadata for Azure packages + if (needsConstantPathsUpdate && isAzure && packageInfo["//metadata"]) { + const metadata = packageInfo["//metadata"]; + // Filter out existing userAgentInfo entries + const nonUserAgentPaths = (metadata.constantPaths || []).filter( + (item: any) => item.prefix !== "userAgentInfo" + ); + // Add new userAgentInfo entries from clientContextPaths + const newUserAgentPaths = clientContextPaths!.map((path) => ({ + path: path, + prefix: "userAgentInfo" + })); + metadata.constantPaths = [...nonUserAgentPaths, ...newUserAgentPaths]; + } + + return { + path: "package.json", + content: JSON.stringify(packageInfo, null, 2) + }; +} + +function getPackageVersion(model: RLCModel): string { + return model.options?.packageDetails?.version ?? "1.0.0-beta.1"; +} + +function getDescription(model: RLCModel): string { + const description = model.options?.packageDetails?.description; + if (!description) { + return `A generated SDK for ${model.libraryName}.`; + } + return description; +} + +function getClientFilePath(model: RLCModel) { + const { srcPath } = model; + const sdkReletivePart = getRelativePartFromSrcPath(srcPath); + const clientFilename = normalizeName(model.libraryName, NameType.File); + return sdkReletivePart + ? `src/${sdkReletivePart}/${clientFilename}.ts` + : `src/${clientFilename}.ts`; +} diff --git a/packages/typespec-ts/src/rlc-common/metadata/buildReadmeFile.ts b/packages/typespec-ts/src/rlc-common/metadata/buildReadmeFile.ts new file mode 100644 index 0000000000..0704e3f883 --- /dev/null +++ b/packages/typespec-ts/src/rlc-common/metadata/buildReadmeFile.ts @@ -0,0 +1,528 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { RLCModel } from "../interfaces.js"; +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: to fix the handlebars issue +import hbs from "handlebars"; +import { NameType, normalizeName } from "../helpers/nameUtils.js"; +import { isAzurePackage } from "../helpers/packageUtil.js"; +import { getClientName } from "../helpers/nameConstructors.js"; +import { readFileSync } from "fs"; + +const azureReadmeRLCTemplate = `# {{ clientDescriptiveName }} library for JavaScript + +{{ description }} + +{{#if azureArm}} +**If you are not familiar with our REST client, please spend 5 minutes to take a look at {{#if serviceDocURL}}[the service's documentation]({{ serviceDocURL }}) and {{/if}}our [REST client docs](https://github.com/Azure/azure-sdk-for-js/blob/main/documentation/rest-clients.md) to use this library, the REST client provides a light-weighted & developer friendly way to call azure rest api +{{else}} +**Please rely heavily on {{#if serviceDocURL}}[the service's documentation]({{ serviceDocURL }}) and {{/if}}our [REST client docs](https://github.com/Azure/azure-sdk-for-js/blob/main/documentation/rest-clients.md) to use this library** +{{/if}} + +Key links: + +{{#if packageSourceURL}} +- [Source code]({{ packageSourceURL }}) +{{/if}} +{{#if packageNPMURL}} +- [Package (NPM)]({{ packageNPMURL }}) +{{/if}} +{{#if apiRefURL}} +- [API reference documentation]({{ apiRefURL }}) +{{/if}} +{{#if serviceDocURL}} +- [Product documentation]({{ serviceDocURL }}) +{{/if}} +{{#if samplesURL}} +- [Samples]({{ samplesURL }}) +{{/if}} + +## Getting started + +### Currently supported environments + +- LTS versions of Node.js + +### Prerequisites + +- You must have an [Azure subscription](https://azure.microsoft.com/free/){{#if dependencyLink}} and follow [these]({{ dependencyLink }}) instructions{{/if}} to use this package. + +### Install the \`{{ clientPackageName }}\` package + +Install the {{ clientDescriptiveName }} REST client library for JavaScript with \`npm\`: + +\`\`\`bash +npm install {{ clientPackageName }} +\`\`\` + +### Create and authenticate a \`{{ clientClassName }}\` + +To use an [Azure Active Directory (AAD) token credential](https://github.com/Azure/azure-sdk-for-js/blob/main/sdk/identity/identity/samples/AzureIdentityExamples.md#authenticating-with-a-pre-fetched-access-token), +provide an instance of the desired credential type obtained from the +[@azure/identity](https://github.com/Azure/azure-sdk-for-js/tree/main/sdk/identity/identity#credentials) library. + +To authenticate with AAD, you must first \`npm\` install [\`@azure/identity\`](https://www.npmjs.com/package/@azure/identity) {{#if dependencyLink}}and +[{{dependencyDescription }}]({{ dependencyLink }}){{/if}} + +After setup, you can choose which type of [credential](https://github.com/Azure/azure-sdk-for-js/tree/main/sdk/identity/identity#credentials) from \`@azure/identity\` to use. +As an example, [DefaultAzureCredential](https://github.com/Azure/azure-sdk-for-js/tree/main/sdk/identity/identity#defaultazurecredential) +can be used to authenticate the client. + +## Troubleshooting + +### Logging + +Enabling logging may help uncover useful information about failures. In order to see a log of HTTP requests and responses, set the \`AZURE_LOG_LEVEL\` environment variable to \`info\`. Alternatively, logging can be enabled at runtime by calling \`setLogLevel\` in the \`@azure/logger\`: + +\`\`\`ts {{#if azureSdkForJs}}{{#if generateTest}}snippet:SetLogLevel{{/if}}{{/if}} +import { setLogLevel } from "@azure/logger"; + +setLogLevel("info"); +\`\`\` + +For more detailed instructions on how to enable logs, you can look at the [@azure/logger package docs](https://github.com/Azure/azure-sdk-for-js/tree/main/sdk/core/logger). +`; + +const azureReadmeModularTemplate = `# {{ clientDescriptiveName }} library for JavaScript + +This package contains an isomorphic SDK (runs both in Node.js and in browsers) for {{ clientDescriptiveName }}. + +{{ description }} + +Key links: + +{{#if packageSourceURL}} +- [Source code]({{ packageSourceURL }}) +{{/if}} +{{#if packageNPMURL}} +- [Package (NPM)]({{ packageNPMURL }}) +{{/if}} +{{#if apiRefURL}} +- [API reference documentation]({{ apiRefURL }}) +{{/if}} +{{#if samplesURL}} +- [Samples]({{samplesURL}}) +{{/if}} + +## Getting started + +### Currently supported environments + +- [LTS versions of Node.js](https://github.com/nodejs/release#release-schedule) +- Latest versions of Safari, Chrome, Edge and Firefox. + +See our [support policy](https://github.com/Azure/azure-sdk-for-js/blob/main/SUPPORT.md) for more details. + +{{#if azure}} +### Prerequisites + +- An [Azure subscription][azure_sub]. +{{/if}} + +{{#if isReleasablePackage}} +### Install the \`{{ clientPackageName }}\` package + +Install the {{ clientDescriptiveName }} library for JavaScript with \`npm\`: + +\`\`\`bash +npm install {{ clientPackageName }} +\`\`\` +{{/if}} + +{{#if azure}} +{{#if addCredentials}} +### Create and authenticate a \`{{ clientClassName}}\` + +To create a client object to access the {{ serviceName }} API, you will need the \`endpoint\` of your {{ serviceName }} resource and a \`credential\`. The {{ clientDescriptiveName }} can use Azure Active Directory credentials to authenticate. +You can find the endpoint for your {{ serviceName }} resource in the [Azure Portal][azure_portal]. + +You can authenticate with Azure Active Directory using a credential from the [@azure/identity][azure_identity] library or [an existing AAD Token](https://github.com/Azure/azure-sdk-for-js/blob/main/sdk/identity/identity/samples/AzureIdentityExamples.md#authenticating-with-a-pre-fetched-access-token). + +To use the [DefaultAzureCredential][defaultazurecredential] provider shown below, or other credential providers provided with the Azure SDK, please install the \`@azure/identity\` package: + +\`\`\`bash +npm install @azure/identity +\`\`\` + +You will also need to **register a new AAD application and grant access to {{ serviceName}}** by assigning the suitable role to your service principal (note: roles such as \`"Owner"\` will not grant the necessary permissions). + +For more information about how to create an Azure AD Application check out [this guide](https://learn.microsoft.com/azure/active-directory/develop/howto-create-service-principal-portal). + +{{#if azureArm}} +Using Node.js and Node-like environments, you can use the \`DefaultAzureCredential\` class to authenticate the client. + +\`\`\`ts {{#if azureSdkForJs}}{{#if generateTest}}snippet:ReadmeSampleCreateClient_Node{{/if}}{{/if}} +import { {{ clientClassName }} } from "{{ clientPackageName }}"; +import { DefaultAzureCredential } from "@azure/identity"; + +{{#if hasSubscriptionId}} +const subscriptionId = "00000000-0000-0000-0000-000000000000"; +const client = new {{ clientClassName }}(new DefaultAzureCredential(), subscriptionId); +{{else}} +const client = new {{ clientClassName }}(new DefaultAzureCredential()); +{{/if}} +\`\`\` + +For browser environments, use the \`InteractiveBrowserCredential\` from the \`@azure/identity\` package to authenticate. + +\`\`\`ts {{#if azureSdkForJs}}{{#if generateTest}}snippet:ReadmeSampleCreateClient_Browser{{/if}}{{/if}} +import { InteractiveBrowserCredential } from "@azure/identity"; +import { {{ clientClassName }} } from "{{ clientPackageName }}"; + +const credential = new InteractiveBrowserCredential({ + tenantId: "", + clientId: "", + }); + +{{#if hasSubscriptionId}} +const subscriptionId = "00000000-0000-0000-0000-000000000000"; +const client = new {{ clientClassName }}(credential, subscriptionId); +{{else}} +const client = new {{ clientClassName }}(credential); +{{/if}} +\`\`\` +{{else}} +Using Node.js and Node-like environments, you can use the \`DefaultAzureCredential\` class to authenticate the client. + +\`\`\`ts {{#if azureSdkForJs}}{{#if generateTest}}snippet:ReadmeSampleCreateClient_Node{{/if}}{{/if}} +import { {{ clientClassName }} } from "{{ clientPackageName }}"; +import { DefaultAzureCredential } from "@azure/identity"; + +const client = new {{ clientClassName }}("", new DefaultAzureCredential()); +\`\`\` + +For browser environments, use the \`InteractiveBrowserCredential\` from the \`@azure/identity\` package to authenticate. + +\`\`\`ts {{#if azureSdkForJs}}{{#if generateTest}}snippet:ReadmeSampleCreateClient_Browser{{/if}}{{/if}} +import { InteractiveBrowserCredential } from "@azure/identity"; +import { {{ clientClassName }} } from "{{ clientPackageName }}"; + +const credential = new InteractiveBrowserCredential({ + tenantId: "", + clientId: "" + }); +const client = new {{ clientClassName }}("", credential); +\`\`\` +{{/if}} +{{/if}}{{/if}} + +### JavaScript Bundle +To use this client library in the browser, first you need to use a bundler. For details on how to do this, please refer to our [bundling documentation](https://aka.ms/AzureSDKBundling). + +## Key concepts + +### {{ clientClassName }} + +\`{{ clientClassName }}\` is the primary interface for developers using the {{ clientDescriptiveName }} library. Explore the methods on this client object to understand the different features of the {{ serviceName }} service that you can access. + +{{#if azure}} +## Troubleshooting + +### Logging + +Enabling logging may help uncover useful information about failures. In order to see a log of HTTP requests and responses, set the \`AZURE_LOG_LEVEL\` environment variable to \`info\`. Alternatively, logging can be enabled at runtime by calling \`setLogLevel\` in the \`@azure/logger\`: + +\`\`\`ts {{#if azureSdkForJs}}{{#if generateTest}}snippet:SetLogLevel{{/if}}{{/if}} +import { setLogLevel } from "@azure/logger"; + +setLogLevel("info"); +\`\`\` + +For more detailed instructions on how to enable logs, you can look at the [@azure/logger package docs]({{ repoURL }}/tree/main/sdk/core/logger). + +{{#if samplesURL}} +## Next steps + +Please take a look at the [samples]({{ samplesURL }}) directory for detailed examples on how to use this library. +{{/if}} + +## Contributing + +If you'd like to contribute to this library, please read the [contributing guide]({{ contributingGuideURL }}) to learn more about how to build and test the code. + +## Related projects + +- [{{ projectName }}]({{ repoURL }}) + +[azure_sub]: https://azure.microsoft.com/free/ +[azure_portal]: https://portal.azure.com +{{#if identityPackageURL}}[azure_identity]: {{ identityPackageURL }} +{{/if}}[defaultazurecredential]: {{ identityPackageURL }}#defaultazurecredential +{{/if}} +`; + +const nonBrandedReadmeTemplate = `# {{ clientDescriptiveName }} library for JavaScript + +{{ description }} + +Key links: + +{{#if packageSourceURL}} +- [Source code]({{ packageSourceURL }}) +{{/if}} +{{#if packageNPMURL}} +- [Package (NPM)]({{ packageNPMURL }}) +{{/if}} +{{#if apiRefURL}} +- [API reference documentation]({{ apiRefURL }}) +{{/if}} +{{#if serviceDocURL}} +- [Product documentation]({{ serviceDocURL }}) +{{/if}} +{{#if samplesURL}} +- [Samples]({{ samplesURL }}) +{{/if}} + +## Getting started + +### Currently supported environments + +- LTS versions of Node.js + +### Install the \`{{ clientPackageName }}\` package + +Install the {{ clientDescriptiveName }} library for JavaScript with \`npm\`: + +\`\`\`bash +npm install {{ clientPackageName }} +\`\`\` +`; + +const apiReferenceTemplate = `{{#if apiRefURL}} +- [API reference documentation]({{ apiRefURL }}) +{{/if}} +`; + +/** + * Meta data information about the service, the package, and the client. + */ +interface Metadata { + /** The name of the service */ + serviceName: string; + /** The name of the package */ + clientPackageName: string; + /** The name of the client class */ + clientClassName: string; + /** The URL of the repository the package lives in */ + repoURL?: string; + /** The URL to the package directory in the repository */ + packageSourceURL?: string; + /** The URL to the package's samples */ + samplesURL?: string; + /** A descriptive name for the client extracted from the swagger */ + clientDescriptiveName?: string; + /** A description for the service extracted from the swagger */ + description?: string; + /** The URL to the package on npmjs.org */ + packageNPMURL?: string; + /** The name of the project that lives in the repository */ + projectName?: string; + /** whether the client accepts standard credentials */ + addCredentials?: boolean; + /** The link to the identity package in the repository */ + identityPackageURL?: string; + /** The URL for the service document */ + serviceDocURL?: string; + /** The dependency info for this service */ + dependencyDescription?: string; + dependencyLink?: string; + /** Indicates if the package is a multi-client */ + hasMultiClients?: boolean; + /** The URL to the API reference */ + apiRefURL?: string; + /** Check if the rp is management plane */ + azureArm?: boolean; + /** Whether the package being generated is for an Azure service */ + azure: boolean; + /** Indicates if the package is a test/releasable package. */ + isReleasablePackage?: boolean; + /** The link to the contributing guide in the repository */ + contributingGuideURL?: string; + /** Indicates if the package is generated to azure-sdk-for-js repo */ + azureSdkForJs?: boolean; + /** Indicates if the package need generate test files */ + generateTest?: boolean; + /** Indicates if the package need SubscriptionId as the client parameter */ + hasSubscriptionId?: boolean; +} + +export function buildReadmeFile(model: RLCModel) { + const metadata = createMetadata(model) ?? {}; + const readmeFileContents = hbs.compile( + model.options && isAzurePackage(model) + ? model.options.isModularLibrary + ? azureReadmeModularTemplate + : azureReadmeRLCTemplate + : nonBrandedReadmeTemplate, + { noEscape: true } + ); + return { + path: "README.md", + content: readmeFileContents(metadata) + }; +} + +export function hasClientNameChanged( + model: RLCModel, + existingReadmeFilePath: string +): boolean { + try { + const existingContent = readFileSync(existingReadmeFilePath, "utf8"); + const importMatch = existingContent.match( + /import\s*\{\s*([A-Za-z0-9_]+)\s*\}\s*from\s*["'][^"']*["']/ + ); + const existingClientName = importMatch?.[1]; + const newClientName = getClientName(model); + return !!existingClientName && existingClientName !== newClientName; + } catch { + return false; + } +} + +export function updateReadmeFile( + model: RLCModel, + existingReadmeFilePath: string +): { path: string; content: string } | undefined { + try { + const existingContent = readFileSync(existingReadmeFilePath, "utf8"); + const metadata = createMetadata(model) ?? {}; + + const newApiRefLink = hbs + .compile(apiReferenceTemplate, { noEscape: true })(metadata) + .trim(); + + if (!newApiRefLink) { + return { path: "README.md", content: existingContent }; + } + + const apiRefRegex = + /^- \[API reference documentation\]\(https:\/\/learn\.microsoft\.com\/javascript\/api\/[^)]+\)$/m; + const updatedContent = existingContent.replace(apiRefRegex, (match) => + match ? newApiRefLink : match + ); + + return { path: "README.md", content: updatedContent }; + } catch { + return; + } +} + +/** + * Returns meta data information about the service, the package, and the client. + * @param codeModel - include the client details + * @returns inferred metadata about the service, the package, and the client + */ +function createMetadata(model: RLCModel): Metadata | undefined { + if (!model.options || !model.options.packageDetails) { + return; + } + // const packageDetails = model.options.packageDetails; + const { + packageDetails, + azureOutputDirectory, + productDocLink, + dependencyInfo, + multiClient, + batch, + serviceInfo, + isTypeSpecTest + } = model.options; + + const azureHuh = + packageDetails?.scopeName === "azure" || + packageDetails?.scopeName === "azure-rest"; + const repoURL = "https://github.com/Azure/azure-sdk-for-js"; + const relativePackageSourcePath = azureOutputDirectory; + const packageSourceURL = + relativePackageSourcePath && + repoURL && + `${repoURL}/tree/main/${relativePackageSourcePath}`; + + const clientPackageName = packageDetails?.name; + const clientClassName = getClientName(model); + const serviceName = getServiceName(model); + let apiRefUrlQueryParameter: string = ""; + if ( + !packageDetails?.isVersionUserProvided && + model.apiVersionInfo?.defaultValue + ) { + if (model.apiVersionInfo?.defaultValue?.toLowerCase().includes("preview")) { + apiRefUrlQueryParameter = "?view=azure-node-preview"; + } + } else { + packageDetails.version = packageDetails.version ?? "1.0.0-beta.1"; + if (packageDetails?.version.includes("beta")) { + apiRefUrlQueryParameter = "?view=azure-node-preview"; + } + } + + return { + serviceName, + clientClassName, + clientPackageName: clientPackageName, + clientDescriptiveName: model.options.isModularLibrary + ? `${serviceName} client` + : `${serviceName} REST client`, + description: serviceInfo?.description ?? packageDetails.description, + serviceDocURL: productDocLink, + packageSourceURL: packageSourceURL, + packageNPMURL: `https://www.npmjs.com/package/${clientPackageName}`, + samplesURL: + model.options.generateSample && packageSourceURL + ? `${packageSourceURL}/samples` + : undefined, + apiRefURL: azureHuh + ? `https://learn.microsoft.com/javascript/api/${clientPackageName}${apiRefUrlQueryParameter}` + : undefined, + dependencyDescription: dependencyInfo?.description, + dependencyLink: dependencyInfo?.link, + hasMultiClients: multiClient && batch && batch.length > 1, + azureArm: Boolean(model.options.azureArm), + azure: azureHuh, + isReleasablePackage: !isTypeSpecTest, + repoURL: repoURL, + projectName: azureHuh ? "Microsoft Azure SDK for JavaScript" : undefined, + identityPackageURL: repoURL && `${repoURL}/tree/main/sdk/identity/identity`, + addCredentials: model.options.addCredentials, + contributingGuideURL: repoURL && `${repoURL}/blob/main/CONTRIBUTING.md`, + azureSdkForJs: model.options.azureSdkForJs, + generateTest: model.options.generateTest, + hasSubscriptionId: model.options.hasSubscriptionId + }; +} + +function getServiceName(model: RLCModel) { + const azureHuh = + model?.options?.packageDetails?.scopeName === "azure" || + model?.options?.packageDetails?.scopeName === "azure-rest"; + const libraryName = model.libraryName; + const serviceTitle = model.options?.isModularLibrary + ? model.libraryName + : (model.options?.serviceInfo?.title ?? model.libraryName); + const batch = model?.options?.batch, + packageDetails = model?.options?.packageDetails; + let simpleServiceName = + batch && batch.length > 1 + ? normalizeName( + packageDetails!.nameWithoutScope ?? packageDetails?.name ?? "", + NameType.Class + ) + : normalizeName(serviceTitle, NameType.Class); + simpleServiceName = + /** + * It is a required convention in Azure swaggers for their titles to end with + * "Client". + */ + serviceTitle.match(/(.*) Client/)?.[1] ?? + serviceTitle.match(/(.*)Client/)?.[1] ?? + libraryName.match(/(.*)Client/)?.[1] ?? + serviceTitle.match(/(.*) Service/)?.[1] ?? + simpleServiceName; + + return azureHuh + ? simpleServiceName.startsWith("Azure") + ? simpleServiceName + : `Azure ${simpleServiceName}` + : simpleServiceName; +} diff --git a/packages/typespec-ts/src/rlc-common/metadata/buildRollupConfig.ts b/packages/typespec-ts/src/rlc-common/metadata/buildRollupConfig.ts new file mode 100644 index 0000000000..6d05e8cdab --- /dev/null +++ b/packages/typespec-ts/src/rlc-common/metadata/buildRollupConfig.ts @@ -0,0 +1,150 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { Project } from "ts-morph"; +import { RLCModel } from "../interfaces.js"; +import { isAzurePackage } from "../helpers/packageUtil.js"; + +export function buildRollupConfig(model: RLCModel) { + const azureSdkForJs = Boolean(model.options?.azureSdkForJs); + // Only generate the file when it is not in sdk repo + if ( + isAzurePackage(model) && + (azureSdkForJs === true || azureSdkForJs === undefined) + ) { + return; + } + + const project = new Project(); + const filePath = "rollup.config.js"; + const rollupFile = project.createSourceFile(filePath, undefined, { + overwrite: true + }); + + rollupFile.addStatements( + `import nodeResolve from "@rollup/plugin-node-resolve"; + import cjs from "@rollup/plugin-commonjs"; + import sourcemaps from "rollup-plugin-sourcemaps"; + import multiEntry from "@rollup/plugin-multi-entry"; + import json from "@rollup/plugin-json"; + + import nodeBuiltins from "builtin-modules"; + + // #region Warning Handler + + /** + * A function that can determine whether a rollup warning should be ignored. If + * the function returns \`true\`, then the warning will not be displayed. + */ + + function ignoreNiseSinonEval(warning) { + return ( + warning.code === "EVAL" && + (warning.id && ((warning.id.includes("node_modules/nise")) || + warning.id.includes("node_modules/sinon")) === true) + ); + } + + function ignoreChaiCircularDependency(warning) { + return ( + warning.code === "CIRCULAR_DEPENDENCY" && + (warning.importer && warning.importer.includes("node_modules/chai") === true) + ); + } + + const warningInhibitors = [ + ignoreChaiCircularDependency, + ignoreNiseSinonEval + ]; + + /** + * Construct a warning handler for the shared rollup configuration + * that ignores certain warnings that are not relevant to testing. + */ + function makeOnWarnForTesting() { + return (warning, warn) => { + // If every inhibitor returns false (i.e. no inhibitors), then show the warning + if (warningInhibitors.every(inhib => !inhib(warning))) { + warn(warning); + } + }; + } + + // #endregion + + function makeBrowserTestConfig() { + const config = { + input: { + include: ["dist-esm/test/**/*.spec.js"], + exclude: ["dist-esm/test/**/node/**"] + }, + output: { + file: \`dist-test/index.browser.js\`, + format: "umd", + sourcemap: true + }, + preserveSymlinks: false, + plugins: [ + multiEntry({ exports: false }), + nodeResolve({ + mainFields: ["module", "browser"] + }), + cjs(), + json(), + sourcemaps() + //viz({ filename: "dist-test/browser-stats.html", sourcemap: true }) + ], + onwarn: makeOnWarnForTesting(), + // Disable tree-shaking of test code. In rollup-plugin-node-resolve@5.0.0, + // rollup started respecting the "sideEffects" field in package.json. Since + // our package.json sets "sideEffects=false", this also applies to test + // code, which causes all tests to be removed by tree-shaking. + treeshake: false + }; + + return config; + } + + const defaultConfigurationOptions = { + disableBrowserBundle: false + }; + + export function makeConfig( + pkg, + options + ) { + options = { + ...defaultConfigurationOptions, + ...(options || {}) + }; + + const baseConfig = { + // Use the package's module field if it has one + input: pkg["module"] || "dist-esm/src/index.js", + external: [ + ...nodeBuiltins, + ...Object.keys(pkg.dependencies), + ...Object.keys(pkg.devDependencies) + ], + output: { file: "dist/index.js", format: "cjs", sourcemap: true }, + preserveSymlinks: false, + plugins: [sourcemaps(), nodeResolve()] + }; + + const config = [baseConfig]; + + if (!options.disableBrowserBundle) { + config.push(makeBrowserTestConfig()); + } + + return config; + } + + export default makeConfig(require("./package.json"));` + ); + + return { + path: filePath, + content: rollupFile.getFullText() + }; +} diff --git a/packages/typespec-ts/src/rlc-common/metadata/buildSampleEnvFile.ts b/packages/typespec-ts/src/rlc-common/metadata/buildSampleEnvFile.ts new file mode 100644 index 0000000000..43ca5e2ef7 --- /dev/null +++ b/packages/typespec-ts/src/rlc-common/metadata/buildSampleEnvFile.ts @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +import { RLCModel } from "../interfaces.js"; + +const sampleEnvText = ` +# Feel free to add your own environment variables. +`; + +export function buildSampleEnvFile(model: RLCModel) { + if ( + (model.options?.generateMetadata === true || + model.options?.generateSample === true) && + model.options?.flavor === "azure" + ) { + const filePath = "sample.env"; + return { + path: filePath, + content: sampleEnvText.trim() + }; + } + return undefined; +} diff --git a/packages/typespec-ts/src/rlc-common/metadata/buildTestConfig.ts b/packages/typespec-ts/src/rlc-common/metadata/buildTestConfig.ts new file mode 100644 index 0000000000..a856463bbb --- /dev/null +++ b/packages/typespec-ts/src/rlc-common/metadata/buildTestConfig.ts @@ -0,0 +1,72 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { RLCModel } from "../interfaces.js"; +import { getPackageName } from "./utils.js"; + +function shouldGenerateTestConfig(model: RLCModel): boolean { + const isAzureSdkForJs = model.options?.azureSdkForJs ?? false; + return !( + model.options?.generateMetadata === false || + model.options?.generateTest === false || + isAzureSdkForJs !== true + ); +} + +/** + * Builds config/tsconfig.test.browser.json — extends eng/tsconfigs/test.browser.json + */ +export function buildTestBrowserTsConfig(model: RLCModel) { + if (!shouldGenerateTestConfig(model)) { + return; + } + + const name = getPackageName(model); + + return { + path: "config/tsconfig.test.browser.json", + content: JSON.stringify( + { + extends: "../../../../eng/tsconfigs/test.browser.json", + compilerOptions: { + paths: { + [name]: ["../src/index.ts"], + [`${name}/*`]: ["../src/*"], + "$internal/*": ["../src/*"] + } + } + }, + null, + 2 + ) + }; +} + +/** + * Builds config/tsconfig.test.node.json — extends eng/tsconfigs/test.node.json + */ +export function buildTestNodeTsConfig(model: RLCModel) { + if (!shouldGenerateTestConfig(model)) { + return; + } + + const name = getPackageName(model); + + return { + path: "config/tsconfig.test.node.json", + content: JSON.stringify( + { + extends: "../../../../eng/tsconfigs/test.node.json", + compilerOptions: { + paths: { + [name]: ["../src/index.ts"], + [`${name}/*`]: ["../src/*"], + "$internal/*": ["../src/*"] + } + } + }, + null, + 2 + ) + }; +} diff --git a/packages/typespec-ts/src/rlc-common/metadata/buildTsConfig.ts b/packages/typespec-ts/src/rlc-common/metadata/buildTsConfig.ts new file mode 100644 index 0000000000..b2a0a29ad0 --- /dev/null +++ b/packages/typespec-ts/src/rlc-common/metadata/buildTsConfig.ts @@ -0,0 +1,205 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { Project } from "ts-morph"; +import { RLCModel } from "../interfaces.js"; + +/** + * Builds the root tsconfig.json. + * + * For azureSdkForJs packages, emits project references pointing into the + * `config/` subfolder (following the eng/tsconfigs pattern). + */ +export function buildTsConfig(model: RLCModel) { + const { packageDetails, azureSdkForJs } = model.options || {}; + const { generateTest, generateSample, generateReactNativeTarget } = + model.options || {}; + const clientPackageName = packageDetails?.name ?? ""; + const project = new Project(); + + let tsConfig: Record; + + if (azureSdkForJs) { + const references: { path: string }[] = [ + { path: "./config/tsconfig.src.esm.json" }, + { path: "./config/tsconfig.src.browser.json" } + ]; + + if (generateReactNativeTarget) { + references.push({ path: "./config/tsconfig.src.react-native.json" }); + } + + references.push({ path: "./config/tsconfig.src.cjs.json" }); + + if (generateTest) { + references.push( + { path: "./config/tsconfig.test.node.json" }, + { path: "./config/tsconfig.test.browser.json" } + ); + } + + if (generateSample) { + references.push({ path: "./config/tsconfig.samples.json" }); + } + + if (generateTest) { + references.push({ path: "./config/tsconfig.snippets.json" }); + } + + tsConfig = { references, files: [] }; + } else { + const { options } = model; + tsConfig = { + compilerOptions: { + target: "ES2017", + module: options?.moduleKind === "esm" ? "NodeNext" : "es6", + lib: [], + declaration: true, + declarationMap: true, + inlineSources: true, + sourceMap: true, + importHelpers: true, + strict: true, + alwaysStrict: true, + noUnusedLocals: true, + noUnusedParameters: true, + noImplicitReturns: true, + noFallthroughCasesInSwitch: true, + forceConsistentCasingInFileNames: true, + moduleResolution: options?.moduleKind === "esm" ? "NodeNext" : "node", + allowSyntheticDefaultImports: true, + esModuleInterop: true, + outDir: options?.moduleKind === "cjs" ? "./dist-esm" : undefined, + declarationDir: options?.moduleKind === "cjs" ? "./types" : undefined + }, + include: ["src/**/*.ts"] + }; + + if (generateTest) { + tsConfig["include"].push("test/**/*.ts"); + } + if (generateSample) { + tsConfig["include"].push("samples-dev/**/*.ts"); + tsConfig["compilerOptions"]["paths"] = {}; + tsConfig["compilerOptions"]["paths"][clientPackageName] = ["./src/index"]; + } + } + + const filePath = "tsconfig.json"; + const configFile = project.createSourceFile( + filePath, + JSON.stringify(tsConfig, null, 2), + { overwrite: true } + ); + return { + path: filePath, + content: configFile.getFullText() + }; +} + +/** + * Builds config/tsconfig.src.esm.json — extends eng/tsconfigs/src.esm.json + */ +export function buildTsSrcEsmConfig() { + return { + path: "config/tsconfig.src.esm.json", + content: JSON.stringify( + { + extends: "../../../../eng/tsconfigs/src.esm.json", + include: ["../src/index.ts"] + }, + null, + 2 + ) + }; +} + +/** + * Builds config/tsconfig.src.browser.json — extends eng/tsconfigs/src.browser.json + */ +export function buildTsSrcBrowserConfig() { + return { + path: "config/tsconfig.src.browser.json", + content: JSON.stringify( + { + extends: "../../../../eng/tsconfigs/src.browser.json", + include: ["../src/index.ts"] + }, + null, + 2 + ) + }; +} + +/** + * Builds config/tsconfig.src.react-native.json — extends eng/tsconfigs/src.react-native.json + */ +export function buildTsSrcReactNativeConfig() { + return { + path: "config/tsconfig.src.react-native.json", + content: JSON.stringify( + { + extends: "../../../../eng/tsconfigs/src.react-native.json", + include: ["../src/index.ts"] + }, + null, + 2 + ) + }; +} + +/** + * Builds config/tsconfig.src.cjs.json — extends eng/tsconfigs/src.cjs.json + */ +export function buildTsSrcCjsConfig() { + return { + path: "config/tsconfig.src.cjs.json", + content: JSON.stringify( + { + extends: "../../../../eng/tsconfigs/src.cjs.json", + include: ["../src/index.ts"] + }, + null, + 2 + ) + }; +} + +/** + * Builds config/tsconfig.samples.json — extends eng/tsconfigs/samples.json + */ +export function buildTsSampleConfig(model: RLCModel) { + const { packageDetails } = model.options || {}; + const clientPackageName = packageDetails?.name ?? ""; + return { + path: "config/tsconfig.samples.json", + content: JSON.stringify( + { + extends: "../../../../eng/tsconfigs/samples.json", + compilerOptions: { + paths: { + [clientPackageName]: ["../dist/esm"] + } + } + }, + null, + 2 + ) + }; +} + +/** + * Builds config/tsconfig.snippets.json — extends eng/tsconfigs/snippets.json + */ +export function buildTsSnippetsConfig() { + return { + path: "config/tsconfig.snippets.json", + content: JSON.stringify( + { + extends: "../../../../eng/tsconfigs/snippets.json" + }, + null, + 2 + ) + }; +} diff --git a/packages/typespec-ts/src/rlc-common/metadata/buildVitestConfig.ts b/packages/typespec-ts/src/rlc-common/metadata/buildVitestConfig.ts new file mode 100644 index 0000000000..30bf81aad1 --- /dev/null +++ b/packages/typespec-ts/src/rlc-common/metadata/buildVitestConfig.ts @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { RLCModel } from "../interfaces.js"; + +const nodeConfig = `import viteConfig from "../../../vitest.shared.config.ts"; + +export default viteConfig; +`; + +const browserConfig = `export { default } from "../../../eng/vitestconfigs/browser.config.ts"; +`; + +export function buildVitestConfig( + model: RLCModel, + platform: "browser" | "node" +) { + if ( + model.options?.generateMetadata === false || + model.options?.generateTest === false + ) { + return; + } + switch (platform) { + case "browser": + return { + path: "vitest.browser.config.ts", + content: browserConfig + }; + case "node": + return { + path: "vitest.config.ts", + content: nodeConfig + }; + } +} diff --git a/packages/typespec-ts/src/rlc-common/metadata/buildWarpConfig.ts b/packages/typespec-ts/src/rlc-common/metadata/buildWarpConfig.ts new file mode 100644 index 0000000000..686e4f38c8 --- /dev/null +++ b/packages/typespec-ts/src/rlc-common/metadata/buildWarpConfig.ts @@ -0,0 +1,94 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { RLCModel } from "../interfaces.js"; + +export interface WarpConfigOptions { + /** Source-level exports, e.g. { ".": "./src/index.ts", "./models": "./src/models/index.ts" } */ + exports?: Record; +} + +/** Default exports included in every warp config. */ +const BASE_EXPORTS: Record = { + "./package.json": "./package.json", + ".": "./src/index.ts" +}; + +/** Full inline warp config template with react-native target. */ +const WarpConfigTemplateWithReactNative = `# warp.config.yml — build configuration + +exports: +{{exports}} + +targets: + - name: browser + tsconfig: "./config/tsconfig.src.browser.json" + + - name: react-native + tsconfig: "./config/tsconfig.src.react-native.json" + + - name: esm + condition: import + tsconfig: "./config/tsconfig.src.esm.json" + + - name: commonjs + condition: require + tsconfig: "./config/tsconfig.src.cjs.json" + moduleType: commonjs +`; + +/** Warp config template without react-native target (default). */ +const WarpConfigTemplateDefault = `# warp.config.yml — build configuration + +exports: +{{exports}} + +targets: + - name: browser + tsconfig: "./config/tsconfig.src.browser.json" + + - name: esm + condition: import + tsconfig: "./config/tsconfig.src.esm.json" + + - name: commonjs + condition: require + tsconfig: "./config/tsconfig.src.cjs.json" + moduleType: commonjs +`; + +/** + * Builds a self-contained warp.config.yml file. + * + * Emits a full inline config with all exports and targets. + * Polyfill resolution (browser/react-native file substitution) is handled + * via package.json `imports` subpath imports (#platform/*). + * + * By default, react-native target is NOT included. Set `generateReactNativeTarget: true` + * in options to include it. + */ +export function buildWarpConfig( + model: RLCModel, + { exports }: WarpConfigOptions = {} +) { + if (model.options?.moduleKind !== "esm") { + return; + } + + const allExports: Record = { + ...BASE_EXPORTS, + ...exports + }; + + const exportsContent = Object.entries(allExports) + .map(([key, value]) => ` ${JSON.stringify(key)}: ${JSON.stringify(value)}`) + .join("\n"); + + const template = model.options?.generateReactNativeTarget + ? WarpConfigTemplateWithReactNative + : WarpConfigTemplateDefault; + + const content = template.replace("{{exports}}", exportsContent); + + return { path: "warp.config.yml", content }; +} diff --git a/packages/typespec-ts/src/rlc-common/metadata/packageJson/azurePackageCommon.ts b/packages/typespec-ts/src/rlc-common/metadata/packageJson/azurePackageCommon.ts new file mode 100644 index 0000000000..5ddb718d32 --- /dev/null +++ b/packages/typespec-ts/src/rlc-common/metadata/packageJson/azurePackageCommon.ts @@ -0,0 +1,161 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { + PackageCommonInfoConfig, + getCommonPackageDevDependencies +} from "./packageCommon.js"; + +export interface AzurePackageInfoConfig extends PackageCommonInfoConfig { + hasLro: boolean; + specSource: "Swagger" | "TypeSpec"; +} + +/** + * Build the common package.json config for an Azure package. + */ +export function getAzureCommonPackageInfo(config: AzurePackageInfoConfig) { + return { + keywords: ["node", "azure", "cloud", "typescript", "browser", "isomorphic"], + author: "Microsoft Corporation", + license: "MIT", + ...getAzureCjsCommonInfo(config), + ...getAzureEsmCommonInfo(config) + }; +} + +/** + * Builds the common dependencies for an Azure package. + */ +export function getAzurePackageDependencies({ + hasLro, + specSource, + dependencies +}: AzurePackageInfoConfig) { + let azureDependencies: Record = { + ...dependencies, + "@azure-rest/core-client": specSource === "Swagger" ? "^1.4.0" : "^2.3.1", + "@azure/core-auth": "^1.6.0", + "@azure/core-rest-pipeline": "^1.5.0", + "@azure/logger": "^1.0.0", + tslib: "^2.6.2" + }; + + if (hasLro) { + azureDependencies = { + ...azureDependencies, + "@azure/core-lro": "^3.1.0", + "@azure/abort-controller": "^2.1.2" + }; + } + + return azureDependencies; +} + +function getAzureCjsCommonInfo({ + withTests, + withSamples, + name, + nameWithoutScope, + moduleKind +}: AzurePackageInfoConfig) { + if (moduleKind !== "cjs") { + return {}; + } + + return { + files: [ + "dist/", + "!dist/**/*.d.*ts.map", + withTests || withSamples ? "dist-esm/src/" : "dist-esm/", + `types/${nameWithoutScope ?? name}.d.ts`, + "README.md", + "LICENSE" + ] + }; +} + +function getAzureEsmCommonInfo({ moduleKind }: AzurePackageInfoConfig) { + if (moduleKind !== "esm") { + return {}; + } + return { + files: ["dist/", "!dist/**/*.d.*ts.map", "README.md", "LICENSE"] + }; +} + +function getAzurePackageCjsDevDependencies({ + moduleKind, + withTests +}: AzurePackageInfoConfig) { + if (moduleKind !== "cjs") { + return {}; + } + const testDevDependencies = { + "@azure-tools/test-credential": "^1.1.0", + "@azure-tools/test-recorder": "^3.0.0", + nyc: "^15.1.0", + mocha: "^11.0.2", + "@types/mocha": "^10.0.0", + "@types/chai": "^4.2.8", + chai: "^4.2.0", + "karma-chrome-launcher": "^3.0.0", + "karma-coverage": "^2.0.0", + "karma-env-preprocessor": "^0.1.1", + "karma-firefox-launcher": "^2.1.3", + "karma-junit-reporter": "^2.0.1", + "karma-mocha-reporter": "^2.2.5", + "karma-mocha": "^2.0.1", + "karma-source-map-support": "~1.4.0", + "karma-sourcemap-loader": "^0.4.0", + karma: "^6.2.0" + }; + + return { + ...(withTests && testDevDependencies), + "source-map-support": "^0.5.9" + }; +} + +function getAzurePackageEsmDevDependencies({ + moduleKind, + withTests +}: AzurePackageInfoConfig) { + if (moduleKind !== "esm") { + return {}; + } + + let devDependencies: Record = {}; + + if (withTests) { + devDependencies = { + ...devDependencies, + "@vitest/browser-playwright": "^4.0.6", + "@vitest/coverage-istanbul": "^4.0.6", + playwright: "^1.41.2", + vitest: "^4.0.6", + "@azure-tools/test-credential": "^2.0.0", + "@azure-tools/test-recorder": "^4.0.0" + }; + } + + return devDependencies; +} + +export function getAzurePackageDevDependencies(config: AzurePackageInfoConfig) { + const esmDevDependencies = getAzurePackageEsmDevDependencies(config); + const cjsDevDependencies = getAzurePackageCjsDevDependencies(config); + + const testDevDependencies = { + "@azure/identity": "^4.2.1" + }; + + return { + dotenv: "^16.0.0", + ...getCommonPackageDevDependencies(config), + ...(config.withTests && testDevDependencies), + ...(config.specSource === "Swagger" && { autorest: "latest" }), + ...esmDevDependencies, + ...cjsDevDependencies + }; +} diff --git a/packages/typespec-ts/src/rlc-common/metadata/packageJson/buildAzureMonorepoPackage.ts b/packages/typespec-ts/src/rlc-common/metadata/packageJson/buildAzureMonorepoPackage.ts new file mode 100644 index 0000000000..56a49c7df4 --- /dev/null +++ b/packages/typespec-ts/src/rlc-common/metadata/packageJson/buildAzureMonorepoPackage.ts @@ -0,0 +1,222 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { + AzurePackageInfoConfig, + getAzureCommonPackageInfo +} from "./azurePackageCommon.js"; +import { + getCommonPackageScripts, + getPackageCommonInfo +} from "./packageCommon.js"; + +export interface AzureMonorepoInfoConfig extends AzurePackageInfoConfig { + monorepoPackageDirectory?: string; + clientFilePaths: string[]; + clientContextPaths?: string[]; +} + +/** + * Builds the package.json for an Azure package that will be hosted in the azure-sdk-for-js mono repo. + */ +export function buildAzureMonorepoPackage(config: AzureMonorepoInfoConfig) { + const packageInfo = { + ...getAzureMonorepoPackageInfo(config), + ...getAzureMonorepoDependencies(config), + scripts: getAzureMonorepoScripts(config), + ...getSampleMetadata(config) + }; + + return packageInfo; +} + +/** + * Builds the dependencies for an Azure package that will be hosted in the azure-sdk-for-js mono repo. + */ +export function getAzureMonorepoDependencies(config: AzureMonorepoInfoConfig) { + const { hasLro, dependencies, withTests } = config; + + // revert this change after sdk repo update. + const runtimeDeps = { + ...dependencies, + "@azure-rest/core-client": "^2.3.1", + ...(hasLro && { + "@azure/abort-controller": "^2.1.2" + }), + "@azure/core-auth": "^1.9.0", + ...(hasLro && { + "@azure/core-lro": "^3.1.0" + }), + "@azure/core-rest-pipeline": "^1.20.0", + "@azure/core-util": "^1.12.0", + "@azure/logger": "^1.2.0", + tslib: "^2.8.1" + }; + + const testDeps = withTests + ? { + "@vitest/browser-playwright": "catalog:testing", + "@vitest/coverage-istanbul": "catalog:testing", + dotenv: "catalog:testing", + playwright: "catalog:testing", + typescript: "catalog:", + vitest: "catalog:testing" + } + : { + typescript: "catalog:" + }; + + return { + dependencies: runtimeDeps, + devDependencies: { + "@azure-tools/test-credential": "workspace:^", + "@azure-tools/test-recorder": "workspace:^", + "@azure-tools/test-utils-vitest": "workspace:^", + "@azure/dev-tool": "workspace:^", + "@azure/eslint-plugin-azure-sdk": "workspace:^", + "@azure/identity": "catalog:internal", + "@types/node": "catalog:", + "cross-env": "catalog:", + eslint: "catalog:", + prettier: "catalog:", + rimraf: "catalog:", + ...(config.specSource === "Swagger" && { + autorest: "catalog:" + }), + ...testDeps + } + }; +} + +/** + * Build the common package.json config for an Azure package that will be hosted in the azure-sdk-for-js mono repo. + */ +export function getAzureMonorepoPackageInfo( + config: AzureMonorepoInfoConfig +): Record { + const commonPackageInfo = getPackageCommonInfo(config); + const repositoryDirectory = config.monorepoPackageDirectory ?? "sdk/"; + + return { + ...commonPackageInfo, + ...getAzureCommonPackageInfo(config), + "sdk-type": `${config.azureArm ? "mgmt" : "client"}`, + repository: { + type: "git", + url: "git+https://github.com/Azure/azure-sdk-for-js", + directory: repositoryDirectory + }, + bugs: { + url: "https://github.com/Azure/azure-sdk-for-js/issues" + }, + ...(config.monorepoPackageDirectory && { + homepage: `https://github.com/Azure/azure-sdk-for-js/tree/main/${config.monorepoPackageDirectory}/README.md` + }), + prettier: "@azure/eslint-plugin-azure-sdk/prettier.json", + "//metadata": getMetadataInfo(config) + }; +} + +function getSampleMetadata({ + name, + version, + withSamples +}: AzureMonorepoInfoConfig) { + if (!withSamples) { + return {}; + } + + let apiRefUrlQueryParameter: string = ""; + if (version.includes("beta")) { + apiRefUrlQueryParameter = "?view=azure-node-preview"; + } + + return { + "//sampleConfiguration": { + productName: name, + productSlugs: ["azure"], + disableDocsMs: true, + apiRefLink: `https://learn.microsoft.com/javascript/api/${name}${apiRefUrlQueryParameter}` + } + }; +} + +function addSwaggerMetadata( + metadata: Record, + specSource: "Swagger" | "TypeSpec" +) { + if (specSource !== "Swagger") { + return; + } + + metadata["constantPaths"].push({ + path: "swagger/README.md", + prefix: "package-version" + }); +} + +function getAzureMonorepoScripts(config: AzureMonorepoInfoConfig) { + const esmScripts = getEsmScripts(config); + const skipLinting = config.azureArm && config.isModularLibrary; + const buildSampleScripts = config.azureArm + ? "tsc -p config/tsconfig.samples.json && dev-tool samples publish -f" + : "tsc -p config/tsconfig.samples.json"; + return { + ...getCommonPackageScripts(), + "build:samples": config.withSamples ? buildSampleScripts : "echo skipped", + "check-format": `prettier --list-different --config ../../../.prettierrc.json --ignore-path ../../../.prettierignore "src/**/*.{ts,cts,mts}" "test/**/*.{ts,cts,mts}" "*.{js,cjs,mjs,json}" ${ + config.withSamples ? '"samples-dev/*.ts"' : "" + }`, + clean: + "rimraf --glob dist dist-browser dist-esm test-dist temp types *.tgz *.log", + "execute:samples": config.withSamples + ? "dev-tool samples run samples-dev" + : "echo skipped", + "extract-api": "rimraf review && dev-tool run extract-api", + format: `prettier --write --config ../../../.prettierrc.json --ignore-path ../../../.prettierignore "src/**/*.{ts,cts,mts}" "test/**/*.{ts,cts,mts}" "*.{js,cjs,mjs,json}" ${ + config.withSamples ? '"samples-dev/*.ts"' : "" + }`, + "generate:client": "echo skipped", + "test:browser": + "dev-tool run build-test && dev-tool run test:vitest --browser", + "lint:fix": skipLinting + ? "echo skipped" + : "eslint package.json src test --fix --fix-type [problem,suggestion]", + lint: skipLinting ? "echo skipped" : "eslint package.json src test", + pack: `pnpm pack 2>&1`, + ...esmScripts, + "update-snippets": "dev-tool run update-snippets" + }; +} + +function getEsmScripts({ moduleKind }: AzureMonorepoInfoConfig) { + if (moduleKind !== "esm") { + return {}; + } + + return { + build: + "npm run clean && dev-tool run build-package && dev-tool run extract-api", + "test:node": "dev-tool run test:vitest", + "test:node:esm": "dev-tool run test:vitest --esm", + test: "npm run test:node && npm run test:browser" + }; +} + +function getMetadataInfo(config: AzureMonorepoInfoConfig) { + const metadata: Record = { + constantPaths: [] + }; + const paths = config.isModularLibrary + ? config.clientContextPaths + : config.clientFilePaths; + addSwaggerMetadata(metadata, config.specSource); + for (const path of paths ?? []) { + metadata["constantPaths"].push({ + path: path, + prefix: "userAgentInfo" + }); + } + + return metadata; +} diff --git a/packages/typespec-ts/src/rlc-common/metadata/packageJson/buildAzureStandalonePackage.ts b/packages/typespec-ts/src/rlc-common/metadata/packageJson/buildAzureStandalonePackage.ts new file mode 100644 index 0000000000..4f342d4a9d --- /dev/null +++ b/packages/typespec-ts/src/rlc-common/metadata/packageJson/buildAzureStandalonePackage.ts @@ -0,0 +1,134 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { + AzurePackageInfoConfig, + getAzureCommonPackageInfo, + getAzurePackageDependencies, + getAzurePackageDevDependencies +} from "./azurePackageCommon.js"; +import { + getCommonPackageScripts, + getPackageCommonInfo +} from "./packageCommon.js"; + +/** + * Builds the package.json for an Azure package that won't be hosted in the azure-sdk-for-js repo. + */ +export function buildAzureStandalonePackage(config: AzurePackageInfoConfig) { + const packageInfo = { + ...getAzureStandalonePackageInfo(config), + ...getAzureStandaloneDependencies(config), + scripts: getAzureStandaloneScripts(config) + }; + + return packageInfo; +} + +function getAzureStandalonePackageInfo( + config: AzurePackageInfoConfig +): Record { + const commonPackageInfo = getPackageCommonInfo(config); + + return { + ...commonPackageInfo, + ...getAzureCommonPackageInfo(config) + }; +} + +function getAzureStandaloneDependencies( + config: AzurePackageInfoConfig +): Record { + return { + dependencies: { + ...getAzurePackageDependencies(config) + }, + devDependencies: { + ...getStandaloneDevDependencies(config), + "@microsoft/api-extractor": "^7.40.3", + rimraf: "^5.0.5", + mkdirp: "^3.0.1" + } + }; +} + +function getStandaloneDevDependencies(config: AzurePackageInfoConfig) { + return { + ...getAzurePackageDevDependencies(config), + "@microsoft/api-extractor": "^7.40.3", + ...getStandaloneCjsDevDependencies(config) + }; +} + +function getStandaloneCjsDevDependencies(config: AzurePackageInfoConfig) { + if (config.moduleKind !== "cjs") { + return {}; + } + + return { + "@rollup/plugin-commonjs": "^24.0.0", + "@rollup/plugin-json": "^6.0.0", + "@rollup/plugin-multi-entry": "^6.0.0", + "@rollup/plugin-node-resolve": "^13.1.3", + ...(config.moduleKind === "cjs" && + config.withTests && { "cross-env": "^7.0.2" }), + rollup: "^2.66.1", + "rollup-plugin-sourcemaps": "^0.6.3", + "uglify-js": "^3.4.9" + }; +} + +function getAzureStandaloneScripts( + config: AzurePackageInfoConfig +): Record { + const testScripts = { + "test:browser": "karma start --single-run", + "test:node": `nyc mocha -r esm --require source-map-support/register --timeout 5000000 --full-trace "dist-esm/test/{,!(browser)/**/}*.spec.js"`, + test: "npm run test:node && npm run test:browser" + }; + return { + ...getCommonPackageScripts(), + clean: + "rimraf --glob dist dist-browser dist-esm test-dist temp types *.tgz *.log", + ...(config.withTests && testScripts), + ...getCjsScripts(config), + ...getEsmScripts(config) + }; +} + +function getCjsScripts(config: AzurePackageInfoConfig): Record { + if (config.moduleKind !== "cjs") { + return {}; + } + + const testScripts = { + "build:test": "tsc -p . && rollup -c 2>&1", + "build:browser": "tsc -p . && cross-env ONLY_BROWSER=true rollup -c 2>&1", + "build:node": "tsc -p . && cross-env ONLY_NODE=true rollup -c 2>&1" + }; + + return { + build: + "npm run clean && tsc && rollup -c 2>&1 && npm run minify && mkdirp ./review && npm run extract-api", + ...(config.withTests && testScripts), + minify: + "uglifyjs -c -m --comments --source-map \"content='./dist/index.js.map'\" -o ./dist/index.min.js ./dist/index.js" + }; +} + +function getEsmScripts(config: AzurePackageInfoConfig): Record { + if (config.moduleKind !== "esm") { + return {}; + } + + const testScripts = { + test: "npm run clean && tshy && npm run unit-test:node && npm run unit-test:browser && npm run integration-test", + "test:node": "vitest -c vitest.config.ts", + "test:browser": "vitest -c vitest.browser.config.ts" + }; + + return { + build: "npm run clean && tshy && npm run extract-api", + ...(config.withTests && testScripts) + }; +} diff --git a/packages/typespec-ts/src/rlc-common/metadata/packageJson/buildFlavorlessPackage.ts b/packages/typespec-ts/src/rlc-common/metadata/packageJson/buildFlavorlessPackage.ts new file mode 100644 index 0000000000..db9bc8e808 --- /dev/null +++ b/packages/typespec-ts/src/rlc-common/metadata/packageJson/buildFlavorlessPackage.ts @@ -0,0 +1,70 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { + PackageCommonInfoConfig, + getPackageCommonInfo, + getCommonPackageScripts, + getCommonPackageDevDependencies, + commonPackageDependencies +} from "./packageCommon.js"; + +/** + * Builds the package.json for a flavorless package. + */ +export function buildFlavorlessPackage(config: PackageCommonInfoConfig) { + const packageInfo = { + ...getFlavorlessPackageInfo(config), + scripts: getFlavorlessScripts(config), + devDependencies: { + ...getCommonPackageDevDependencies(config), + "@microsoft/api-extractor": "^7.40.3", + rimraf: "^5.0.5", + mkdirp: "^3.0.1" + }, + dependencies: { + ...commonPackageDependencies, + "@typespec/ts-http-runtime": "0.1.0" + } + }; + + return packageInfo; +} + +function getFlavorlessPackageInfo( + config: PackageCommonInfoConfig +): Record { + const commonPackageInfo = getPackageCommonInfo(config); + + return { + ...commonPackageInfo + }; +} + +function getFlavorlessScripts(config: PackageCommonInfoConfig) { + return { + ...getCommonPackageScripts(), + ...getCjsScripts(config), + ...getEsmScripts(config) + }; +} + +function getCjsScripts({ moduleKind }: PackageCommonInfoConfig) { + if (moduleKind !== "cjs") { + return {}; + } + + return { + build: "npm run clean && tsc && npm run extract-api" + }; +} + +function getEsmScripts({ moduleKind }: PackageCommonInfoConfig) { + if (moduleKind !== "esm") { + return {}; + } + + return { + build: "npm run clean && tshy && npm run extract-api" + }; +} diff --git a/packages/typespec-ts/src/rlc-common/metadata/packageJson/packageCommon.ts b/packages/typespec-ts/src/rlc-common/metadata/packageJson/packageCommon.ts new file mode 100644 index 0000000000..011f4f15ca --- /dev/null +++ b/packages/typespec-ts/src/rlc-common/metadata/packageJson/packageCommon.ts @@ -0,0 +1,255 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +export interface PackageCommonInfoConfig { + name: string; + nameWithoutScope?: string; + version: string; + description: string; + moduleKind: "esm" | "cjs"; + withTests: boolean; + withSamples: boolean; + exports?: Record; + dependencies?: Record; + azureArm?: boolean; + isModularLibrary?: boolean; + azureSdkForJs?: boolean; + /** + * When true, generates React Native build targets (dist/react-native, exports condition). + * Defaults to false. Only applicable when azureSdkForJs is true. + */ + generateReactNativeTarget?: boolean; +} + +/** + * Common package.json config for a package. + */ +export function getPackageCommonInfo(config: PackageCommonInfoConfig) { + const { name, version, description } = config; + + return { + name, + version, + description, + engines: { + node: ">=20.0.0" + }, + sideEffects: false, + autoPublish: false, + ...getEntryPointInformation(config) + }; +} + +export const commonPackageDependencies = { + tslib: "^2.6.2" +}; + +export function getCommonPackageDevDependencies( + config: PackageCommonInfoConfig +) { + return { + "@types/node": "^20.0.0", + eslint: "^9.9.0", + typescript: "~5.8.2", + ...getEsmDevDependencies(config) + }; +} + +function getEsmDevDependencies({ + moduleKind, + azureSdkForJs +}: PackageCommonInfoConfig) { + if (moduleKind !== "esm") { + return {}; + } + // Azure monorepo packages use warp (invoked via dev-tool), no tshy needed + if (azureSdkForJs) { + return {}; + } + return { + tshy: "^2.0.0" + }; +} + +function getEntryPointInformation(config: PackageCommonInfoConfig) { + return { + ...getCjsEntrypointInformation(config), + ...getEsmEntrypointInformation(config) + }; +} + +function getCjsEntrypointInformation({ + name, + nameWithoutScope, + moduleKind, + withTests, + withSamples +}: PackageCommonInfoConfig) { + if (moduleKind !== "cjs") { + return; + } + + const types = + withTests || withSamples + ? `./types/src/${nameWithoutScope ?? name}.d.ts` + : `./types/${nameWithoutScope ?? name}.d.ts`; + const main = withTests || withSamples ? "dist/src/index.js" : "dist/index.js"; + return { + main, + module: + withTests || withSamples + ? "./dist-esm/src/index.js" + : "./dist-esm/index.js", + types + }; +} + +function getEsmEntrypointInformation(config: PackageCommonInfoConfig) { + if (config.moduleKind !== "esm") { + return; + } + + // Azure monorepo packages use warp instead of tshy + if (config.azureSdkForJs) { + const result: Record = { + type: "module", + main: "./dist/commonjs/index.js", + module: "./dist/esm/index.js", + types: "./dist/commonjs/index.d.ts", + browser: "./dist/browser/index.js", + imports: { + "#platform/*.js": { + browser: "./src/*-browser.mjs", + default: "./src/*.js" + } as Record + }, + exports: resolveWarpExports( + config.exports, + config.generateReactNativeTarget + ) + }; + + if (config.generateReactNativeTarget) { + result["react-native"] = "./dist/react-native/index.js"; + (result["imports"]["#platform/*.js"] as Record)[ + "react-native" + ] = "./src/*-react-native.mjs"; + // Reorder so react-native comes before default + const importsEntry = result["imports"]["#platform/*.js"] as Record< + string, + string + >; + result["imports"]["#platform/*.js"] = { + browser: importsEntry["browser"], + "react-native": importsEntry["react-native"], + default: importsEntry["default"] + }; + } + + return result; + } + + // Non-monorepo packages use tshy which manages polyfill resolution + // via esmDialects — do NOT add imports here. + const result: Record = { + tshy: getTshyConfig(config), + type: "module", + browser: "./dist/browser/index.js" + }; + + if (config.generateReactNativeTarget) { + result["react-native"] = "./dist/react-native/index.js"; + } + + return result; +} + +/** + * Resolve source-level exports to dist-level exports for warp. + * Converts { ".": "./src/index.ts" } to the nested condition map with + * browser/import/require conditions pointing to dist/ paths. + */ +export function resolveWarpExports( + sourceExports?: Record, + includeReactNative?: boolean +): Record { + const exports: Record = {}; + const allExports: Record = { + "./package.json": "./package.json", + ".": "./src/index.ts", + ...sourceExports + }; + + for (const [subpath, sourcePath] of Object.entries(allExports)) { + // Pass-through entries (e.g. "./package.json": "./package.json") + if (!/\.ts$/.test(sourcePath)) { + exports[subpath] = sourcePath; + continue; + } + + // Convert source path to dist path: "./src/foo/index.ts" -> "foo/index" + const relPath = sourcePath.replace(/^\.\/src\//, "").replace(/\.ts$/, ""); + + const exportEntry: Record = { + browser: { + types: `./dist/browser/${relPath}.d.ts`, + default: `./dist/browser/${relPath}.js` + } + }; + + if (includeReactNative) { + exportEntry["react-native"] = { + types: `./dist/react-native/${relPath}.d.ts`, + default: `./dist/react-native/${relPath}.js` + }; + } + + exportEntry["import"] = { + types: `./dist/esm/${relPath}.d.ts`, + default: `./dist/esm/${relPath}.js` + }; + + exportEntry["require"] = { + types: `./dist/commonjs/${relPath}.d.ts`, + default: `./dist/commonjs/${relPath}.js` + }; + + exports[subpath] = exportEntry; + } + + return exports; +} + +export function getTshyConfig(config: PackageCommonInfoConfig) { + const { exports = {} } = config; + const esmDialects = config.generateReactNativeTarget + ? ["browser", "react-native"] + : ["browser"]; + const tshyConfig: Record = { + exports: { + "./package.json": "./package.json", + ".": "./src/index.ts", + ...exports + }, + dialects: ["esm", "commonjs"], + esmDialects, + selfLink: false + }; + if (config.azureSdkForJs) { + tshyConfig["project"] = "../../../tsconfig.src.build.json"; + } + return tshyConfig; +} + +export function getCommonPackageScripts() { + return { + clean: + "rimraf --glob dist dist-browser dist-esm test-dist temp types *.tgz *.log", + "extract-api": + "rimraf review && mkdirp ./review && api-extractor run --local", + pack: "npm pack 2>&1", + lint: "eslint package.json api-extractor.json src", + "lint:fix": + "eslint package.json api-extractor.json src --fix --fix-type [problem,suggestion]" + }; +} diff --git a/packages/typespec-ts/src/rlc-common/metadata/utils.ts b/packages/typespec-ts/src/rlc-common/metadata/utils.ts new file mode 100644 index 0000000000..bf5bddda62 --- /dev/null +++ b/packages/typespec-ts/src/rlc-common/metadata/utils.ts @@ -0,0 +1,8 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { RLCModel } from "../interfaces.js"; + +export function getPackageName(model: RLCModel): string { + return model.options?.packageDetails?.name ?? model.libraryName; +} diff --git a/packages/typespec-ts/src/rlc-common/static/paginateContent.ts b/packages/typespec-ts/src/rlc-common/static/paginateContent.ts new file mode 100644 index 0000000000..ba0af9d93a --- /dev/null +++ b/packages/typespec-ts/src/rlc-common/static/paginateContent.ts @@ -0,0 +1,345 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +export const paginateContent = ` +import type { Client, PathUncheckedResponse } from "@azure-rest/core-client"; +import { createRestError } from "@azure-rest/core-client"; + +/** + * returns an async iterator that iterates over results. It also has a \`byPage\` + * method that returns pages of items at once. + * + * @param pagedResult - an object that specifies how to get pages. + * @returns a paged async iterator that iterates over results. + */ +function getPagedAsyncIterator< + TElement, + TPage = TElement[], + TPageSettings = PageSettings, + TLink = string, +>( + pagedResult: PagedResult, +): PagedAsyncIterableIterator { + const iter = getItemAsyncIterator(pagedResult); + return { + next() { + return iter.next(); + }, + [Symbol.asyncIterator]() { + return this; + }, + byPage: + pagedResult?.byPage ?? + (((settings?: PageSettings) => { + const { continuationToken } = settings ?? {}; + return getPageAsyncIterator(pagedResult, { + pageLink: continuationToken as unknown as TLink | undefined, + }); + }) as unknown as (settings?: TPageSettings) => AsyncIterableIterator), + }; +} + +async function* getItemAsyncIterator( + pagedResult: PagedResult, +): AsyncIterableIterator { + const pages = getPageAsyncIterator(pagedResult); + const firstVal = await pages.next(); + // if the result does not have an array shape, i.e. TPage = TElement, then we return it as is + if (!Array.isArray(firstVal.value)) { + // can extract elements from this page + const { toElements } = pagedResult; + if (toElements) { + yield* toElements(firstVal.value) as TElement[]; + for await (const page of pages) { + yield* toElements(page) as TElement[]; + } + } else { + yield firstVal.value; + // \`pages\` is of type \`AsyncIterableIterator\` but TPage = TElement in this case + yield* pages as unknown as AsyncIterableIterator; + } + } else { + yield* firstVal.value; + for await (const page of pages) { + // pages is of type \`AsyncIterableIterator\` so \`page\` is of type \`TPage\`. In this branch, + // it must be the case that \`TPage = TElement[]\` + yield* page as unknown as TElement[]; + } + } +} + +async function* getPageAsyncIterator( + pagedResult: PagedResult, + options: { + pageLink?: TLink; + } = {}, +): AsyncIterableIterator { + const { pageLink } = options; + let response = await pagedResult.getPage(pageLink ?? pagedResult.firstPageLink); + if (!response) { + return; + } + yield response.page; + while (response.nextPageLink) { + response = await pagedResult.getPage(response.nextPageLink); + if (!response) { + return; + } + yield response.page; + } +} + +/** + * An interface that tracks the settings for paged iteration + */ +export interface PageSettings { + /** + * The token that keeps track of where to continue the iterator + */ + continuationToken?: string; +} + +/** + * An interface that allows async iterable iteration both to completion and by page. + */ +export interface PagedAsyncIterableIterator< + TElement, + TPage = TElement[], + TPageSettings = PageSettings, +> { + /** + * The next method, part of the iteration protocol + */ + next(): Promise>; + /** + * The connection to the async iterator, part of the iteration protocol + */ + [Symbol.asyncIterator](): PagedAsyncIterableIterator; + /** + * Return an AsyncIterableIterator that works a page at a time + */ + byPage: (settings?: TPageSettings) => AsyncIterableIterator; +} + +/** + * An interface that describes how to communicate with the service. + */ +interface PagedResult { + /** + * Link to the first page of results. + */ + firstPageLink: TLink; + /** + * A method that returns a page of results. + */ + getPage: ( + pageLink: TLink, + ) => Promise<{ page: TPage; nextPageLink?: TLink } | undefined>; + /** + * a function to implement the \`byPage\` method on the paged async iterator. + */ + byPage?: (settings?: TPageSettings) => AsyncIterableIterator; + + /** + * A function to extract elements from a page. + */ + toElements?: (page: TPage) => unknown[]; +} + +/** + * Helper type to extract the type of an array + */ +export type GetArrayType = T extends Array ? TData : never; + +/** + * The type of a custom function that defines how to get a page and a link to the next one if any. + */ +export type GetPage = ( + pageLink: string, +) => Promise<{ + page: TPage; + nextPageLink?: string; +}>; + +/** + * Options for the paging helper + */ +export interface PagingOptions { + /** + * Custom function to extract pagination details for crating the PagedAsyncIterableIterator + */ + customGetPage?: GetPage[]> +} + +/** + * Helper type to infer the Type of the paged elements from the response type + * This type is generated based on the swagger information for x-ms-pageable + * specifically on the itemName property which indicates the property of the response + * where the page items are found. The default value is \`value\`. + * This type will allow us to provide strongly typed Iterator based on the response we get as second parameter + */ + export type PaginateReturn = TResult extends {{#each itemNames}} + { + + body: { {{this}}?: infer TPage } + +} {{#if @last }}{{else}} | {{/if}} +{{/each}} + ? GetArrayType + : Array; + + /** + * Helper to paginate results from an initial response that follows the specification of Autorest \`x-ms-pageable\` extension + * @param client - Client to use for sending the next page requests + * @param initialResponse - Initial response containing the nextLink and current page of elements + * @param customGetPage - Optional - Function to define how to extract the page and next link to be used to paginate the results + * @returns - PagedAsyncIterableIterator to iterate the elements + */ + export function paginate( + client: Client, + initialResponse: TResponse, + options: PagingOptions = {} + ): PagedAsyncIterableIterator> { + // Extract element type from initial response + type TElement = PaginateReturn; + let firstRun = true; + {{#if isComplexPaging}} + // We need to check the response for success before trying to inspect it looking for + // the properties to use for nextLink and itemName + checkPagingRequest(initialResponse); + const { itemName, nextLinkName } = getPaginationProperties(initialResponse); + {{else}} + const itemName = {{ quoteWrap itemNames }}; + const nextLinkName = {{quoteWrap nextLinkNames}}; + {{/if}} + const { customGetPage } = options; + const pagedResult: PagedResult = { + firstPageLink: "", + getPage: + typeof customGetPage === "function" + ? customGetPage + : async (pageLink: string) => { + const result = firstRun + ? initialResponse + : await client.pathUnchecked(pageLink).get(); + firstRun = false; + checkPagingRequest(result); + const nextLink = getNextLink(result.body, nextLinkName); + const values = getElements(result.body, itemName); + return { + page: values, + nextPageLink: nextLink + }; + } + }; + + return getPagedAsyncIterator(pagedResult); +} + +/** + * Gets for the value of nextLink in the body + */ +function getNextLink(body: unknown, nextLinkName?: string): string | undefined { + if (!nextLinkName) { + return undefined; + } + + const nextLink = (body as Record)[nextLinkName]; + + if (typeof nextLink !== "string" && typeof nextLink !== "undefined") { + throw new Error( + \`Body Property \${nextLinkName} should be a string or undefined\` + ); + } + + return nextLink; +} + +/** + * Gets the elements of the current request in the body. + */ +function getElements(body: unknown, itemName: string): T[] { + const value = (body as Record)[itemName] as T[]; + + // value has to be an array according to the x-ms-pageable extension. + // The fact that this must be an array is used above to calculate the + // type of elements in the page in PaginateReturn + if (!Array.isArray(value)) { + throw new Error( + \`Couldn't paginate response\\n Body doesn't contain an array property with name: \${itemName}\` + ); + } + + return value ?? []; +} + +/** + * Checks if a request failed + */ +function checkPagingRequest(response: PathUncheckedResponse): void { + const Http2xxStatusCodes = [ + "200", + "201", + "202", + "203", + "204", + "205", + "206", + "207", + "208", + "226" + ]; + if (!Http2xxStatusCodes.includes(response.status)) { + throw createRestError( + \`Pagination failed with unexpected statusCode \${response.status}\`, + response + ); + } +} + +{{#if isComplexPaging}} +/** + * Extracts the itemName and nextLinkName from the initial response to use them for pagination + */ +function getPaginationProperties(initialResponse: PathUncheckedResponse) { + // Build a set with the passed custom nextLinkNames + const nextLinkNames = new Set([{{ quoteWrap nextLinkNames }}]); + + // Build a set with the passed custom set of itemNames + const itemNames = new Set([{{ quoteWrap itemNames }}]); + + let nextLinkName: string | undefined; + let itemName: string | undefined; + + for (const name of nextLinkNames) { + const nextLink = (initialResponse.body as Record)[ + name + ] as string; + if (nextLink) { + nextLinkName = name; + break; + } + } + + for (const name of itemNames) { + const item = (initialResponse.body as Record)[ + name + ] as string; + if (item) { + itemName = name; + break; + } + } + + if (!itemName) { + throw new Error( + \`Couldn't paginate response\\n Body doesn't contain an array property with name: \${[ + ...itemNames + ].join(" OR ")}\` + ); + } + + return { itemName, nextLinkName }; +} +{{/if}} +`; diff --git a/packages/typespec-ts/src/rlc-common/static/pollingContent.ts b/packages/typespec-ts/src/rlc-common/static/pollingContent.ts new file mode 100644 index 0000000000..d8d379c695 --- /dev/null +++ b/packages/typespec-ts/src/rlc-common/static/pollingContent.ts @@ -0,0 +1,225 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +export const pollingContent = ` +import type { Client, HttpResponse } from "@azure-rest/core-client"; +import type { AbortSignalLike } from "@azure/abort-controller"; +import type { + CancelOnProgress, + CreateHttpPollerOptions, + RunningOperation, + OperationResponse, + OperationState, +} from "@azure/core-lro"; + import { createHttpPoller } from "@azure/core-lro"; +{{#if clientOverload}} +import type { + {{#each importedResponses}} + {{this}}, + {{/each}} +} from "./responses{{#if isEsm}}.js{{/if}}"; +{{/if}} + +/** + * A simple poller that can be used to poll a long running operation. + */ +export interface SimplePollerLike< + TState extends OperationState, + TResult +> { + /** + * Returns true if the poller has finished polling. + */ + isDone(): boolean; + /** + * Returns the state of the operation. + */ + getOperationState(): TState; + /** + * Returns the result value of the operation, + * regardless of the state of the poller. + * It can return undefined or an incomplete form of the final TResult value + * depending on the implementation. + */ + getResult(): TResult | undefined; + /** + * Returns a promise that will resolve once a single polling request finishes. + * It does this by calling the update method of the Poller's operation. + */ + poll(options?: { abortSignal?: AbortSignalLike }): Promise; + /** + * Returns a promise that will resolve once the underlying operation is completed. + */ + pollUntilDone(pollOptions?: { + abortSignal?: AbortSignalLike; + }): Promise; + /** + * Invokes the provided callback after each polling is completed, + * sending the current state of the poller's operation. + * + * It returns a method that can be used to stop receiving updates on the given callback function. + */ + onProgress(callback: (state: TState) => void): CancelOnProgress; + + /** + * Returns a promise that could be used for serialized version of the poller's operation + * by invoking the operation's serialize method. + */ + serialize(): Promise; + + /** + * Wait the poller to be submitted. + */ + submitted(): Promise; + + /** + * Returns a string representation of the poller's operation. Similar to serialize but returns a string. + * @deprecated Use serialize() instead. + */ + toString(): string; + + /** + * Stops the poller from continuing to poll. Please note this will only stop the client-side polling + * @deprecated Use abortSignal to stop polling instead. + */ + stopPolling(): void; + + /** + * Returns true if the poller is stopped. + * @deprecated Use abortSignal status to track this instead. + */ + isStopped(): boolean; +} + +/** + * Helper function that builds a Poller object to help polling a long running operation. + * @param client - Client to use for sending the request to get additional pages. + * @param initialResponse - The initial response. + * @param options - Options to set a resume state or custom polling interval. + * @returns - A poller object to poll for operation state updates and eventually get the final response. + */ +{{#if clientOverload}} +{{#each overloadMap}} +export async function getLongRunningPoller< + TResult extends {{ this.finalResponses }} +>( + client: Client, + initialResponse: {{ this.initialResponses }}, + options?: CreateHttpPollerOptions> +): Promise, TResult>>; +{{/each}} +{{/if}} +export async function getLongRunningPoller( + client: Client, + initialResponse: TResult, + options: CreateHttpPollerOptions> = {} + ): Promise, TResult>> { + const abortController = new AbortController(); + const poller: RunningOperation = { + sendInitialRequest: async () => { + // In the case of Rest Clients we are building the LRO poller object from a response that's the reason + // we are not triggering the initial request here, just extracting the information from the + // response we were provided. + return getLroResponse(initialResponse); + }, + sendPollRequest: async ( + path: string, + pollOptions?: { abortSignal?: AbortSignalLike } + ) => { + // This is the callback that is going to be called to poll the service + // to get the latest status. We use the client provided and the polling path + // which is an opaque URL provided by caller, the service sends this in one of the following headers: operation-location, azure-asyncoperation or location + // depending on the lro pattern that the service implements. If non is provided we default to the initial path. + function abortListener(): void { + abortController.abort(); + } + const inputAbortSignal = pollOptions?.abortSignal; + const abortSignal = abortController.signal; + if (inputAbortSignal?.aborted) { + abortController.abort(); + } else if (!abortSignal.aborted) { + inputAbortSignal?.addEventListener("abort", abortListener, { + once: true + }); + } + let response; + try { + response = await client + .pathUnchecked(path ?? initialResponse.request.url) + .get({ abortSignal }); + } finally { + inputAbortSignal?.removeEventListener("abort", abortListener); + } + const lroResponse = getLroResponse(response as TResult); + lroResponse.rawResponse.headers["x-ms-original-url"] = + initialResponse.request.url; + return lroResponse; + } + }; + + options.resolveOnUnsuccessful = options.resolveOnUnsuccessful ?? true; + const httpPoller = createHttpPoller(poller, options); + const simplePoller: SimplePollerLike, TResult> = { + isDone() { + return httpPoller.isDone; + }, + isStopped() { + return abortController.signal.aborted; + }, + getOperationState() { + if (!httpPoller.operationState) { + throw new Error( + "Operation state is not available. The poller may not have been started and you could await submitted() before calling getOperationState()." + ); + } + return httpPoller.operationState; + }, + getResult() { + return httpPoller.result; + }, + toString() { + if (!httpPoller.operationState) { + throw new Error( + "Operation state is not available. The poller may not have been started and you could await submitted() before calling getOperationState()." + ); + } + return JSON.stringify({ + state: httpPoller.operationState + }); + }, + stopPolling() { + abortController.abort(); + }, + onProgress: httpPoller.onProgress, + poll: httpPoller.poll, + pollUntilDone: httpPoller.pollUntilDone, + serialize: httpPoller.serialize, + submitted: httpPoller.submitted + }; + return simplePoller; +} + +/** + * Converts a Rest Client response to a response that the LRO implementation understands + * @param response - a rest client http response + * @returns - An LRO response that the LRO implementation understands + */ +function getLroResponse( + response: TResult +): OperationResponse { + if (Number.isNaN(response.status)) { + throw new TypeError( + \`Status code of the response is not a number. Value: \${response.status}\` + ); + } + + return { + flatResponse: response, + rawResponse: { + ...response, + statusCode: Number.parseInt(response.status), + body: response.body + } + }; +} +`; diff --git a/packages/typespec-ts/src/rlc-common/static/sampleTemplate.ts b/packages/typespec-ts/src/rlc-common/static/sampleTemplate.ts new file mode 100644 index 0000000000..85437dbd5f --- /dev/null +++ b/packages/typespec-ts/src/rlc-common/static/sampleTemplate.ts @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +export const sampleTemplate = ` +{{#each importedTypes}} +{{this}} +{{/each}} +import "dotenv/config"; + +{{#each samples}} +/** + * This sample demonstrates how to {{this.description}} + * + * @summary {{this.description}} + {{#if this.originalFileLocation}} + * x-ms-original-file: {{this.originalFileLocation}} + {{/if}} + */ +async function {{name}}(): Promise { + {{#each this.clientParamAssignments}} + {{this}} + {{/each}} + const client = {{this.defaultFactoryName}}({{this.clientParamNames}}); + {{#each this.pathParamAssignments}} + {{this}} + {{/each}} + {{#each this.methodParamAssignments}} + {{this}} + {{/each}} + {{#if this.isPaging}} + const initialResponse = await client.path({{this.pathParamNames}}).{{method}}({{methodParamNames}}); + const pageData = paginate(client, initialResponse); + const result = []; + for await (const item of pageData) { + result.push(item); + } + {{else if this.isLRO}} + const initialResponse ={{#unless this.useLegacyLro}} await{{/unless}} client.path({{this.pathParamNames}}).{{method}}({{methodParamNames}}); + const poller = await getLongRunningPoller(client, initialResponse); + const result = await poller.pollUntilDone(); + {{else}} + const result = await client.path({{this.pathParamNames}}).{{method}}({{methodParamNames}}); + {{/if}} + console.log(result); +} + +{{/each}} + +async function main(): Promise { +{{#each samples}} + await {{this.name}}(); +{{/each}} +} + +main().catch(console.error); +`; diff --git a/packages/typespec-ts/src/rlc-common/static/serializeHelper.ts b/packages/typespec-ts/src/rlc-common/static/serializeHelper.ts new file mode 100644 index 0000000000..1e9b7ca8c0 --- /dev/null +++ b/packages/typespec-ts/src/rlc-common/static/serializeHelper.ts @@ -0,0 +1,34 @@ +export const buildMultiCollectionContent = ` +export function buildMultiCollection( + items: string[], + parameterName: string +): string { + return items + .map((item, index) => { + if (index === 0) { + return item; + } + return \`\${parameterName}=\${item}\`; + }) + .join("&"); +}`; + +export const buildPipeCollectionContent = ` +export function buildPipeCollection(items: string[] | number[]): string { + return items.join("|"); +}`; + +export const buildSsvCollectionContent = ` +export function buildSsvCollection(items: string[] | number[]): string { + return items.join(" "); +}`; + +export const buildTsvCollectionContent = ` +export function buildTsvCollection(items: string[] | number[]): string { + return items.join("\\t"); +}`; + +export const buildCsvCollectionContent = ` +export function buildCsvCollection(items: string[] | number[]): string { + return items.join(","); +}`; diff --git a/packages/typespec-ts/src/rlc-common/test/buildKarmaConfig.ts b/packages/typespec-ts/src/rlc-common/test/buildKarmaConfig.ts new file mode 100644 index 0000000000..a0ba48acbc --- /dev/null +++ b/packages/typespec-ts/src/rlc-common/test/buildKarmaConfig.ts @@ -0,0 +1,12 @@ +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: to fix the handlebars issue +import hbs from "handlebars"; +import { RLCModel } from "../interfaces.js"; +import { karmaConfig } from "./template.js"; + +export function buildKarmaConfigFile(_model: RLCModel) { + return { + path: "karma.conf.js", + content: hbs.compile(karmaConfig, { noEscape: true })({}) + }; +} diff --git a/packages/typespec-ts/src/rlc-common/test/buildRecordedClient.ts b/packages/typespec-ts/src/rlc-common/test/buildRecordedClient.ts new file mode 100644 index 0000000000..b3e1c405ee --- /dev/null +++ b/packages/typespec-ts/src/rlc-common/test/buildRecordedClient.ts @@ -0,0 +1,18 @@ +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: to fix the handlebars issue +import hbs from "handlebars"; +import { RLCModel } from "../interfaces.js"; +import { recordedClientContent } from "./template.js"; + +export function buildRecordedClientFile(model: RLCModel) { + const recordedClientFileContents = hbs.compile(recordedClientContent, { + noEscape: true + }); + return { + path: "test/public/utils/recordedClient.ts", + content: recordedClientFileContents({ + isEsm: model.options?.moduleKind === "esm", + isCjs: model.options?.moduleKind === "cjs" + }) + }; +} diff --git a/packages/typespec-ts/src/rlc-common/test/buildSampleTest.ts b/packages/typespec-ts/src/rlc-common/test/buildSampleTest.ts new file mode 100644 index 0000000000..6e885e1753 --- /dev/null +++ b/packages/typespec-ts/src/rlc-common/test/buildSampleTest.ts @@ -0,0 +1,15 @@ +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: to fix the handlebars issue +import hbs from "handlebars"; +import { sampleTestContent } from "./template.js"; +import { RLCModel } from "../interfaces.js"; + +export function buildSampleTest(model: RLCModel) { + return { + path: "test/public/sampleTest.spec.ts", + content: hbs.compile(sampleTestContent, { noEscape: true })({ + isEsm: model.options?.moduleKind === "esm", + isCjs: model.options?.moduleKind === "cjs" + }) + }; +} diff --git a/packages/typespec-ts/src/rlc-common/test/buildSnippets.ts b/packages/typespec-ts/src/rlc-common/test/buildSnippets.ts new file mode 100644 index 0000000000..4f4d3b974c --- /dev/null +++ b/packages/typespec-ts/src/rlc-common/test/buildSnippets.ts @@ -0,0 +1,35 @@ +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: to fix the handlebars issue +import hbs from "handlebars"; +import { snippetsContent } from "./template.js"; +import { RLCModel } from "../interfaces.js"; +import { getClientName } from "../helpers/nameConstructors.js"; + +export function buildSnippets( + model: RLCModel, + clientName?: string, + azureSdkForJs?: boolean +) { + const azureSdkForJsInfo = azureSdkForJs + ? azureSdkForJs + : model.options?.azureSdkForJs; + // to keep the same config for azure scope in buildReadmeFile.ts + if ( + (model?.options?.packageDetails?.scopeName === "azure" || + model?.options?.packageDetails?.scopeName === "azure-rest") && + model.options.addCredentials && + azureSdkForJsInfo + ) { + return { + path: "test/snippets.spec.ts", + content: hbs.compile(snippetsContent, { noEscape: true })({ + clientClassName: clientName ? clientName : getClientName(model), + azureArm: model.options?.azureArm, + azureSdkForJs: azureSdkForJsInfo, + isModularLibrary: model.options.isModularLibrary, + hasSubscriptionId: model.options.hasSubscriptionId + }) + }; + } + return undefined; +} diff --git a/packages/typespec-ts/src/rlc-common/test/template.ts b/packages/typespec-ts/src/rlc-common/test/template.ts new file mode 100644 index 0000000000..7841587849 --- /dev/null +++ b/packages/typespec-ts/src/rlc-common/test/template.ts @@ -0,0 +1,263 @@ +export const karmaConfig = ` +// https://github.com/karma-runner/karma-chrome-launcher +process.env.CHROME_BIN = require("puppeteer").executablePath(); +require("dotenv").config(); +const { relativeRecordingsPath } = require("@azure-tools/test-recorder"); +process.env.RECORDINGS_RELATIVE_PATH = relativeRecordingsPath(); + +module.exports = function (config) { + config.set({ + // base path that will be used to resolve all patterns (eg. files, exclude) + basePath: "./", + + // frameworks to use + // available frameworks: https://npmjs.org/browse/keyword/karma-adapter + frameworks: ["source-map-support", "mocha"], + + plugins: [ + "karma-mocha", + "karma-mocha-reporter", + "karma-chrome-launcher", + "karma-firefox-launcher", + "karma-env-preprocessor", + "karma-coverage", + "karma-sourcemap-loader", + "karma-junit-reporter", + "karma-source-map-support", + ], + + // list of files / patterns to load in the browser + files: [ + "dist-test/index.browser.js", + { pattern: "dist-test/index.browser.js.map", type: "html", included: false, served: true }, + ], + + // list of files / patterns to exclude + exclude: [], + + // preprocess matching files before serving them to the browser + // available preprocessors: https://npmjs.org/browse/keyword/karma-preprocessor + preprocessors: { + "**/*.js": ["sourcemap", "env"], + // IMPORTANT: COMMENT following line if you want to debug in your browsers!! + // Preprocess source file to calculate code coverage, however this will make source file unreadable + // "dist-test/index.js": ["coverage"] + }, + + envPreprocessor: [ + "TEST_MODE", + "ENDPOINT", + "AZURE_CLIENT_SECRET", + "AZURE_CLIENT_ID", + "AZURE_TENANT_ID", + "SUBSCRIPTION_ID", + "RECORDINGS_RELATIVE_PATH", + ], + + // test results reporter to use + // possible values: 'dots', 'progress' + // available reporters: https://npmjs.org/browse/keyword/karma-reporter + reporters: ["mocha", "coverage", "junit"], + + coverageReporter: { + // specify a common output directory + dir: "coverage-browser/", + reporters: [ + { type: "json", subdir: ".", file: "coverage.json" }, + { type: "lcovonly", subdir: ".", file: "lcov.info" }, + { type: "html", subdir: "html" }, + { type: "cobertura", subdir: ".", file: "cobertura-coverage.xml" }, + ], + }, + + junitReporter: { + outputDir: "", // results will be saved as $outputDir/$browserName.xml + outputFile: "test-results.browser.xml", // if included, results will be saved as $outputDir/$browserName/$outputFile + suite: "", // suite will become the package name attribute in xml testsuite element + useBrowserName: false, // add browser name to report and classes names + nameFormatter: undefined, // function (browser, result) to customize the name attribute in xml testcase element + classNameFormatter: undefined, // function (browser, result) to customize the classname attribute in xml testcase element + properties: {}, // key value pair of properties to add to the section of the report + }, + + // web server port + port: 9876, + + // enable / disable colors in the output (reporters and logs) + colors: true, + + // level of logging + // possible values: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG + logLevel: config.LOG_INFO, + + // enable / disable watching file and executing tests whenever any file changes + autoWatch: false, + + // --no-sandbox allows our tests to run in Linux without having to change the system. + // --disable-web-security allows us to authenticate from the browser without having to write tests using interactive auth, which would be far more complex. + browsers: ["ChromeHeadlessNoSandbox"], + customLaunchers: { + ChromeHeadlessNoSandbox: { + base: "ChromeHeadless", + flags: ["--no-sandbox", "--disable-web-security"], + }, + }, + + // Continuous Integration mode + // if true, Karma captures browsers, runs the tests and exits + singleRun: false, + + // Concurrency level + // how many browser should be started simultaneous + concurrency: 1, + + browserNoActivityTimeout: 60000000, + browserDisconnectTimeout: 10000, + browserDisconnectTolerance: 3, + + client: { + mocha: { + // change Karma's debug.html to the mocha web reporter + reporter: "html", + timeout: "600000", + }, + }, + }); +}; +`; + +export const recordedClientContent = ` + +{{#if isEsm}} +import { + Recorder, + RecorderStartOptions, + VitestTestContext, +} from "@azure-tools/test-recorder"; +{{/if}} + +{{#if isCjs}} +import { Context } from "mocha"; +import { Recorder, RecorderStartOptions } from "@azure-tools/test-recorder"; +{{/if}} + +const replaceableVariables: Record = { + SUBSCRIPTION_ID: "azure_subscription_id" +}; + +const recorderEnvSetup: RecorderStartOptions = { + envSetupForPlayback: replaceableVariables, +}; + +/** + * creates the recorder and reads the environment variables from the \`.env\` file. + * Should be called first in the test suite to make sure environment variables are + * read before they are being used. + */ +{{#if isEsm}} +export async function createRecorder(context: VitestTestContext): Promise { + const recorder = new Recorder(context); + await recorder.start(recorderEnvSetup); + return recorder; +} +{{/if}} + +{{#if isCjs}} +export async function createRecorder(context: Context): Promise { + const recorder = new Recorder(context.currentTest); + await recorder.start(recorderEnvSetup); + return recorder; +} +{{/if}} +`; + +export const sampleTestContent = ` +{{#if isEsm}} +// import { Recorder } from "@azure-tools/test-recorder"; +// import { createRecorder } from "./utils/recordedClient.js"; +import { assert, + // beforeEach, + // afterEach, + it, + describe +} from "vitest"; +{{/if}} + +{{#if isCjs}} +// import { Recorder } from "@azure-tools/test-recorder"; +import { assert } from "chai"; +// import { createRecorder } from "./utils/recordedClient{{#if isModularLibrary}}.js{{/if}}"; +// import { Context } from "mocha"; +{{/if}} + +describe("My test", () => { + // let recorder: Recorder; + + // beforeEach(async function({{#if isCjs}}this: Context{{else}}ctx{{/if}}) { + {{#if isEsm}} + // recorder = await createRecorder(ctx); + {{else}} + // recorder = await createRecorder(this); + {{/if}} + // }); + + // afterEach(async function() { + // await recorder.stop(); + // }); + + it("sample test", async function() { + assert.equal(1, 1); + }); +}); +`; + +export const snippetsContent = ` +{{#if isModularLibrary}} +import { {{ clientClassName }} } from "../src/index.js"; +import { DefaultAzureCredential, InteractiveBrowserCredential } from "@azure/identity"; +{{/if}} +import { setLogLevel } from "@azure/logger"; +import { describe, it } from "vitest"; + +describe("snippets", () => { +{{#if isModularLibrary}} + it("ReadmeSampleCreateClient_Node", async () => { + {{#if azureArm}} + {{#if hasSubscriptionId}} + const subscriptionId = "00000000-0000-0000-0000-000000000000"; + const client = new {{ clientClassName }}(new DefaultAzureCredential(), subscriptionId); + {{else}} + const client = new {{ clientClassName }}(new DefaultAzureCredential()); + {{/if}} + {{else}} + const client = new {{ clientClassName }}("", new DefaultAzureCredential()); + {{/if}} + }); + + it("ReadmeSampleCreateClient_Browser", async () => { + {{#if azureArm}} + const credential = new InteractiveBrowserCredential({ + tenantId: "", + clientId: "", + }); + {{#if hasSubscriptionId}} + const subscriptionId = "00000000-0000-0000-0000-000000000000"; + const client = new {{ clientClassName }}(credential, subscriptionId); + {{else}} + const client = new {{ clientClassName }}(credential); + {{/if}} + {{else}} + const credential = new InteractiveBrowserCredential({ + tenantId: "", + clientId: "", + }); + const client = new {{ clientClassName }}("", credential); + {{/if}} + }); + {{/if}} + + it("SetLogLevel", async () => { + setLogLevel("info"); + }); +}); +`; diff --git a/packages/typespec-ts/src/rlc-common/transformSampleGroups.ts b/packages/typespec-ts/src/rlc-common/transformSampleGroups.ts new file mode 100644 index 0000000000..be3bfc395a --- /dev/null +++ b/packages/typespec-ts/src/rlc-common/transformSampleGroups.ts @@ -0,0 +1,458 @@ +import { generateParameterTypeValue } from "./helpers/valueGenerationUtil.js"; +import { getClientName } from "./helpers/nameConstructors.js"; +import { normalizeName, NameType, camelCase } from "./helpers/nameUtils.js"; +import { buildSchemaObjectMap } from "./helpers/schemaHelpers.js"; +import { + RLCModel, + RLCSampleGroup, + Paths, + OperationMethod, + RLCSampleDetail, + SampleParameters, + SampleParameter, + Schema, + PathMetadata, + OperationParameter +} from "./interfaces.js"; +import { isAzurePackage } from "./helpers/packageUtil.js"; + +/** + * Transform the sample data based RLC detail e.g path, operations & schemas + * @param model RLC detail + * @param allowMockValue allow to mock value if not exist, currently we always generate mock value + * @returns Generated sample data or undefined if not support to generate + */ +export function transformSampleGroups(model: RLCModel, allowMockValue = true) { + if (model.options?.multiClient || model.options?.isModularLibrary) { + // Not support to generate if multiple clients + // Not support to generate if modular libraries + return; + } + if ( + (model.sampleGroups && model.sampleGroups.length > 0) || + !allowMockValue + ) { + // Skip to transform if already has sample data + // Skip to transform if not allow to mock value + return; + } + const rlcSampleGroups: RLCSampleGroup[] = []; + // Get all paths + const paths: Paths = model.paths; + const clientName = getClientName(model); + const clientInterfaceName = clientName.endsWith("Client") + ? `${clientName}` + : `${clientName}Client`; + const defaultFactoryName = normalizeName( + camelCase(`create ${clientInterfaceName}`), + NameType.Method + ); + const packageName = model.options?.packageDetails?.name ?? ""; + const methodParameterMap = buildMethodParamMap(model); + const schemaObjectMap = buildSchemaObjectMap(model); + for (const path in paths) { + const pathDetails = paths[path]; + if (!pathDetails) { + continue; + } + const methods = pathDetails.methods; + for (const method in methods) { + const importedDict: Record> = {}; + const methodArray = methods[method]; + if (!methodArray || methodArray.length === 0) { + continue; + } + const detail = methodArray[0]; + if (!detail) { + continue; + } + const operatonConcante = getOperationConcate( + detail.operationName, + pathDetails.operationGroupName, + model.options?.sourceFrom + ); + const operationPrefix = normalizeName( + camelCase(transformSpecialLetterToSpace(operatonConcante)), + NameType.Operation + ); + const sampleGroup: RLCSampleGroup = { + filename: `${operationPrefix}Sample`, + defaultFactoryName, + clientPackageName: packageName, + samples: [] + }; + + // initialize the sample + const sample: RLCSampleDetail = { + description: `call operation ${detail.operationName}`, + name: `${operationPrefix}Sample`, + path, + defaultFactoryName, + clientParamAssignments: [], + pathParamAssignments: [], + methodParamAssignments: [], + clientParamNames: "", + pathParamNames: "", + methodParamNames: "", + method, + isLRO: detail.operationHelperDetail?.lroDetails?.isLongRunning ?? false, + isPaging: detail.operationHelperDetail?.isPaging ?? false, + useLegacyLro: false + }; + // client-level, path-level and method-level parameter preparation + const parameters: SampleParameters = { + client: convertClientLevelParameters( + model, + importedDict, + schemaObjectMap + ), + path: convertPathLevelParameters(pathDetails, path, schemaObjectMap), + method: convertMethodLevelParameters( + methodArray, + schemaObjectMap, + methodParameterMap.get(operatonConcante) + ) + }; + // enrich parameter details + enrichParameterInSample(sample, parameters); + // enrich LRO and pagination info + enrichLROAndPagingInSample(detail, importedDict, packageName); + sampleGroup.samples.push(sample); + rlcSampleGroups.push(sampleGroup); + enrichImportedString( + sampleGroup, + importedDict, + defaultFactoryName, + packageName + ); + } + } + return rlcSampleGroups; +} + +function enrichLROAndPagingInSample( + operation: OperationMethod, + importedDict: Record>, + packageName: string +) { + const isLRO = + operation.operationHelperDetail?.lroDetails?.isLongRunning ?? false, + isPaging = operation.operationHelperDetail?.isPaging ?? false; + if (isPaging) { + if (isLRO) { + // TODO: report warning this is not supported + } + addValueInImportedDict(packageName, "paginate", importedDict); + } else if (isLRO) { + addValueInImportedDict(packageName, "getLongRunningPoller", importedDict); + } +} + +function transformSpecialLetterToSpace(str: string) { + if (!str) { + return str; + } + return str + .replace(/_/g, " ") + .replace(/\//g, " Or ") + .replace(/,|\.|\(|\)/g, " ") + .replace("'s ", " "); +} + +function enrichImportedString( + sampleGroup: RLCSampleGroup, + importedDict: Record>, + defaultFactoryName: string, + packageName: string +) { + const importedTypes: string[] = []; + if (!importedDict[packageName] || importedDict[packageName].size === 0) { + importedTypes.push(`import ${defaultFactoryName} from "${packageName}";`); + } + for (const key in importedDict) { + const importedSet = importedDict[key]; + if (!importedSet) { + continue; + } + const values = Array.from(importedSet).join(", "); + const hasDefaultFactory = + key === packageName ? `${defaultFactoryName},` : ""; + importedTypes.push( + `import ${hasDefaultFactory} { ${values} } from "${key}";` + ); + } + sampleGroup.importedTypes = importedTypes; +} + +function enrichParameterInSample( + sample: RLCSampleDetail, + parameters: SampleParameters +) { + sample.clientParamAssignments = getAssignmentStrArray(parameters.client); + sample.clientParamNames = getContactParameterNames(parameters.client); + sample.pathParamAssignments = getAssignmentStrArray(parameters.path); + sample.pathParamNames = getContactParameterNames(parameters.path); + // Directly apply the inline option value as method parameter + sample.methodParamNames = + parameters.method.length > 0 ? (parameters.method[0]?.value ?? "") : ""; +} + +function getAssignmentStrArray(parameters: SampleParameter[]) { + return parameters.filter((p) => !!p.assignment).map((p) => p.assignment!); +} + +function getContactParameterNames(parameters: SampleParameter[]) { + return parameters + .filter((p) => p.name != null) + .map((p) => p.name!) + .join(","); +} + +function convertClientLevelParameters( + model: RLCModel, + importedDict: Record>, + schemaMap: Map +): SampleParameter[] { + if (!model.options) { + return []; + } + const clientParams: SampleParameter[] = []; + const urlParameters = model?.urlInfo?.urlParameters?.filter( + // Do not include parameters with constant values in the signature, these should go in the options bag + (p) => p.value === undefined + ); + const { + addCredentials, + credentialScopes, + credentialKeyHeaderName, + customHttpAuthHeaderName, + flavor + } = model.options; + const hasUrlParameter = !!urlParameters, + hasCredentials = + addCredentials && + (credentialScopes || credentialKeyHeaderName || customHttpAuthHeaderName); + + if (hasUrlParameter) { + // convert the host parameters in url + const clientParamAssignments = urlParameters.map((urlParameter) => { + const urlValue = generateParameterTypeValue( + urlParameter.type, + urlParameter.name, + schemaMap + ); + const normalizedName = normalizeName( + urlParameter.name, + NameType.Parameter + ); + return { + name: normalizedName, + assignment: `const ${normalizedName} = ` + urlValue + `;` + }; + }); + + clientParams.push(...clientParamAssignments); + } + if (hasCredentials) { + // Currently only support token credential + const apiKeyCredentialPackage = isAzurePackage(model) + ? "@azure/core-auth" + : "@typespec/ts-http-runtime"; + const tokenCredentialPackage = isAzurePackage(model) + ? "@azure/identity" + : "@typespec/ts-http-runtime"; + if (credentialKeyHeaderName && isAzurePackage(model)) { + clientParams.push({ + name: "credential", + assignment: `const credential = new AzureKeyCredential("{Your API key}");` + }); + addValueInImportedDict( + apiKeyCredentialPackage, + "AzureKeyCredential", + importedDict + ); + } else if ( + (credentialKeyHeaderName && flavor !== "azure") || + customHttpAuthHeaderName + ) { + clientParams.push({ + name: "credential", + assignment: `const credential = { key: "{Your API key}"};` + }); + } else if (isAzurePackage(model)) { + clientParams.push({ + name: "credential", + assignment: "const credential = new DefaultAzureCredential();" + }); + addValueInImportedDict( + tokenCredentialPackage, + "DefaultAzureCredential", + importedDict + ); + } else { + clientParams.push({ + name: "credential", + assignment: `const credential = {getToken: () => Promise.resolve({ token: "{Your token}", expiresOnTimestamp: 0 })};` + }); + } + } + return clientParams; +} + +function convertPathLevelParameters( + pathDetail: PathMetadata, + path: string, + schemaMap: Map +): SampleParameter[] { + const pathItself = { + name: `"${path}"` + }; + const pathParams = (pathDetail || []).pathParameters.map((p) => { + const pathParam: SampleParameter = { + name: normalizeName(p.name, NameType.Parameter) + }; + const value = generateParameterTypeValue(p.type, p.name, schemaMap); + pathParam.assignment = `const ${pathParam.name} =` + value + `;`; + return pathParam; + }); + return [pathItself].concat(pathParams); +} + +function convertMethodLevelParameters( + methods: OperationMethod[], + schemaMap: Map, + operationParameter?: OperationParameter +): SampleParameter[] { + if ( + !methods || + methods.length === 0 || + !operationParameter || + operationParameter.parameters.length === 0 + ) { + return []; + } + const rawMethodParams = operationParameter.parameters; + const method = methods[0]; + const requestParameter = rawMethodParams[0]; + if (!method || !requestParameter) { + return []; + } + const hasInputParams = !!rawMethodParams && rawMethodParams.length > 0, + requireParam = !method.hasOptionalOptions; + if (!hasInputParams && !requireParam) { + return []; + } + + const allSideAssignments = [], + querySideAssignments: string[] = [], + headerSideAssignments: string[] = []; + if ( + !!requestParameter.body && + requestParameter.body && + requestParameter.body.body && + requestParameter.body.body?.length > 0 + ) { + const body = requestParameter.body.body[0]; + if (body) { + const bodyTypeName = body.typeName ?? body.type; + if (bodyTypeName !== "string" && body.oriSchema) { + schemaMap.set(bodyTypeName, body.oriSchema); + } + allSideAssignments.push( + ` body: ` + generateParameterTypeValue(bodyTypeName, "body", schemaMap) + ); + } + } + + requestParameter.parameters + ?.filter((p) => p.type === "query") + .forEach((p) => { + const name = `${p.name}`; + querySideAssignments.push( + `${name}: ` + generateParameterTypeValue(p.param.type, name, schemaMap) + ); + }); + + if (querySideAssignments.length > 0) { + allSideAssignments.push( + ` queryParameters: { ` + querySideAssignments.join(", ") + `}` + ); + } + requestParameter.parameters + ?.filter((p) => p.type === "header") + .filter((p) => p.name.toLowerCase() !== "contenttype") + .forEach((p) => { + const name = `${p.name}`; + headerSideAssignments.push( + `${name}: ` + generateParameterTypeValue(p.param.type, name, schemaMap) + ); + }); + if (headerSideAssignments.length > 0) { + allSideAssignments.push( + ` headers: { ` + headerSideAssignments.join(", ") + `}` + ); + } + const contentType = requestParameter.parameters + ?.filter((p) => p.type === "header") + .filter((p) => p.name.toLowerCase() === "contenttype"); + const firstContentType = contentType?.[0]; + if (firstContentType) { + allSideAssignments.push( + ` ${firstContentType.name}: ` + + generateParameterTypeValue( + firstContentType.param.type, + firstContentType.name, + schemaMap + ) + ); + } + let value: string = `{}`; + if (allSideAssignments.length > 0) { + value = `{ ` + allSideAssignments.join(", ") + `}`; + } else { + return []; + } + + const optionParam: SampleParameter = { + name: "options", + assignment: `const options =` + value + `;`, + value + }; + return [optionParam]; +} + +function addValueInImportedDict( + key: string, + val: string, + importedDict: Record> +) { + if (!importedDict[key]) { + importedDict[key] = new Set(); + } + importedDict[key].add(val); +} + +function buildMethodParamMap(model: RLCModel): Map { + const map = new Map(); + (model.parameters ?? []).forEach((p) => { + const operatonConcante = getOperationConcate( + p.operationName, + p.operationGroup, + model.options?.sourceFrom + ); + map.set(operatonConcante, p); + }); + return map; +} + +function getOperationConcate( + opName: string, + opGroup: string, + sourceFrom?: string +) { + return sourceFrom === "Swagger" + ? opGroup === "" || opGroup === "Client" + ? opName + : `${opGroup}${opName}` + : `${opGroup}_${opName}`; +} diff --git a/packages/typespec-ts/src/transform/transform.ts b/packages/typespec-ts/src/transform/transform.ts index 7f28e74879..eb40649b4e 100644 --- a/packages/typespec-ts/src/transform/transform.ts +++ b/packages/typespec-ts/src/transform/transform.ts @@ -17,7 +17,7 @@ import { SchemaContext, transformSampleGroups, UrlInfo -} from "@azure-tools/rlc-common"; +} from "../rlc-common/index.js"; import { SdkClient } from "@azure-tools/typespec-client-generator-core"; import { getDoc } from "@typespec/compiler"; import { getServers } from "@typespec/http"; diff --git a/packages/typespec-ts/src/transform/transformApiVersionInfo.ts b/packages/typespec-ts/src/transform/transformApiVersionInfo.ts index 350d73e266..8f6b468c62 100644 --- a/packages/typespec-ts/src/transform/transformApiVersionInfo.ts +++ b/packages/typespec-ts/src/transform/transformApiVersionInfo.ts @@ -5,7 +5,7 @@ import { extractPathApiVersion, SchemaContext, UrlInfo -} from "@azure-tools/rlc-common"; +} from "../rlc-common/index.js"; import { getHttpOperationWithCache, isApiVersion, diff --git a/packages/typespec-ts/src/transform/transformHelperFunctionDetails.ts b/packages/typespec-ts/src/transform/transformHelperFunctionDetails.ts index ea6a456920..f0fa295916 100644 --- a/packages/typespec-ts/src/transform/transformHelperFunctionDetails.ts +++ b/packages/typespec-ts/src/transform/transformHelperFunctionDetails.ts @@ -1,4 +1,4 @@ -import { HelperFunctionDetails, PackageFlavor } from "@azure-tools/rlc-common"; +import { HelperFunctionDetails, PackageFlavor } from "../rlc-common/index.js"; import { getHttpOperationWithCache, SdkClient diff --git a/packages/typespec-ts/src/transform/transformParameters.ts b/packages/typespec-ts/src/transform/transformParameters.ts index b70e415b43..0ad9f7776c 100644 --- a/packages/typespec-ts/src/transform/transformParameters.ts +++ b/packages/typespec-ts/src/transform/transformParameters.ts @@ -10,7 +10,7 @@ import { ParameterMetadata, Schema, SchemaContext -} from "@azure-tools/rlc-common"; +} from "../rlc-common/index.js"; import { HttpOperation, HttpOperationParameter, diff --git a/packages/typespec-ts/src/transform/transformPaths.ts b/packages/typespec-ts/src/transform/transformPaths.ts index 54891ba8d8..8eaa3096cb 100644 --- a/packages/typespec-ts/src/transform/transformPaths.ts +++ b/packages/typespec-ts/src/transform/transformPaths.ts @@ -10,7 +10,7 @@ import { SchemaContext, getParameterTypeName, getResponseTypeName -} from "@azure-tools/rlc-common"; +} from "../rlc-common/index.js"; import { SdkClient, getHttpOperationWithCache, diff --git a/packages/typespec-ts/src/transform/transformResponses.ts b/packages/typespec-ts/src/transform/transformResponses.ts index f5c69c4fff..67b439436c 100644 --- a/packages/typespec-ts/src/transform/transformResponses.ts +++ b/packages/typespec-ts/src/transform/transformResponses.ts @@ -9,7 +9,7 @@ import { ResponseMetadata, Schema, SchemaContext -} from "@azure-tools/rlc-common"; +} from "../rlc-common/index.js"; import { getHttpOperationWithCache, SdkClient diff --git a/packages/typespec-ts/src/transform/transformSchemas.ts b/packages/typespec-ts/src/transform/transformSchemas.ts index 59365788ef..6eb4a37877 100644 --- a/packages/typespec-ts/src/transform/transformSchemas.ts +++ b/packages/typespec-ts/src/transform/transformSchemas.ts @@ -17,7 +17,7 @@ import { trimUsage } from "../utils/modelUtils.js"; -import { SchemaContext } from "@azure-tools/rlc-common"; +import { SchemaContext } from "../rlc-common/index.js"; import { SdkContext } from "../utils/interfaces.js"; import { useContext } from "../contextManager.js"; import { listOperationsUnderRLCClient } from "../utils/clientUtils.js"; diff --git a/packages/typespec-ts/src/transform/transformTelemetryInfo.ts b/packages/typespec-ts/src/transform/transformTelemetryInfo.ts index f6cc582891..e72c95c7f1 100644 --- a/packages/typespec-ts/src/transform/transformTelemetryInfo.ts +++ b/packages/typespec-ts/src/transform/transformTelemetryInfo.ts @@ -1,4 +1,4 @@ -import { TelemetryInfo } from "@azure-tools/rlc-common"; +import { TelemetryInfo } from "../rlc-common/index.js"; import { getHttpOperationWithCache, SdkClient, diff --git a/packages/typespec-ts/src/transform/transfromRLCOptions.ts b/packages/typespec-ts/src/transform/transfromRLCOptions.ts index 2867af363d..01f2a4fd99 100644 --- a/packages/typespec-ts/src/transform/transfromRLCOptions.ts +++ b/packages/typespec-ts/src/transform/transfromRLCOptions.ts @@ -6,7 +6,7 @@ import { PackageFlavor, RLCOptions, ServiceInfo -} from "@azure-tools/rlc-common"; +} from "../rlc-common/index.js"; import { getHttpOperationWithCache } from "@azure-tools/typespec-client-generator-core"; import { getDoc, NoTarget, Program } from "@typespec/compiler"; import { getAuthentication } from "@typespec/http"; diff --git a/packages/typespec-ts/src/utils/clientUtils.ts b/packages/typespec-ts/src/utils/clientUtils.ts index 9d4a1ef992..fd043215a5 100644 --- a/packages/typespec-ts/src/utils/clientUtils.ts +++ b/packages/typespec-ts/src/utils/clientUtils.ts @@ -16,7 +16,7 @@ import { } from "@typespec/compiler"; import { SdkContext } from "./interfaces.js"; import { ModularClientOptions } from "../modular/interfaces.js"; -import { NameType, normalizeName } from "@azure-tools/rlc-common"; +import { NameType, normalizeName } from "../rlc-common/index.js"; export function getRLCClients( dpgContext: SdkContext, diff --git a/packages/typespec-ts/src/utils/crossLanguageDef.ts b/packages/typespec-ts/src/utils/crossLanguageDef.ts index 01348cda25..8daba2855f 100644 --- a/packages/typespec-ts/src/utils/crossLanguageDef.ts +++ b/packages/typespec-ts/src/utils/crossLanguageDef.ts @@ -4,7 +4,7 @@ import { SdkContext } from "./interfaces.js"; import { transformModularEmitterOptions } from "../modular/buildModularOptions.js"; import { getMethodHierarchiesMap } from "./operationUtil.js"; -import { NameType, normalizeName } from "@azure-tools/rlc-common"; +import { NameType, normalizeName } from "../rlc-common/index.js"; import { UsageFlags } from "@azure-tools/typespec-client-generator-core"; export function generateCrossLanguageDefinitionFile(dpgContext: SdkContext): { diff --git a/packages/typespec-ts/src/utils/emitUtil.ts b/packages/typespec-ts/src/utils/emitUtil.ts index 3990ba1cd0..f27dbd72ff 100644 --- a/packages/typespec-ts/src/utils/emitUtil.ts +++ b/packages/typespec-ts/src/utils/emitUtil.ts @@ -4,7 +4,7 @@ import { File, isAzurePackage, RLCModel -} from "@azure-tools/rlc-common"; +} from "../rlc-common/index.js"; import { CompilerHost, Program, NoTarget } from "@typespec/compiler"; import { dirname, join } from "path"; import { format } from "prettier"; diff --git a/packages/typespec-ts/src/utils/interfaces.ts b/packages/typespec-ts/src/utils/interfaces.ts index e44f7271d4..b87900767f 100644 --- a/packages/typespec-ts/src/utils/interfaces.ts +++ b/packages/typespec-ts/src/utils/interfaces.ts @@ -1,4 +1,4 @@ -import { RLCOptions, SchemaContext } from "@azure-tools/rlc-common"; +import { RLCOptions, SchemaContext } from "../rlc-common/index.js"; import { SdkContext as TCGCSdkContext } from "@azure-tools/typespec-client-generator-core"; import { ModelProperty, Namespace } from "@typespec/compiler"; import { KnownMediaType } from "./mediaTypes.js"; diff --git a/packages/typespec-ts/src/utils/modelUtils.ts b/packages/typespec-ts/src/utils/modelUtils.ts index 71cbcebf46..131dc4db8e 100644 --- a/packages/typespec-ts/src/utils/modelUtils.ts +++ b/packages/typespec-ts/src/utils/modelUtils.ts @@ -10,7 +10,7 @@ import { SchemaContext, isArraySchema, normalizeName -} from "@azure-tools/rlc-common"; +} from "../rlc-common/index.js"; import { Discriminator, EncodeData, diff --git a/packages/typespec-ts/src/utils/operationUtil.ts b/packages/typespec-ts/src/utils/operationUtil.ts index e698aa52df..67c974e3c9 100644 --- a/packages/typespec-ts/src/utils/operationUtil.ts +++ b/packages/typespec-ts/src/utils/operationUtil.ts @@ -12,7 +12,7 @@ import { Paths, ResponseMetadata, ResponseTypes -} from "@azure-tools/rlc-common"; +} from "../rlc-common/index.js"; import { getLroMetadata } from "@azure-tools/typespec-azure-core"; import { getDisablePageable, diff --git a/packages/typespec-ts/src/utils/parameterUtils.ts b/packages/typespec-ts/src/utils/parameterUtils.ts index 6da0eb215e..821ad0071d 100644 --- a/packages/typespec-ts/src/utils/parameterUtils.ts +++ b/packages/typespec-ts/src/utils/parameterUtils.ts @@ -3,7 +3,7 @@ import { normalizeName, Schema, SchemaContext -} from "@azure-tools/rlc-common"; +} from "../rlc-common/index.js"; import { HttpOperationParameter } from "@typespec/http"; import { getTypeName, isArrayType, isObjectOrDictType } from "./modelUtils.js"; import { SdkContext } from "./interfaces.js"; diff --git a/packages/typespec-ts/test/unit/sample/generateSampleContent.spec.ts b/packages/typespec-ts/test/unit/sample/generateSampleContent.spec.ts index 5869c87a0d..f817d0d9b7 100644 --- a/packages/typespec-ts/test/unit/sample/generateSampleContent.spec.ts +++ b/packages/typespec-ts/test/unit/sample/generateSampleContent.spec.ts @@ -4,7 +4,7 @@ import { RLCModel, buildSchemaObjectMap, generateParameterTypeValue -} from "@azure-tools/rlc-common"; +} from "../../../src/rlc-common/index.js"; import { emitSchemasFromTypeSpec } from "../../util/emitUtil.js"; describe("Integration test for mocking sample", () => { diff --git a/packages/typespec-ts/test/unit/transform/transformSchemas.spec.ts b/packages/typespec-ts/test/unit/transform/transformSchemas.spec.ts index 3744c8e151..030a84fffa 100644 --- a/packages/typespec-ts/test/unit/transform/transformSchemas.spec.ts +++ b/packages/typespec-ts/test/unit/transform/transformSchemas.spec.ts @@ -1,6 +1,6 @@ import { describe, it, assert } from "vitest"; -import { ObjectSchema } from "@azure-tools/rlc-common"; +import { ObjectSchema } from "../../../src/rlc-common/index.js"; import { emitSchemasFromTypeSpec } from "../../util/emitUtil.js"; describe("#transformSchemas", () => { diff --git a/packages/typespec-ts/test/unit/utils/modelUtils.spec.ts b/packages/typespec-ts/test/unit/utils/modelUtils.spec.ts index c33bdfd299..985e4fabe8 100644 --- a/packages/typespec-ts/test/unit/utils/modelUtils.spec.ts +++ b/packages/typespec-ts/test/unit/utils/modelUtils.spec.ts @@ -1,6 +1,6 @@ import { describe, it, assert } from "vitest"; -import { ObjectSchema } from "@azure-tools/rlc-common"; +import { ObjectSchema } from "../../../src/rlc-common/index.js"; import { getModelInlineSigniture } from "../../../src/utils/modelUtils.js"; import { emitSchemasFromTypeSpec } from "../../util/emitUtil.js"; diff --git a/packages/typespec-ts/test/util/emitUtil.ts b/packages/typespec-ts/test/util/emitUtil.ts index aa954f5b83..a09f1c68ae 100644 --- a/packages/typespec-ts/test/util/emitUtil.ts +++ b/packages/typespec-ts/test/util/emitUtil.ts @@ -10,7 +10,7 @@ import { buildRuntimeImports, buildSchemaTypes, initInternalImports -} from "@azure-tools/rlc-common"; +} from "../../src/rlc-common/index.js"; import { emitTypes, emitNonModelResponseTypes, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5ebed25a9b..dc24128631 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -323,15 +323,15 @@ importers: packages/typespec-ts: dependencies: - '@azure-tools/rlc-common': - specifier: workspace:^0.53.1 - version: link:../rlc-common fast-xml-parser: specifier: ^4.5.0 version: 4.5.3 fs-extra: specifier: ^11.1.0 version: 11.3.2 + handlebars: + specifier: ^4.7.7 + version: 4.7.8 lodash: specifier: ^4.17.21 version: 4.17.21