diff --git a/tools/tsp-client/CHANGELOG.md b/tools/tsp-client/CHANGELOG.md index 5a60f9c133e..d00372437fa 100644 --- a/tools/tsp-client/CHANGELOG.md +++ b/tools/tsp-client/CHANGELOG.md @@ -1,5 +1,10 @@ # Release +## 2025-01-07 - 0.15.0 + +- Support specifying an entrypoint file in tsp-location.yaml. +- Ensure client.tsp selection over main.tsp in the entrypoint file search. + ## 2024-12-20 - 0.14.3 - Bumped `@autorest/openapi-to-typespec` version to `0.10.5`. diff --git a/tools/tsp-client/README.md b/tools/tsp-client/README.md index a589c32e517..3caa423c1e8 100644 --- a/tools/tsp-client/README.md +++ b/tools/tsp-client/README.md @@ -124,6 +124,7 @@ The file has the following properties: | additionalDirectories | Sometimes a typespec file will use a relative import that might not be under the main directory. In this case a single `directory` will not be enough to pull down all necessary files. To support this you can specify additional directories as a list to sync so that all needed files are synced. | false: default = null | | commit | The commit sha for the version of the typespec files you want to generate off of. This allows us to have idempotence on generation until we opt into pointing at a later version. | true | | repo | The repo this spec lives in. This should be either `Azure/azure-rest-api-specs` or `Azure/azure-rest-api-specs-pr`. Note that pr will work locally but not in CI until we add another change to handle token based auth. | true | +| entrypointFile | A specific entrypoint file used to compile the TypeSpec project. NOTE: This option should only be used with a non-standard entrypoint file name. DO NOT use this option with standard entrypoints: `client.tsp` or `main.tsp`. | false | Example: diff --git a/tools/tsp-client/package-lock.json b/tools/tsp-client/package-lock.json index 96023d365a0..89fb3db6fc4 100644 --- a/tools/tsp-client/package-lock.json +++ b/tools/tsp-client/package-lock.json @@ -1,12 +1,12 @@ { "name": "@azure-tools/typespec-client-generator-cli", - "version": "0.14.3", + "version": "0.15.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@azure-tools/typespec-client-generator-cli", - "version": "0.14.3", + "version": "0.15.0", "license": "MIT", "dependencies": { "@autorest/core": "^3.10.2", diff --git a/tools/tsp-client/package.json b/tools/tsp-client/package.json index 2b7dfc95f90..83f09451297 100644 --- a/tools/tsp-client/package.json +++ b/tools/tsp-client/package.json @@ -1,6 +1,6 @@ { "name": "@azure-tools/typespec-client-generator-cli", - "version": "0.14.3", + "version": "0.15.0", "description": "A tool to generate Azure SDKs from TypeSpec", "main": "dist/index.js", "homepage": "https://github.com/Azure/azure-sdk-tools/tree/main/tools/tsp-client#readme", diff --git a/tools/tsp-client/src/commands.ts b/tools/tsp-client/src/commands.ts index 0dc3a35a826..4009db64caa 100644 --- a/tools/tsp-client/src/commands.ts +++ b/tools/tsp-client/src/commands.ts @@ -9,7 +9,12 @@ import { } from "./fs.js"; import { cp, mkdir, readFile, stat, unlink, writeFile } from "fs/promises"; import { npmCommand, nodeCommand } from "./npm.js"; -import { compileTsp, discoverMainFile, resolveTspConfigUrl, TspLocation } from "./typespec.js"; +import { + compileTsp, + discoverEntrypointFile, + resolveTspConfigUrl, + TspLocation, +} from "./typespec.js"; import { writeTspLocationYaml, getAdditionalDirectoryName, @@ -257,7 +262,7 @@ export async function generateCommand(argv: any) { if (!emitter) { throw new Error("emitter is undefined"); } - const mainFilePath = await discoverMainFile(srcDir); + const mainFilePath = await discoverEntrypointFile(srcDir, tspLocation.entrypointFile); const resolvedMainFilePath = joinPaths(srcDir, mainFilePath); Logger.info("Installing dependencies from npm..."); const args: string[] = []; diff --git a/tools/tsp-client/src/typespec.ts b/tools/tsp-client/src/typespec.ts index c20ce39a1a6..7df514d9ac5 100644 --- a/tools/tsp-client/src/typespec.ts +++ b/tools/tsp-client/src/typespec.ts @@ -13,6 +13,7 @@ export interface TspLocation { commit: string; repo: string; additionalDirectories?: string[]; + entrypointFile?: string; } export function resolveTspConfigUrl(configUrl: string): { @@ -42,18 +43,32 @@ export function resolveTspConfigUrl(configUrl: string): { } } -export async function discoverMainFile(srcDir: string): Promise { +export async function discoverEntrypointFile( + srcDir: string, + specifiedEntrypointFile?: string, +): Promise { Logger.debug(`Discovering entry file in ${srcDir}`); - let entryTsp = ""; + let entryTsp: string | undefined = undefined; const files = await readdir(srcDir, { recursive: true }); - for (const file of files) { - if (file.includes("client.tsp") || file.includes("main.tsp")) { - entryTsp = file; - Logger.debug(`Found entry file: ${entryTsp}`); - return entryTsp; + + function findEntrypoint(name: string): string | undefined { + return files.find((file) => file.endsWith(name)) ?? undefined; + } + if (specifiedEntrypointFile) { + entryTsp = findEntrypoint(specifiedEntrypointFile); + if (!entryTsp) { + throw new Error( + `Couldn't find the entrypoint file specified in tsp-location.yaml: "${specifiedEntrypointFile}". Please verify that the entrypoint file name is correct.`, + ); + } + } else { + entryTsp = findEntrypoint("client.tsp") ?? findEntrypoint("main.tsp"); + if (!entryTsp) { + throw new Error(`No main.tsp or client.tsp found`); } } - throw new Error(`No main.tsp or client.tsp found`); + Logger.debug(`Found entry file: ${entryTsp}`); + return entryTsp; } export async function compileTsp({ diff --git a/tools/tsp-client/test/commands.spec.ts b/tools/tsp-client/test/commands.spec.ts index d921bceee47..f29afb14369 100644 --- a/tools/tsp-client/test/commands.spec.ts +++ b/tools/tsp-client/test/commands.spec.ts @@ -96,6 +96,21 @@ describe.sequential("Verify commands", () => { assert.isTrue(dir.isFile()); }); + it("Generate with alternate entrypoint", async () => { + try { + const args = { + "output-dir": joinPaths(cwd(), "./test/examples/sdk/alternate-entrypoint"), + "local-spec-repo": + "./test/examples/specification/contosowidgetmanager/Contoso.WidgetManager", + }; + await updateCommand(args); + } catch (error) { + assert.fail(`Failed to generate. Error: ${error}`); + } + const tspLocation = await readTspLocation("./test/examples/sdk/alternate-entrypoint"); + assert.equal(tspLocation.entrypointFile, "foo.tsp"); + }); + it("Update example sdk", async () => { try { const args = { diff --git a/tools/tsp-client/test/examples/sdk/alternate-entrypoint/.eslintrc.json b/tools/tsp-client/test/examples/sdk/alternate-entrypoint/.eslintrc.json new file mode 100644 index 00000000000..8793fba07e9 --- /dev/null +++ b/tools/tsp-client/test/examples/sdk/alternate-entrypoint/.eslintrc.json @@ -0,0 +1,14 @@ +{ + "plugins": ["@azure/azure-sdk"], + "extends": ["plugin:@azure/azure-sdk/azure-sdk-base"], + "rules": { + "@azure/azure-sdk/ts-modules-only-named": "warn", + "@azure/azure-sdk/ts-apiextractor-json-types": "warn", + "@azure/azure-sdk/ts-package-json-types": "warn", + "@azure/azure-sdk/ts-package-json-engine-is-present": "warn", + "tsdoc/syntax": "warn", + "@azure/azure-sdk/ts-package-json-module": "off", + "@azure/azure-sdk/ts-package-json-files-required": "off", + "@azure/azure-sdk/ts-package-json-main-is-cjs": "off" + } +} diff --git a/tools/tsp-client/test/examples/sdk/alternate-entrypoint/README.md b/tools/tsp-client/test/examples/sdk/alternate-entrypoint/README.md new file mode 100644 index 00000000000..b92cb462fb0 --- /dev/null +++ b/tools/tsp-client/test/examples/sdk/alternate-entrypoint/README.md @@ -0,0 +1,57 @@ +# Azure WidgetManager REST client library for JavaScript + + + +**Please rely heavily on our [REST client docs](https://github.com/Azure/azure-sdk-for-js/blob/main/documentation/rest-clients.md) to use this library** + +Key links: + +- [Package (NPM)](https://www.npmjs.com/package/@azure-rest/contoso-widgetmanager-rest) +- [API reference documentation](https://docs.microsoft.com/javascript/api/@azure-rest/contoso-widgetmanager-rest?view=azure-node-preview) + +## Getting started + +### Currently supported environments + +- LTS versions of Node.js + +### Prerequisites + +- You must have an [Azure subscription](https://azure.microsoft.com/free/) to use this package. + +### Install the `@azure-rest/contoso-widgetmanager-rest` package + +Install the Azure WidgetManager REST client REST client library for JavaScript with `npm`: + +```bash +npm install @azure-rest/contoso-widgetmanager-rest +``` + +### Create and authenticate a `WidgetManagerClient` + +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) + +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. + +Set the values of the client ID, tenant ID, and client secret of the AAD application as environment variables: +AZURE_CLIENT_ID, AZURE_TENANT_ID, AZURE_CLIENT_SECRET + +## 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`: + +```javascript +const { setLogLevel } = require("@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). diff --git a/tools/tsp-client/test/examples/sdk/alternate-entrypoint/api-extractor.json b/tools/tsp-client/test/examples/sdk/alternate-entrypoint/api-extractor.json new file mode 100644 index 00000000000..aa42d0a349d --- /dev/null +++ b/tools/tsp-client/test/examples/sdk/alternate-entrypoint/api-extractor.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", + "mainEntryPointFilePath": "./dist/esm/index.d.ts", + "docModel": { "enabled": true }, + "apiReport": { "enabled": true, "reportFolder": "./review" }, + "dtsRollup": { + "enabled": true, + "untrimmedFilePath": "", + "publicTrimmedFilePath": "./types/contoso-widgetmanager-rest.d.ts" + }, + "messages": { + "tsdocMessageReporting": { "default": { "logLevel": "none" } }, + "extractorMessageReporting": { + "ae-missing-release-tag": { "logLevel": "none" }, + "ae-unresolved-link": { "logLevel": "none" } + } + } +} diff --git a/tools/tsp-client/test/examples/sdk/alternate-entrypoint/karma.conf.js b/tools/tsp-client/test/examples/sdk/alternate-entrypoint/karma.conf.js new file mode 100644 index 00000000000..a9d5f1b5fc5 --- /dev/null +++ b/tools/tsp-client/test/examples/sdk/alternate-entrypoint/karma.conf.js @@ -0,0 +1,133 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +// 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", + }, + }, + }); +}; diff --git a/tools/tsp-client/test/examples/sdk/alternate-entrypoint/package.json b/tools/tsp-client/test/examples/sdk/alternate-entrypoint/package.json new file mode 100644 index 00000000000..7a2235c0da3 --- /dev/null +++ b/tools/tsp-client/test/examples/sdk/alternate-entrypoint/package.json @@ -0,0 +1,94 @@ +{ + "name": "@azure-rest/contoso-widgetmanager-rest", + "version": "1.0.0-beta.1", + "description": "A generated SDK for WidgetManagerClient.", + "engines": { + "node": ">=18.0.0" + }, + "sideEffects": false, + "autoPublish": false, + "tshy": { + "exports": { + "./package.json": "./package.json", + ".": "./src/index.ts" + }, + "dialects": ["esm", "commonjs"], + "esmDialects": ["browser", "react-native"], + "selfLink": false + }, + "type": "module", + "keywords": ["node", "azure", "cloud", "typescript", "browser", "isomorphic"], + "author": "Microsoft Corporation", + "license": "MIT", + "files": ["dist", "README.md", "LICENSE", "review/*"], + "sdk-type": "client", + "repository": "github:Azure/azure-sdk-for-js", + "bugs": { + "url": "https://github.com/Azure/azure-sdk-for-js/issues" + }, + "prettier": "@azure/eslint-plugin-azure-sdk/prettier.json", + "//metadata": { + "constantPaths": [ + { + "path": "src/widgetManagerClient.ts", + "prefix": "package-version" + } + ] + }, + "dependencies": { + "@azure-rest/core-client": "^2.1.0", + "@azure/core-auth": "^1.6.0", + "@azure/core-rest-pipeline": "^1.5.0", + "@azure/logger": "^1.0.0", + "tslib": "^2.6.2", + "@azure/core-lro": "3.0.0", + "@azure/abort-controller": "^2.1.2", + "@azure/core-paging": "^1.5.0" + }, + "devDependencies": { + "dotenv": "^16.0.0", + "@microsoft/api-extractor": "^7.40.3", + "@types/node": "^18.0.0", + "eslint": "^8.55.0", + "prettier": "^3.2.5", + "rimraf": "^5.0.5", + "mkdirp": "^3.0.1", + "typescript": "~5.4.5", + "tshy": "^1.11.1", + "@azure/core-util": "^1.0.0", + "@azure/identity": "^4.2.1", + "@vitest/browser": "^1.3.1", + "@vitest/coverage-istanbul": "^1.3.1", + "playwright": "^1.41.2", + "vitest": "^1.3.1", + "@azure-tools/test-credential": "^2.0.0", + "@azure-tools/test-recorder": "^4.0.0", + "@azure/dev-tool": "^1.0.0", + "@azure/eslint-plugin-azure-sdk": "^3.0.0" + }, + "scripts": { + "clean": "rimraf --glob dist dist-browser dist-esm test-dist temp types *.tgz *.log", + "extract-api": "rimraf review && mkdirp ./review && dev-tool run extract-api", + "pack": "npm pack 2>&1", + "lint": "eslint package.json api-extractor.json src test --ext .ts --ext .cts --ext .mts", + "lint:fix": "eslint package.json api-extractor.json src test --ext .ts --ext .cts --ext .mts --fix --fix-type [problem,suggestion]", + "unit-test": "npm run unit-test:node && npm run unit-test:browser", + "unit-test:browser": "npm run build:test && dev-tool run test:vitest --browser", + "unit-test:node": "dev-tool run test:vitest", + "integration-test": "npm run integration-test:node && npm run integration-test:browser", + "integration-test:browser": "echo skipped", + "integration-test:node": "echo skipped", + "audit": "node ../../../common/scripts/rush-audit.js && rimraf node_modules package-lock.json && npm i --package-lock-only 2>&1 && npm audit", + "build:samples": "dev-tool samples publish --force", + "check-format": "dev-tool run vendored prettier --list-different --config ../../../.prettierrc.json --ignore-path ../../../.prettierignore \"src/**/*.{ts,cts,mts}\" \"test/**/*.{ts,cts,mts}\" \"*.{js,cjs,mjs,json}\"", + "execute:samples": "dev-tool samples run samples-dev", + "format": "dev-tool run vendored prettier --write --config ../../../.prettierrc.json --ignore-path ../../../.prettierignore \"src/**/*.{ts,cts,mts}\" \"test/**/*.{ts,cts,mts}\" \"*.{js,cjs,mjs,json}\"", + "generate:client": "echo skipped", + "test:browser": "npm run clean && npm run build:test && npm run unit-test:browser && npm run integration-test:browser", + "minify": "uglifyjs -c -m --comments --source-map \"content='./dist/index.js.map'\" -o ./dist/index.min.js ./dist/index.js", + "build:test": "npm run clean && tshy && dev-tool run build-test", + "build": "npm run clean && tshy && mkdirp ./review && dev-tool run extract-api", + "test:node": "npm run clean && tshy && npm run unit-test:node && npm run integration-test:node", + "test": "npm run clean && tshy && npm run unit-test:node && dev-tool run bundle && npm run unit-test:browser && npm run integration-test" + } +} diff --git a/tools/tsp-client/test/examples/sdk/alternate-entrypoint/src/clientDefinitions.ts b/tools/tsp-client/test/examples/sdk/alternate-entrypoint/src/clientDefinitions.ts new file mode 100644 index 00000000000..af0e3f73f4f --- /dev/null +++ b/tools/tsp-client/test/examples/sdk/alternate-entrypoint/src/clientDefinitions.ts @@ -0,0 +1,77 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { + GetWidgetParameters, + CreateOrUpdateWidgetParameters, + DeleteWidgetParameters, + GetWidgetOperationStatusParameters, + ListWidgetsParameters, +} from "./parameters.js"; +import { + GetWidget200Response, + GetWidgetDefaultResponse, + CreateOrUpdateWidget200Response, + CreateOrUpdateWidget201Response, + CreateOrUpdateWidgetDefaultResponse, + DeleteWidget202Response, + DeleteWidgetDefaultResponse, + GetWidgetOperationStatus200Response, + GetWidgetOperationStatusDefaultResponse, + ListWidgets200Response, + ListWidgetsDefaultResponse, +} from "./responses.js"; +import { Client, StreamableMethod } from "@azure-rest/core-client"; + +export interface GetWidget { + /** Fetch a Widget by name. */ + get( + options?: GetWidgetParameters, + ): StreamableMethod; + /** Creates or updates a Widget asynchronously. */ + patch( + options: CreateOrUpdateWidgetParameters, + ): StreamableMethod< + | CreateOrUpdateWidget200Response + | CreateOrUpdateWidget201Response + | CreateOrUpdateWidgetDefaultResponse + >; + /** Delete a Widget asynchronously. */ + delete( + options?: DeleteWidgetParameters, + ): StreamableMethod; +} + +export interface GetWidgetOperationStatus { + /** Gets status of a Widget operation. */ + get( + options?: GetWidgetOperationStatusParameters, + ): StreamableMethod< + | GetWidgetOperationStatus200Response + | GetWidgetOperationStatusDefaultResponse + >; +} + +export interface ListWidgets { + /** List Widget resources */ + get( + options?: ListWidgetsParameters, + ): StreamableMethod; +} + +export interface Routes { + /** Resource for '/widgets/\{widgetName\}' has methods for the following verbs: get, patch, delete */ + (path: "/widgets/{widgetName}", widgetName: string): GetWidget; + /** Resource for '/widgets/\{widgetName\}/operations/\{operationId\}' has methods for the following verbs: get */ + ( + path: "/widgets/{widgetName}/operations/{operationId}", + widgetName: string, + operationId: string, + ): GetWidgetOperationStatus; + /** Resource for '/widgets' has methods for the following verbs: get */ + (path: "/widgets"): ListWidgets; +} + +export type WidgetManagerClient = Client & { + path: Routes; +}; diff --git a/tools/tsp-client/test/examples/sdk/alternate-entrypoint/src/index.ts b/tools/tsp-client/test/examples/sdk/alternate-entrypoint/src/index.ts new file mode 100644 index 00000000000..c32bbe5e537 --- /dev/null +++ b/tools/tsp-client/test/examples/sdk/alternate-entrypoint/src/index.ts @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import WidgetManagerClient from "./widgetManagerClient.js"; + +export * from "./widgetManagerClient.js"; +export * from "./parameters.js"; +export * from "./responses.js"; +export * from "./clientDefinitions.js"; +export * from "./isUnexpected.js"; +export * from "./models.js"; +export * from "./outputModels.js"; +export * from "./paginateHelper.js"; +export * from "./pollingHelper.js"; + +export default WidgetManagerClient; diff --git a/tools/tsp-client/test/examples/sdk/alternate-entrypoint/src/isUnexpected.ts b/tools/tsp-client/test/examples/sdk/alternate-entrypoint/src/isUnexpected.ts new file mode 100644 index 00000000000..31e42275fa0 --- /dev/null +++ b/tools/tsp-client/test/examples/sdk/alternate-entrypoint/src/isUnexpected.ts @@ -0,0 +1,154 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { + GetWidget200Response, + GetWidgetDefaultResponse, + CreateOrUpdateWidget200Response, + CreateOrUpdateWidget201Response, + CreateOrUpdateWidgetLogicalResponse, + CreateOrUpdateWidgetDefaultResponse, + DeleteWidget202Response, + DeleteWidgetLogicalResponse, + DeleteWidgetDefaultResponse, + GetWidgetOperationStatus200Response, + GetWidgetOperationStatusDefaultResponse, + ListWidgets200Response, + ListWidgetsDefaultResponse, +} from "./responses.js"; + +const responseMap: Record = { + "GET /widgets/{widgetName}": ["200"], + "PATCH /widgets/{widgetName}": ["200", "201"], + "DELETE /widgets/{widgetName}": ["202"], + "GET /widgets/{widgetName}/operations/{operationId}": ["200"], + "GET /widgets": ["200"], +}; + +export function isUnexpected( + response: GetWidget200Response | GetWidgetDefaultResponse, +): response is GetWidgetDefaultResponse; +export function isUnexpected( + response: + | CreateOrUpdateWidget200Response + | CreateOrUpdateWidget201Response + | CreateOrUpdateWidgetLogicalResponse + | CreateOrUpdateWidgetDefaultResponse, +): response is CreateOrUpdateWidgetDefaultResponse; +export function isUnexpected( + response: + | DeleteWidget202Response + | DeleteWidgetLogicalResponse + | DeleteWidgetDefaultResponse, +): response is DeleteWidgetDefaultResponse; +export function isUnexpected( + response: + | GetWidgetOperationStatus200Response + | GetWidgetOperationStatusDefaultResponse, +): response is GetWidgetOperationStatusDefaultResponse; +export function isUnexpected( + response: ListWidgets200Response | ListWidgetsDefaultResponse, +): response is ListWidgetsDefaultResponse; +export function isUnexpected( + response: + | GetWidget200Response + | GetWidgetDefaultResponse + | CreateOrUpdateWidget200Response + | CreateOrUpdateWidget201Response + | CreateOrUpdateWidgetLogicalResponse + | CreateOrUpdateWidgetDefaultResponse + | DeleteWidget202Response + | DeleteWidgetLogicalResponse + | DeleteWidgetDefaultResponse + | GetWidgetOperationStatus200Response + | GetWidgetOperationStatusDefaultResponse + | ListWidgets200Response + | ListWidgetsDefaultResponse, +): response is + | GetWidgetDefaultResponse + | CreateOrUpdateWidgetDefaultResponse + | DeleteWidgetDefaultResponse + | GetWidgetOperationStatusDefaultResponse + | ListWidgetsDefaultResponse { + 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); +} + +function getParametrizedPathSuccess(method: string, path: string): string[] { + 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; +} + +function getPathFromMapKey(mapKey: string): string { + const pathStart = mapKey.indexOf("/"); + return mapKey.slice(pathStart); +} diff --git a/tools/tsp-client/test/examples/sdk/alternate-entrypoint/src/logger.ts b/tools/tsp-client/test/examples/sdk/alternate-entrypoint/src/logger.ts new file mode 100644 index 00000000000..454be08a1b8 --- /dev/null +++ b/tools/tsp-client/test/examples/sdk/alternate-entrypoint/src/logger.ts @@ -0,0 +1,5 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { createClientLogger } from "@azure/logger"; +export const logger = createClientLogger("contoso-widgetmanager-rest"); diff --git a/tools/tsp-client/test/examples/sdk/alternate-entrypoint/src/models.ts b/tools/tsp-client/test/examples/sdk/alternate-entrypoint/src/models.ts new file mode 100644 index 00000000000..f99d5eafc7b --- /dev/null +++ b/tools/tsp-client/test/examples/sdk/alternate-entrypoint/src/models.ts @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** A widget. */ +export interface WidgetSuite { + /** The ID of the widget's manufacturer. */ + manufacturerId: string; + /** The faked shared model. */ + sharedModel?: FakedSharedModel; +} + +/** Faked shared model */ +export interface FakedSharedModel { + /** The tag. */ + tag: string; + /** The created date. */ + createdAt: Date | string; +} diff --git a/tools/tsp-client/test/examples/sdk/alternate-entrypoint/src/outputModels.ts b/tools/tsp-client/test/examples/sdk/alternate-entrypoint/src/outputModels.ts new file mode 100644 index 00000000000..2cc5e0c3784 --- /dev/null +++ b/tools/tsp-client/test/examples/sdk/alternate-entrypoint/src/outputModels.ts @@ -0,0 +1,50 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { Paged } from "@azure/core-paging"; +import { ErrorModel } from "@azure-rest/core-client"; + +/** A widget. */ +export interface WidgetSuiteOutput { + /** The widget name. */ + readonly name: string; + /** The ID of the widget's manufacturer. */ + manufacturerId: string; + /** The faked shared model. */ + sharedModel?: FakedSharedModelOutput; +} + +/** Faked shared model */ +export interface FakedSharedModelOutput { + /** The tag. */ + tag: string; + /** The created date. */ + createdAt: string; +} + +/** Provides status details for long running operations. */ +export interface ResourceOperationStatusOutput { + /** The unique ID of the operation. */ + id: string; + /** The status of the operation */ + status: OperationStateOutput; + /** Error object that describes the error when status is "Failed". */ + error?: ErrorModel; + /** The result of the operation. */ + result?: WidgetSuiteOutput; +} + +/** Provides status details for long running operations. */ +export interface OperationStatusOutput { + /** The unique ID of the operation. */ + id: string; + /** The status of the operation */ + status: OperationStateOutput; + /** Error object that describes the error when status is "Failed". */ + error?: ErrorModel; +} + +/** Alias for OperationStateOutput */ +export type OperationStateOutput = string; +/** Paged collection of WidgetSuite items */ +export type PagedWidgetSuiteOutput = Paged; diff --git a/tools/tsp-client/test/examples/sdk/alternate-entrypoint/src/paginateHelper.ts b/tools/tsp-client/test/examples/sdk/alternate-entrypoint/src/paginateHelper.ts new file mode 100644 index 00000000000..3aac141db0e --- /dev/null +++ b/tools/tsp-client/test/examples/sdk/alternate-entrypoint/src/paginateHelper.ts @@ -0,0 +1,154 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { + getPagedAsyncIterator, + PagedAsyncIterableIterator, + PagedResult, +} from "@azure/core-paging"; +import { + Client, + createRestError, + PathUncheckedResponse, +} from "@azure-rest/core-client"; + +/** + * 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, + maxPageSize?: number, +) => 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 { + body: { value?: infer TPage }; +} + ? 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; + const itemName = "value"; + const nextLinkName = "nextLink"; + 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, + ); + } +} diff --git a/tools/tsp-client/test/examples/sdk/alternate-entrypoint/src/parameters.ts b/tools/tsp-client/test/examples/sdk/alternate-entrypoint/src/parameters.ts new file mode 100644 index 00000000000..091b2502f5d --- /dev/null +++ b/tools/tsp-client/test/examples/sdk/alternate-entrypoint/src/parameters.ts @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { RequestParameters } from "@azure-rest/core-client"; +import { WidgetSuite } from "./models.js"; + +export type GetWidgetParameters = RequestParameters; +export type GetWidgetOperationStatusParameters = RequestParameters; +/** The resource instance. */ +export type WidgetSuiteResourceMergeAndPatch = Partial; + +export interface CreateOrUpdateWidgetBodyParam { + /** The resource instance. */ + body: WidgetSuiteResourceMergeAndPatch; +} + +export interface CreateOrUpdateWidgetMediaTypesParam { + /** This request has a JSON Merge Patch body. */ + contentType: "application/merge-patch+json"; +} + +export type CreateOrUpdateWidgetParameters = + CreateOrUpdateWidgetMediaTypesParam & + CreateOrUpdateWidgetBodyParam & + RequestParameters; +export type DeleteWidgetParameters = RequestParameters; +export type ListWidgetsParameters = RequestParameters; diff --git a/tools/tsp-client/test/examples/sdk/alternate-entrypoint/src/pollingHelper.ts b/tools/tsp-client/test/examples/sdk/alternate-entrypoint/src/pollingHelper.ts new file mode 100644 index 00000000000..76542ba5d65 --- /dev/null +++ b/tools/tsp-client/test/examples/sdk/alternate-entrypoint/src/pollingHelper.ts @@ -0,0 +1,233 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { Client, HttpResponse } from "@azure-rest/core-client"; +import { AbortSignalLike } from "@azure/abort-controller"; +import { + CancelOnProgress, + CreateHttpPollerOptions, + RunningOperation, + OperationResponse, + OperationState, + createHttpPoller, +} from "@azure/core-lro"; +import { + CreateOrUpdateWidget200Response, + CreateOrUpdateWidget201Response, + CreateOrUpdateWidgetDefaultResponse, + CreateOrUpdateWidgetLogicalResponse, + DeleteWidget202Response, + DeleteWidgetDefaultResponse, + DeleteWidgetLogicalResponse, +} from "./responses.js"; + +/** + * 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. + */ +export async function getLongRunningPoller< + TResult extends + | CreateOrUpdateWidgetLogicalResponse + | CreateOrUpdateWidgetDefaultResponse, +>( + client: Client, + initialResponse: + | CreateOrUpdateWidget200Response + | CreateOrUpdateWidget201Response + | CreateOrUpdateWidgetDefaultResponse, + options?: CreateHttpPollerOptions>, +): Promise, TResult>>; +export async function getLongRunningPoller< + TResult extends DeleteWidgetLogicalResponse | DeleteWidgetDefaultResponse, +>( + client: Client, + initialResponse: DeleteWidget202Response | DeleteWidgetDefaultResponse, + options?: CreateHttpPollerOptions>, +): Promise, TResult>>; +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/tools/tsp-client/test/examples/sdk/alternate-entrypoint/src/responses.ts b/tools/tsp-client/test/examples/sdk/alternate-entrypoint/src/responses.ts new file mode 100644 index 00000000000..618feaa0504 --- /dev/null +++ b/tools/tsp-client/test/examples/sdk/alternate-entrypoint/src/responses.ts @@ -0,0 +1,132 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { RawHttpHeaders } from "@azure/core-rest-pipeline"; +import { HttpResponse, ErrorResponse } from "@azure-rest/core-client"; +import { + WidgetSuiteOutput, + ResourceOperationStatusOutput, + OperationStatusOutput, + PagedWidgetSuiteOutput, +} from "./outputModels.js"; + +/** The request has succeeded. */ +export interface GetWidget200Response extends HttpResponse { + status: "200"; + body: WidgetSuiteOutput; +} + +export interface GetWidgetDefaultHeaders { + /** String error code indicating what went wrong. */ + "x-ms-error-code"?: string; +} + +export interface GetWidgetDefaultResponse extends HttpResponse { + status: string; + body: ErrorResponse; + headers: RawHttpHeaders & GetWidgetDefaultHeaders; +} + +/** The request has succeeded. */ +export interface GetWidgetOperationStatus200Response extends HttpResponse { + status: "200"; + body: ResourceOperationStatusOutput; +} + +export interface GetWidgetOperationStatusDefaultHeaders { + /** String error code indicating what went wrong. */ + "x-ms-error-code"?: string; +} + +export interface GetWidgetOperationStatusDefaultResponse extends HttpResponse { + status: string; + body: ErrorResponse; + headers: RawHttpHeaders & GetWidgetOperationStatusDefaultHeaders; +} + +export interface CreateOrUpdateWidget200Headers { + /** The location for monitoring the operation state. */ + "operation-location": string; +} + +/** The request has succeeded. */ +export interface CreateOrUpdateWidget200Response extends HttpResponse { + status: "200"; + body: WidgetSuiteOutput; + headers: RawHttpHeaders & CreateOrUpdateWidget200Headers; +} + +export interface CreateOrUpdateWidget201Headers { + /** The location for monitoring the operation state. */ + "operation-location": string; +} + +/** The request has succeeded and a new resource has been created as a result. */ +export interface CreateOrUpdateWidget201Response extends HttpResponse { + status: "201"; + body: WidgetSuiteOutput; + headers: RawHttpHeaders & CreateOrUpdateWidget201Headers; +} + +export interface CreateOrUpdateWidgetDefaultHeaders { + /** String error code indicating what went wrong. */ + "x-ms-error-code"?: string; +} + +export interface CreateOrUpdateWidgetDefaultResponse extends HttpResponse { + status: string; + body: ErrorResponse; + headers: RawHttpHeaders & CreateOrUpdateWidgetDefaultHeaders; +} + +/** The final response for long-running createOrUpdateWidget operation */ +export interface CreateOrUpdateWidgetLogicalResponse extends HttpResponse { + status: "200"; + body: WidgetSuiteOutput; +} + +export interface DeleteWidget202Headers { + /** The location for monitoring the operation state. */ + "operation-location": string; +} + +/** The request has been accepted for processing, but processing has not yet completed. */ +export interface DeleteWidget202Response extends HttpResponse { + status: "202"; + body: OperationStatusOutput; + headers: RawHttpHeaders & DeleteWidget202Headers; +} + +export interface DeleteWidgetDefaultHeaders { + /** String error code indicating what went wrong. */ + "x-ms-error-code"?: string; +} + +export interface DeleteWidgetDefaultResponse extends HttpResponse { + status: string; + body: ErrorResponse; + headers: RawHttpHeaders & DeleteWidgetDefaultHeaders; +} + +/** The final response for long-running deleteWidget operation */ +export interface DeleteWidgetLogicalResponse extends HttpResponse { + status: "200"; + body: OperationStatusOutput; +} + +/** The request has succeeded. */ +export interface ListWidgets200Response extends HttpResponse { + status: "200"; + body: PagedWidgetSuiteOutput; +} + +export interface ListWidgetsDefaultHeaders { + /** String error code indicating what went wrong. */ + "x-ms-error-code"?: string; +} + +export interface ListWidgetsDefaultResponse extends HttpResponse { + status: string; + body: ErrorResponse; + headers: RawHttpHeaders & ListWidgetsDefaultHeaders; +} diff --git a/tools/tsp-client/test/examples/sdk/alternate-entrypoint/src/widgetManagerClient.ts b/tools/tsp-client/test/examples/sdk/alternate-entrypoint/src/widgetManagerClient.ts new file mode 100644 index 00000000000..f03d9aeefd0 --- /dev/null +++ b/tools/tsp-client/test/examples/sdk/alternate-entrypoint/src/widgetManagerClient.ts @@ -0,0 +1,73 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { getClient, ClientOptions } from "@azure-rest/core-client"; +import { logger } from "./logger.js"; +import { TokenCredential } from "@azure/core-auth"; +import { WidgetManagerClient } from "./clientDefinitions.js"; + +/** The optional parameters for the client */ +export interface WidgetManagerClientOptions extends ClientOptions { + /** The api version option of the client */ + apiVersion?: string; +} + +/** + * Initialize a new instance of `WidgetManagerClient` + * @param endpointParam - The parameter endpointParam + * @param credentials - uniquely identify client credential + * @param options - the parameter for all optional parameters + */ +export default function createClient( + endpointParam: string, + credentials: TokenCredential, + { + apiVersion = "2022-11-01-preview", + ...options + }: WidgetManagerClientOptions = {}, +): WidgetManagerClient { + const endpointUrl = options.endpoint ?? options.baseUrl ?? `${endpointParam}`; + const userAgentInfo = `azsdk-js-contoso-widgetmanager-rest/1.0.0-beta.1`; + const userAgentPrefix = + options.userAgentOptions && options.userAgentOptions.userAgentPrefix + ? `${options.userAgentOptions.userAgentPrefix} ${userAgentInfo}` + : `${userAgentInfo}`; + options = { + ...options, + userAgentOptions: { + userAgentPrefix, + }, + loggingOptions: { + logger: options.loggingOptions?.logger ?? logger.info, + }, + credentials: { + scopes: options.credentials?.scopes ?? [ + "https://contoso.azure.com/.default", + ], + }, + }; + const client = getClient( + endpointUrl, + credentials, + options, + ) as WidgetManagerClient; + + client.pipeline.removePolicy({ name: "ApiVersionPolicy" }); + 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); + }, + }); + + return client; +} diff --git a/tools/tsp-client/test/examples/sdk/alternate-entrypoint/test/public/sampleTest.spec.ts b/tools/tsp-client/test/examples/sdk/alternate-entrypoint/test/public/sampleTest.spec.ts new file mode 100644 index 00000000000..344657d6f17 --- /dev/null +++ b/tools/tsp-client/test/examples/sdk/alternate-entrypoint/test/public/sampleTest.spec.ts @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { createRecorder } from "./utils/recordedClient.js"; +import { assert, beforeEach, afterEach, it, describe } from "vitest"; + +describe("My test", () => { + // let recorder: Recorder; + + beforeEach(async function () { + // recorder = await createRecorder(this); + }); + + afterEach(async function () { + // await recorder.stop(); + }); + + it("sample test", async function () { + assert.equal(1, 1); + }); +}); diff --git a/tools/tsp-client/test/examples/sdk/alternate-entrypoint/test/public/utils/recordedClient.ts b/tools/tsp-client/test/examples/sdk/alternate-entrypoint/test/public/utils/recordedClient.ts new file mode 100644 index 00000000000..231e0b7c22f --- /dev/null +++ b/tools/tsp-client/test/examples/sdk/alternate-entrypoint/test/public/utils/recordedClient.ts @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { Context } from "mocha"; +import { Recorder, RecorderStartOptions } from "@azure-tools/test-recorder"; + +const envSetupForPlayback: Record = { + ENDPOINT: "https://endpoint", + AZURE_CLIENT_ID: "azure_client_id", + AZURE_CLIENT_SECRET: "azure_client_secret", + AZURE_TENANT_ID: "88888888-8888-8888-8888-888888888888", + SUBSCRIPTION_ID: "azure_subscription_id", +}; + +const recorderEnvSetup: RecorderStartOptions = { + envSetupForPlayback, +}; + +/** + * 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. + */ +export async function createRecorder(context: Context): Promise { + const recorder = new Recorder(context.currentTest); + await recorder.start(recorderEnvSetup); + return recorder; +} diff --git a/tools/tsp-client/test/examples/sdk/alternate-entrypoint/tsconfig.browser.config.json b/tools/tsp-client/test/examples/sdk/alternate-entrypoint/tsconfig.browser.config.json new file mode 100644 index 00000000000..1b37aebc545 --- /dev/null +++ b/tools/tsp-client/test/examples/sdk/alternate-entrypoint/tsconfig.browser.config.json @@ -0,0 +1,10 @@ +{ + "extends": "./.tshy/build.json", + "include": ["./src/**/*.ts", "./src/**/*.mts", "./test/**/*.spec.ts"], + "exclude": ["./test/**/node/**/*.ts"], + "compilerOptions": { + "outDir": "./dist-test/browser", + "rootDir": ".", + "skipLibCheck": true + } +} diff --git a/tools/tsp-client/test/examples/sdk/alternate-entrypoint/tsconfig.json b/tools/tsp-client/test/examples/sdk/alternate-entrypoint/tsconfig.json new file mode 100644 index 00000000000..7483ff67760 --- /dev/null +++ b/tools/tsp-client/test/examples/sdk/alternate-entrypoint/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../../tsconfig.package", + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "rootDir": "." + }, + "include": [ + "./src/**/*.ts", + "./src/**/*.mts", + "./src/**/*.cts", + "test/**/*.ts", + "./test/**/*.ts" + ] +} diff --git a/tools/tsp-client/test/examples/sdk/alternate-entrypoint/tsp-location.yaml b/tools/tsp-client/test/examples/sdk/alternate-entrypoint/tsp-location.yaml new file mode 100644 index 00000000000..f98623d725c --- /dev/null +++ b/tools/tsp-client/test/examples/sdk/alternate-entrypoint/tsp-location.yaml @@ -0,0 +1,6 @@ +directory: tools/tsp-client/test/examples/specification/contosowidgetmanager/Contoso.WidgetManager +commit: db63bea839f5648462c94e685d5cc96f8e8b38ba +repo: Azure/azure-sdk-tools +additionalDirectories: + - tools/tsp-client/test/examples/specification/contosowidgetmanager/Contoso.WidgetManager.Shared/ +entrypointFile: foo.tsp diff --git a/tools/tsp-client/test/examples/sdk/alternate-entrypoint/vitest.browser.config.ts b/tools/tsp-client/test/examples/sdk/alternate-entrypoint/vitest.browser.config.ts new file mode 100644 index 00000000000..cc2bbfc362c --- /dev/null +++ b/tools/tsp-client/test/examples/sdk/alternate-entrypoint/vitest.browser.config.ts @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { defineConfig } from "vitest/config"; +import { relativeRecordingsPath } from "@azure-tools/test-recorder"; + +process.env.RECORDINGS_RELATIVE_PATH = relativeRecordingsPath(); + +export default defineConfig({ + define: { + "process.env": process.env, + }, + test: { + reporters: ["basic", "junit"], + outputFile: { + junit: "test-results.browser.xml", + }, + browser: { + enabled: true, + headless: true, + name: "chromium", + provider: "playwright", + }, + fakeTimers: { + toFake: ["setTimeout", "Date"], + }, + watch: false, + include: ["dist-test/browser/**/*.spec.js"], + coverage: { + include: ["dist-test/browser/**/*.spec.js"], + provider: "istanbul", + reporter: ["text", "json", "html"], + reportsDirectory: "coverage-browser", + }, + }, +}); diff --git a/tools/tsp-client/test/examples/sdk/alternate-entrypoint/vitest.config.ts b/tools/tsp-client/test/examples/sdk/alternate-entrypoint/vitest.config.ts new file mode 100644 index 00000000000..367c90fa4e7 --- /dev/null +++ b/tools/tsp-client/test/examples/sdk/alternate-entrypoint/vitest.config.ts @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { defineConfig } from "vitest/config"; +import { relativeRecordingsPath } from "@azure-tools/test-recorder"; + +export default defineConfig({ + test: { + reporters: ["basic", "junit"], + outputFile: { + junit: "test-results.browser.xml", + }, + fakeTimers: { + toFake: ["setTimeout", "Date"], + }, + watch: false, + include: ["test/**/*.spec.ts"], + exclude: ["test/**/browser/*.spec.ts"], + coverage: { + include: ["src/**/*.ts"], + exclude: [ + "src/**/*-browser.mts", + "src/**/*-react-native.mts", + "vitest*.config.ts", + "samples-dev/**/*.ts", + ], + provider: "istanbul", + reporter: ["text", "json", "html"], + reportsDirectory: "coverage", + }, + }, +}); diff --git a/tools/tsp-client/test/examples/sdk/local-spec-sdk/tsp-location.yaml b/tools/tsp-client/test/examples/sdk/local-spec-sdk/tsp-location.yaml index 219fbbbc9b1..f98623d725c 100644 --- a/tools/tsp-client/test/examples/sdk/local-spec-sdk/tsp-location.yaml +++ b/tools/tsp-client/test/examples/sdk/local-spec-sdk/tsp-location.yaml @@ -3,3 +3,4 @@ commit: db63bea839f5648462c94e685d5cc96f8e8b38ba repo: Azure/azure-sdk-tools additionalDirectories: - tools/tsp-client/test/examples/specification/contosowidgetmanager/Contoso.WidgetManager.Shared/ +entrypointFile: foo.tsp diff --git a/tools/tsp-client/test/examples/specification/contosowidgetmanager/Contoso.WidgetManager/foo.tsp b/tools/tsp-client/test/examples/specification/contosowidgetmanager/Contoso.WidgetManager/foo.tsp new file mode 100644 index 00000000000..ad741769102 --- /dev/null +++ b/tools/tsp-client/test/examples/specification/contosowidgetmanager/Contoso.WidgetManager/foo.tsp @@ -0,0 +1 @@ +import "./main.tsp"; diff --git a/tools/tsp-client/test/typespec.spec.ts b/tools/tsp-client/test/typespec.spec.ts index 6a7f5bbe9e1..e1bd0ebdce3 100644 --- a/tools/tsp-client/test/typespec.spec.ts +++ b/tools/tsp-client/test/typespec.spec.ts @@ -1,11 +1,11 @@ import { assert } from "chai"; import { describe, it } from "vitest"; -import { compileTsp, discoverMainFile } from "../src/typespec.js"; +import { compileTsp, discoverEntrypointFile } from "../src/typespec.js"; import { joinPaths, resolvePath } from "@typespec/compiler"; describe("Check diagnostic reporting", function () { it("Check diagnostic format", async function () { - const mainFile = await discoverMainFile( + const mainFile = await discoverEntrypointFile( resolvePath(process.cwd(), "test", "examples", "specification", "diagnostics"), ); try { @@ -24,4 +24,27 @@ describe("Check diagnostic reporting", function () { assert.fail("Unexpected failure."); } }); + + it("Check discoverEntrypointFile()", async function () { + let entrypointFile = await discoverEntrypointFile( + joinPaths(process.cwd(), "test", "examples", "specification", "convert"), + "Catalog.tsp", + ); + assert.equal(entrypointFile, "Catalog.tsp"); + entrypointFile = await discoverEntrypointFile( + joinPaths(process.cwd(), "test", "examples", "specification", "convert"), + ); + assert.equal(entrypointFile, "client.tsp"); + entrypointFile = await discoverEntrypointFile( + joinPaths( + process.cwd(), + "test", + "examples", + "specification", + "contosowidgetmanager", + "Contoso.WidgetManager", + ), + ); + assert.equal(entrypointFile, "main.tsp"); + }); });