diff --git a/sdk/core/core-lro/CHANGELOG.md b/sdk/core/core-lro/CHANGELOG.md index 0b40289a4156..9712e8339742 100644 --- a/sdk/core/core-lro/CHANGELOG.md +++ b/sdk/core/core-lro/CHANGELOG.md @@ -1,9 +1,11 @@ # Release History -## 2.0.1 (Unreleased) +## 2.1.0 (Unreleased) ### Features Added +- Provides a long-running operation engine. + ### Breaking Changes ### Key Bugs Fixed diff --git a/sdk/core/core-lro/docs/LROEngine.md b/sdk/core/core-lro/docs/LROEngine.md new file mode 100755 index 000000000000..15606b8ad940 --- /dev/null +++ b/sdk/core/core-lro/docs/LROEngine.md @@ -0,0 +1,158 @@ +# Modular Support for Long-Running Operations + +Long-running operations (LROs) are operations that the service _could_ take a long time to finish processing and they follow a common convention: + +- the customer first send an initiation request to the service, which in turn sends back a response, from which the customer can learn how to poll for the status of the operation, if it has not been completed already, +- using their learnings, the customer polls the status of the operation until it is done, +- again, using their learnings, the customer can now get the desired result of the operation once its status says it is done. + +Ideally, we can write an algorithm that implements this convention once and use it in all Azure clients for LRO APIs, however, in reality, this convention is implemented differently across Azure services. The good news is that the TypeScript Autorest extension is AFAIK able to generate code that implements those different ones, but this implementation has a couple of limitations: + +1. it is located in a few files that the code generator copies into every generated package that has LROs. So if in the future there is a bug fix needed in the LRO logic, the package has to be regenerated with the fix. +2. it works only with clients that use `@azure/core-client`, so clients that use `@azure-rest/core-client` or `@azure/core-http` can not use this implementation as-is. + +To fix limitation #1, the most straightforward thing to do is to move those files into `@azure/core-lro`, but without fixing limitation #2 first, `@azure/core-lro` will have to depend on `@azure/core-client` in this case which will force clients that depend on `@azure/core-lro` but not necessarily depend on `@azure/core-client` to transitively depend on the latter, posing concerns about unnecessarily larger bundle sizes. + +This document presents a design that fixes limitation #2 and naturally fixes limitation #1 too. + +## Things to know before reading + +- Some details not related to the high-level concept are not illustrated; the scope of this is limited to the high-level shape and paradigms for the feature area. + +## Terminology + +- **Azure Async Operation**, **Body**, and **Location** are names for the LRO implementations currently supported in the TypeScript Autorest extension. They vary in how to calculate the path to poll from, the algorithm for detecting whether the operation has finished, and the location to retrieve the desired results from. Currently, these pieces of information can be calculated from the response received after sending the initiation request. + +## Why this is needed + +The China team is currently waiting for fixing limitation #1 which they regard as a blocker for GAing the TypeScript Autorest extension. Furthermore, having this LRO implementation being part of `@azure/core-lro` and not tied to `@azure/core-client` will significantly help streamline the underway effort to add convenience helpers for LROs in `@azure-rest` clients. + +## Proposed design + +This document presents a design of an LRO engine to be part of `@azure/core-lro` and could be used by any client regardless of how it is implemented. Furthermore, specific implementations of the engine are also provided to be auto-generated by Autorest. + +The design consists of three main pieces: + +- an interface, named `LongRunningOperation` which groups various primitives needed to implement LROs +- a class, named `LroEngine`, that implements the LRO engine and its constructor takes as input an object that implements `LongRunningOperation` +- a class that implement `LongRunningOperation` that works with clients that use either `@azure/core-http` and `@azure/core-client`. @joheredi also created one for `@azure-rest/core-client` in https://github.com/Azure/azure-sdk-for-js/pull/15898 + +### `LongRunningOperation` + +This interface contains two methods: **sendInitialRequest** and **sendPollRequest**. I propose to make this interface exported by `@azure/core-lro`. + +#### `sendInitialRequest` + +This method should be implemented to send the initial request to start the operation and it has the following signature: + +```ts +sendInitialRequest: () => Promise> +``` + +The method does not take the path or the HTTP request method as parameters because they're members of the interface since they're needed to control many aspects of subsequent polling. This is how this method can be implemented: + +```ts +public async sendInitialRequest(): Promise> { + return this.sendOperation(this.args, this.spec); // the class will have sendOperation, args, and spec as private fields +} +``` + +#### `sendPollRequest` + +This method should be implemented to send a polling (GET) request, a request the service should respond to with the current status of the operation, and it has the following signature: + +```ts +sendPollRequest: (path: string) => Promise>; +``` + +This method takes the polling path as input and here is what a simplified implementation would look like: + +```ts + public async sendPollRequest(path: string): Promise> { + return this.sendOperationFn(this.args, { // the class will have sendOperation, args, and spec as private fields + ...this.spec, + path, + httpMethod: "GET") + }); + } +``` + +### `LroEngine` + +This class implements the `PollerLike` interface and does the heavy lifting for LROs. I propose to make this class also exported by `@azure/core-lro`. This class has the following type signature: + +```ts +class LroEngine> extends Poller +``` + +The class also has the following constructor: + +```ts +constructor(lro: LongRunningOperation, options?: LroEngineOptions); +``` + +Currently `options` have `intervalInMs` to control the polling interval, `resumeFrom` to enable resuming from a serialized state, and `lroResourceLocationConfig` which could determine where to find the results of the LRO after the operation is finished. Typically, Autorest figures out the value for `LroResourceLocationConfig` from the `x-ms-long-running-operation-options` swagger extension. If there are new arguments to be added to the class, they could be added to the options type. + +### [`LroImpl`](https://github.com/deyaaeldeen/autorest.typescript/blob/lro-simplify/src/lroImpl.ts) + +This class implements the `LongRunningOperation` interface and I propose to make it auto-generated by Autorest. `LroImpl` needs access to a few pieces: operation specification and operation arguments and a primitive function that can take them as input to send a request and converts the received response into one of type `LroResponse` which has both the flattened and the raw responses. + +## Usage examples + +### Create an object of `LroImpl` + +```ts +const directSendOperation = async ( + args: OperationArguments, + spec: OperationSpec +): Promise => { + return this.client.sendOperationRequest(args, spec); +}; +const sendOperation = async ( + args: OperationArguments, + spec: OperationSpec +) => { + let currentRawResponse: FullOperationResponse | undefined = undefined; + const providedCallback = args.options?.onResponse; + const callback: RawResponseCallback = ( + rawResponse: FullOperationResponse, + flatResponse: unknown + ) => { + currentRawResponse = rawResponse; + providedCallback?.(rawResponse, flatResponse); + }; + const updatedArgs = { + ...args, + options: { + ...args.options, + onResponse: callback + } + }; + const flatResponse = await directSendOperation(updatedArgs, spec); + return { + flatResponse, + rawResponse: { + statusCode: currentRawResponse!.status, + body: currentRawResponse!.parsedBody, + headers: currentRawResponse!.headers.toJSON() + } + }; +}; + +const lro = new LroImpl( + sendOperation, + { options }, // arguments are just the operation options + spec +); +``` + +### Using `LroEngine` + +```ts +const pollerEngine = new LroEngine(lro, { intervalInMs: 2000 }); // lro was instantiated in the previous section +const result = pollerEngine.pollUntilDone(); +``` + +## Testing + +We have [extensive test suite for LROs](https://github.com/Azure/autorest.typescript/blob/main/test/integration/lro.spec.ts) in the TypeScript code generator repo. I both added those tests here and re-implemented the [lro routes](https://github.com/Azure/autorest.testserver/blob/main/legacy/routes/lros.js) in the Autorest test server. For this to work, I created a [fairly low-level instantiation for `LongRunningOperation` with just `@azure/core-rest-pipeline`](https://github.com/deyaaeldeen/azure-sdk-for-js/blob/lro-design/sdk/core/core-lro/test/utils/coreRestPipelineLro.ts). diff --git a/sdk/core/core-lro/package.json b/sdk/core/core-lro/package.json index da57a3f614dc..808f57132c10 100644 --- a/sdk/core/core-lro/package.json +++ b/sdk/core/core-lro/package.json @@ -2,8 +2,8 @@ "name": "@azure/core-lro", "author": "Microsoft Corporation", "sdk-type": "client", - "version": "2.0.1", - "description": "LRO Polling strategy for the Azure SDK in TypeScript", + "version": "2.1.0", + "description": "Isomorphic client library for supporting long-running operations in node.js and browser.", "tags": [ "isomorphic", "browser", @@ -91,19 +91,15 @@ "sideEffects": false, "dependencies": { "@azure/abort-controller": "^1.0.0", - "@azure/core-tracing": "1.0.0-preview.12", - "events": "^3.0.0", + "@azure/logger": "^1.0.0", "tslib": "^2.2.0" }, "devDependencies": { "@azure/core-http": "^2.0.0", + "@azure/core-rest-pipeline": "^1.1.0", "@azure/eslint-plugin-azure-sdk": "^3.0.0", "@azure/dev-tool": "^1.0.0", "@microsoft/api-extractor": "7.7.11", - "@rollup/plugin-commonjs": "11.0.2", - "@rollup/plugin-multi-entry": "^3.0.0", - "@rollup/plugin-node-resolve": "^8.0.0", - "@rollup/plugin-replace": "^2.2.0", "@types/chai": "^4.1.6", "@types/mocha": "^7.0.2", "@types/node": "^12.0.0", @@ -128,10 +124,6 @@ "prettier": "^1.16.4", "rimraf": "^3.0.0", "rollup": "^1.16.3", - "rollup-plugin-shim": "^1.0.0", - "rollup-plugin-sourcemaps": "^0.4.2", - "rollup-plugin-terser": "^5.1.1", - "rollup-plugin-visualizer": "^4.0.4", "ts-node": "^9.0.0", "typescript": "~4.2.0", "uglify-js": "^3.4.9", diff --git a/sdk/core/core-lro/review/core-lro.api.md b/sdk/core/core-lro/review/core-lro.api.md index 6354d4c6b86b..a4ae0532fd9c 100644 --- a/sdk/core/core-lro/review/core-lro.api.md +++ b/sdk/core/core-lro/review/core-lro.api.md @@ -9,6 +9,36 @@ import { AbortSignalLike } from '@azure/abort-controller'; // @public export type CancelOnProgress = () => void; +// @public +export interface LongRunningOperation { + requestMethod: string; + requestPath: string; + sendInitialRequest: () => Promise>; + sendPollRequest: (path: string) => Promise>; +} + +// @public +export class LroEngine> extends Poller { + constructor(lro: LongRunningOperation, options?: LroEngineOptions); + delay(): Promise; +} + +// @public +export interface LroEngineOptions { + intervalInMs?: number; + lroResourceLocationConfig?: LroResourceLocationConfig; + resumeFrom?: string; +} + +// @public +export type LroResourceLocationConfig = "azure-async-operation" | "location" | "original-uri"; + +// @public +export interface LroResponse { + flatResponse: T; + rawResponse: RawResponse; +} + // @public export abstract class Poller, TResult> implements PollerLike { constructor(operation: PollOperation); @@ -83,6 +113,15 @@ export interface PollOperationState { // @public export type PollProgressCallback = (state: TState) => void; +// @public +export interface RawResponse { + body?: unknown; + headers: { + [headerName: string]: string; + }; + statusCode: number; +} + // (No @packageDocumentation comment for this package) diff --git a/sdk/core/core-lro/src/index.ts b/sdk/core/core-lro/src/index.ts index 972b21c2de22..1f1fbf8ff00a 100644 --- a/sdk/core/core-lro/src/index.ts +++ b/sdk/core/core-lro/src/index.ts @@ -3,3 +3,4 @@ export * from "./pollOperation"; export * from "./poller"; +export * from "./lroEngine"; diff --git a/sdk/core/core-lro/src/lroEngine/azureAsyncPolling.ts b/sdk/core/core-lro/src/lroEngine/azureAsyncPolling.ts new file mode 100644 index 000000000000..2092c6576736 --- /dev/null +++ b/sdk/core/core-lro/src/lroEngine/azureAsyncPolling.ts @@ -0,0 +1,80 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { + failureStates, + LroResourceLocationConfig, + LongRunningOperation, + LroBody, + LroResponse, + LroStatus, + RawResponse, + successStates +} from "./models"; +import { isUnexpectedPollingResponse } from "./requestUtils"; + +function getResponseStatus(rawResponse: RawResponse): string { + const { status } = (rawResponse.body as LroBody) ?? {}; + return status?.toLowerCase() ?? "succeeded"; +} + +function isAzureAsyncPollingDone(rawResponse: RawResponse): boolean { + const state = getResponseStatus(rawResponse); + if (isUnexpectedPollingResponse(rawResponse) || failureStates.includes(state)) { + throw new Error(`The long running operation has failed. The provisioning state: ${state}.`); + } + return successStates.includes(state); +} + +/** + * Sends a request to the URI of the provisioned resource if needed. + */ +async function sendFinalRequest( + lro: LongRunningOperation, + resourceLocation: string, + lroResourceLocationConfig?: LroResourceLocationConfig +): Promise | undefined> { + switch (lroResourceLocationConfig) { + case "original-uri": + return lro.sendPollRequest(lro.requestPath); + case "azure-async-operation": + return undefined; + case "location": + default: + return lro.sendPollRequest(resourceLocation ?? lro.requestPath); + } +} + +export function processAzureAsyncOperationResult( + lro: LongRunningOperation, + resourceLocation?: string, + lroResourceLocationConfig?: LroResourceLocationConfig +): (response: LroResponse) => LroStatus { + return (response: LroResponse): LroStatus => { + if (isAzureAsyncPollingDone(response.rawResponse)) { + if (resourceLocation === undefined) { + return { ...response, done: true }; + } else { + return { + ...response, + done: false, + next: async () => { + const finalResponse = await sendFinalRequest( + lro, + resourceLocation, + lroResourceLocationConfig + ); + return { + ...(finalResponse ?? response), + done: true + }; + } + }; + } + } + return { + ...response, + done: false + }; + }; +} diff --git a/sdk/core/core-lro/src/lroEngine/bodyPolling.ts b/sdk/core/core-lro/src/lroEngine/bodyPolling.ts new file mode 100644 index 000000000000..7653e6563208 --- /dev/null +++ b/sdk/core/core-lro/src/lroEngine/bodyPolling.ts @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { + failureStates, + LroBody, + LroResponse, + LroStatus, + RawResponse, + successStates +} from "./models"; +import { isUnexpectedPollingResponse } from "./requestUtils"; + +function getProvisioningState(rawResponse: RawResponse): string { + const { properties, provisioningState } = (rawResponse.body as LroBody) ?? {}; + const state: string | undefined = properties?.provisioningState ?? provisioningState; + return state?.toLowerCase() ?? "succeeded"; +} + +export function isBodyPollingDone(rawResponse: RawResponse): boolean { + const state = getProvisioningState(rawResponse); + if (isUnexpectedPollingResponse(rawResponse) || failureStates.includes(state)) { + throw new Error(`The long running operation has failed. The provisioning state: ${state}.`); + } + return successStates.includes(state); +} + +/** + * Creates a polling strategy based on BodyPolling which uses the provisioning state + * from the result to determine the current operation state + */ +export function processBodyPollingOperationResult( + response: LroResponse +): LroStatus { + return { + ...response, + done: isBodyPollingDone(response.rawResponse) + }; +} diff --git a/sdk/core/core-lro/src/lroEngine/index.ts b/sdk/core/core-lro/src/lroEngine/index.ts new file mode 100644 index 000000000000..eedf43833638 --- /dev/null +++ b/sdk/core/core-lro/src/lroEngine/index.ts @@ -0,0 +1,11 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +export { LroEngine } from "./lroEngine"; +export { + LroResourceLocationConfig, + RawResponse, + LongRunningOperation, + LroResponse, + LroEngineOptions +} from "./models"; diff --git a/sdk/core/core-lro/src/lroEngine/locationPolling.ts b/sdk/core/core-lro/src/lroEngine/locationPolling.ts new file mode 100644 index 000000000000..bf881809e3fd --- /dev/null +++ b/sdk/core/core-lro/src/lroEngine/locationPolling.ts @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { LroResponse, LroStatus, RawResponse } from "./models"; +import { isUnexpectedPollingResponse } from "./requestUtils"; + +function isLocationPollingDone(rawResponse: RawResponse): boolean { + return !isUnexpectedPollingResponse(rawResponse) && rawResponse.statusCode !== 202; +} + +export function processLocationPollingOperationResult( + response: LroResponse +): LroStatus { + return { + ...response, + done: isLocationPollingDone(response.rawResponse) + }; +} diff --git a/sdk/core/core-lro/src/lroEngine/logger.ts b/sdk/core/core-lro/src/lroEngine/logger.ts new file mode 100644 index 000000000000..aa96a42d1cc4 --- /dev/null +++ b/sdk/core/core-lro/src/lroEngine/logger.ts @@ -0,0 +1,10 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { createClientLogger } from "@azure/logger"; + +/** + * The `@azure/logger` configuration for this package. + * @internal + */ +export const logger = createClientLogger("core-lro"); diff --git a/sdk/core/core-lro/src/lroEngine/lroEngine.ts b/sdk/core/core-lro/src/lroEngine/lroEngine.ts new file mode 100644 index 000000000000..e7d5851f0dcb --- /dev/null +++ b/sdk/core/core-lro/src/lroEngine/lroEngine.ts @@ -0,0 +1,52 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { Poller } from "../poller"; +import { PollOperationState } from "../pollOperation"; +import { + LongRunningOperation, + LroEngineOptions, + PollerConfig, + ResumablePollOperationState +} from "./models"; +import { GenericPollOperation } from "./operation"; + +function deserializeState( + serializedState: string +): TState & ResumablePollOperationState { + try { + return JSON.parse(serializedState).state; + } catch (e) { + throw new Error(`LroEngine: Unable to deserialize state: ${serializedState}`); + } +} + +/** + * The LRO Engine, a class that performs polling. + */ +export class LroEngine> extends Poller< + TState, + TResult +> { + private config: PollerConfig; + + constructor(lro: LongRunningOperation, options?: LroEngineOptions) { + const { intervalInMs = 2000, resumeFrom } = options || {}; + const state: TState & ResumablePollOperationState = resumeFrom + ? deserializeState(resumeFrom) + : ({} as TState & ResumablePollOperationState); + + const operation = new GenericPollOperation(state, lro, options?.lroResourceLocationConfig); + super(operation); + + this.config = { intervalInMs: intervalInMs }; + operation.setPollerConfig(this.config); + } + + /** + * The method used by the poller to wait before attempting to update its operation. + */ + delay(): Promise { + return new Promise((resolve) => setTimeout(() => resolve(), this.config.intervalInMs)); + } +} diff --git a/sdk/core/core-lro/src/lroEngine/models.ts b/sdk/core/core-lro/src/lroEngine/models.ts new file mode 100644 index 000000000000..ef202b06f710 --- /dev/null +++ b/sdk/core/core-lro/src/lroEngine/models.ts @@ -0,0 +1,154 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { PollOperationState } from "../pollOperation"; + +/** + * Options for the LRO poller. + */ +export interface LroEngineOptions { + /** + * Defines how much time the poller is going to wait before making a new request to the service. + */ + intervalInMs?: number; + /** + * A serialized poller which can be used to resume an existing paused Long-Running-Operation. + */ + resumeFrom?: string; + /** + * The potential location of the result of the LRO if specified by the LRO extension in the swagger. + */ + lroResourceLocationConfig?: LroResourceLocationConfig; +} + +export const successStates = ["succeeded"]; +export const failureStates = ["failed", "canceled", "cancelled"]; +/** + * The LRO states that signal that the LRO has completed. + */ +export const terminalStates = successStates.concat(failureStates); + +/** + * The potential location of the result of the LRO if specified by the LRO extension in the swagger. + */ +export type LroResourceLocationConfig = "azure-async-operation" | "location" | "original-uri"; + +/** + * The type of a LRO response body. This is just a convenience type for checking the status of the operation. + */ + +export interface LroBody extends Record { + /** The status of the operation. */ + status?: string; + /** The state of the provisioning process */ + provisioningState?: string; + /** The properties of the provisioning process */ + properties?: { provisioningState?: string } & Record; +} + +/** + * Simple type of the raw response. + */ +export interface RawResponse { + /** The HTTP status code */ + statusCode: number; + /** A HttpHeaders collection in the response represented as a simple JSON object where all header names have been normalized to be lower-case. */ + headers: { + [headerName: string]: string; + }; + /** The parsed response body */ + body?: unknown; +} + +/** + * The type of the response of a LRO. + */ +export interface LroResponse { + /** The flattened response */ + flatResponse: T; + /** The raw response */ + rawResponse: RawResponse; +} + +/** The type of which LRO implementation being followed by a specific API. */ +export type LroMode = "AzureAsync" | "Location" | "Body"; + +/** + * The configuration of a LRO to determine how to perform polling and checking whether the operation has completed. + */ +export interface LroConfig { + /** The LRO mode */ + mode?: LroMode; + /** The path of a provisioned resource */ + resourceLocation?: string; +} + +/** + * Type of a polling operation state that can actually be resumed. + */ +export type ResumablePollOperationState = PollOperationState & { + initialRawResponse?: RawResponse; + config?: LroConfig; + pollingURL?: string; +}; + +export interface PollerConfig { + intervalInMs: number; +} + +/** + * The type of a terminal state of an LRO. + */ +export interface LroTerminalState extends LroResponse { + /** + * Whether the operation has finished. + */ + done: true; +} + +/** + * The type of an in-progress state of an LRO. + */ +export interface LroInProgressState extends LroResponse { + /** + * Whether the operation has finished. + */ + done: false; + /** + * The request to be sent next if it is different from the standard polling one. + * Notice that it will disregard any polling URLs provided to it. + */ + next?: () => Promise>; +} + +/** + * The type of an LRO state which is a tagged union of terminal and in-progress states. + */ +export type LroStatus = LroTerminalState | LroInProgressState; + +/** + * The type of the getLROStatusFromResponse method. It takes the response as input and returns along the response whether the operation has finished. + */ +export type GetLroStatusFromResponse = (response: LroResponse) => LroStatus; + +/** + * Description of a long running operation. + */ +export interface LongRunningOperation { + /** + * The request path. + */ + requestPath: string; + /** + * The HTTP request method. + */ + requestMethod: string; + /** + * A function that can be used to send initial request to the service. + */ + sendInitialRequest: () => Promise>; + /** + * A function that can be used to poll for the current status of a long running operation. + */ + sendPollRequest: (path: string) => Promise>; +} diff --git a/sdk/core/core-lro/src/lroEngine/operation.ts b/sdk/core/core-lro/src/lroEngine/operation.ts new file mode 100644 index 000000000000..9af49e73fe85 --- /dev/null +++ b/sdk/core/core-lro/src/lroEngine/operation.ts @@ -0,0 +1,119 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { AbortSignalLike } from "@azure/abort-controller"; +import { PollOperation, PollOperationState } from "../pollOperation"; +import { logger } from "./logger"; +import { + PollerConfig, + ResumablePollOperationState, + LongRunningOperation, + GetLroStatusFromResponse, + LroResourceLocationConfig, + LroStatus +} from "./models"; +import { getPollingUrl } from "./requestUtils"; +import { createGetLroStatusFromResponse, createInitializeState, createPoll } from "./stateMachine"; + +export class GenericPollOperation> + implements PollOperation { + private poll?: ( + pollingURL: string, + pollerConfig: PollerConfig, + getLroStatusFromResponse: GetLroStatusFromResponse + ) => Promise>; + private pollerConfig?: PollerConfig; + private getLroStatusFromResponse?: GetLroStatusFromResponse; + + constructor( + public state: TState & ResumablePollOperationState, + private lro: LongRunningOperation, + private lroResourceLocationConfig?: LroResourceLocationConfig + ) {} + + public setPollerConfig(pollerConfig: PollerConfig): void { + this.pollerConfig = pollerConfig; + } + + /** + * General update function for LROPoller, the general process is as follows + * 1. Check initial operation result to determine the strategy to use + * - Strategies: Location, Azure-AsyncOperation, Original Uri + * 2. Check if the operation result has a terminal state + * - Terminal state will be determined by each strategy + * 2.1 If it is terminal state Check if a final GET request is required, if so + * send final GET request and return result from operation. If no final GET + * is required, just return the result from operation. + * - Determining what to call for final request is responsibility of each strategy + * 2.2 If it is not terminal state, call the polling operation and go to step 1 + * - Determining what to call for polling is responsibility of each strategy + * - Strategies will always use the latest URI for polling if provided otherwise + * the last known one + */ + async update(options?: { + abortSignal?: AbortSignalLike | undefined; + fireProgress?: ((state: TState) => void) | undefined; + }): Promise> { + const state = this.state; + if (!state.isStarted) { + const initializeState = createInitializeState( + state, + this.lro.requestPath, + this.lro.requestMethod + ); + const response = await this.lro.sendInitialRequest(); + initializeState(response); + } + + if (!state.isCompleted) { + if (!this.poll || !this.getLroStatusFromResponse) { + if (!state.config) { + throw new Error( + "Bad state: LRO mode is undefined. Please check if the serialized state is well-formed." + ); + } + this.getLroStatusFromResponse = createGetLroStatusFromResponse( + this.lro, + state.config, + this.lroResourceLocationConfig + ); + this.poll = createPoll(this.lro); + } + if (!state.pollingURL) { + throw new Error( + "Bad state: polling URL is undefined. Please check if the serialized state is well-formed." + ); + } + const currentState = await this.poll( + state.pollingURL, + this.pollerConfig!, + this.getLroStatusFromResponse + ); + logger.verbose(`LRO: polling response: ${JSON.stringify(currentState.rawResponse)}`); + if (currentState.done) { + state.result = currentState.flatResponse; + state.isCompleted = true; + } else { + this.poll = currentState.next ?? this.poll; + state.pollingURL = getPollingUrl(currentState.rawResponse, state.pollingURL); + } + } + logger.verbose(`LRO: current state: ${JSON.stringify(state)}`); + options?.fireProgress?.(state); + return this; + } + + async cancel(): Promise> { + this.state.isCancelled = true; + return this; + } + + /** + * Serializes the Poller operation. + */ + public toString(): string { + return JSON.stringify({ + state: this.state + }); + } +} diff --git a/sdk/core/core-lro/src/lroEngine/passthrough.ts b/sdk/core/core-lro/src/lroEngine/passthrough.ts new file mode 100644 index 000000000000..2f71930379ce --- /dev/null +++ b/sdk/core/core-lro/src/lroEngine/passthrough.ts @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { LroResponse, LroStatus } from "./models"; + +export function processPassthroughOperationResult( + response: LroResponse +): LroStatus { + return { + ...response, + done: true + }; +} diff --git a/sdk/core/core-lro/src/lroEngine/requestUtils.ts b/sdk/core/core-lro/src/lroEngine/requestUtils.ts new file mode 100644 index 000000000000..839fef893bbc --- /dev/null +++ b/sdk/core/core-lro/src/lroEngine/requestUtils.ts @@ -0,0 +1,94 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { LroConfig, RawResponse } from "./models"; + +/** + * Detects where the continuation token is and returns it. Notice that azure-asyncoperation + * must be checked first before the other location headers because there are scenarios + * where both azure-asyncoperation and location could be present in the same response but + * azure-asyncoperation should be the one to use for polling. + */ +export function getPollingUrl(rawResponse: RawResponse, defaultPath: string): string { + return ( + getAzureAsyncOperation(rawResponse) ?? + getLocation(rawResponse) ?? + getOperationLocation(rawResponse) ?? + defaultPath + ); +} + +function getLocation(rawResponse: RawResponse): string | undefined { + return rawResponse.headers["location"]; +} + +function getOperationLocation(rawResponse: RawResponse): string | undefined { + return rawResponse.headers["operation-location"]; +} + +function getAzureAsyncOperation(rawResponse: RawResponse): string | undefined { + return rawResponse.headers["azure-asyncoperation"]; +} + +export function inferLroMode( + requestPath: string, + requestMethod: string, + rawResponse: RawResponse +): LroConfig { + if (getAzureAsyncOperation(rawResponse) !== undefined) { + return { + mode: "AzureAsync", + resourceLocation: + requestMethod === "PUT" + ? requestPath + : requestMethod === "POST" + ? getLocation(rawResponse) + : undefined + }; + } else if ( + getLocation(rawResponse) !== undefined || + getOperationLocation(rawResponse) !== undefined + ) { + return { + mode: "Location" + }; + } else if (["PUT", "PATCH"].includes(requestMethod)) { + return { + mode: "Body" + }; + } + return {}; +} + +class SimpleRestError extends Error { + public statusCode?: number; + constructor(message: string, statusCode: number) { + super(message); + this.name = "RestError"; + this.statusCode = statusCode; + + Object.setPrototypeOf(this, SimpleRestError.prototype); + } +} + +export function isUnexpectedInitialResponse(rawResponse: RawResponse): boolean { + const code = rawResponse.statusCode; + if (![203, 204, 202, 201, 200, 500].includes(code)) { + throw new SimpleRestError( + `Received unexpected HTTP status code ${code} in the initial response. This may indicate a server issue.`, + code + ); + } + return false; +} + +export function isUnexpectedPollingResponse(rawResponse: RawResponse): boolean { + const code = rawResponse.statusCode; + if (![202, 201, 200, 500].includes(code)) { + throw new SimpleRestError( + `Received unexpected HTTP status code ${code} while polling. This may indicate a server issue.`, + code + ); + } + return false; +} diff --git a/sdk/core/core-lro/src/lroEngine/stateMachine.ts b/sdk/core/core-lro/src/lroEngine/stateMachine.ts new file mode 100644 index 000000000000..83ddbb6e8d0d --- /dev/null +++ b/sdk/core/core-lro/src/lroEngine/stateMachine.ts @@ -0,0 +1,117 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { processAzureAsyncOperationResult } from "./azureAsyncPolling"; +import { isBodyPollingDone, processBodyPollingOperationResult } from "./bodyPolling"; +import { processLocationPollingOperationResult } from "./locationPolling"; +import { logger } from "./logger"; +import { + LroResourceLocationConfig, + GetLroStatusFromResponse, + LongRunningOperation, + LroConfig, + PollerConfig, + ResumablePollOperationState, + LroResponse, + LroStatus +} from "./models"; +import { processPassthroughOperationResult } from "./passthrough"; +import { getPollingUrl, inferLroMode, isUnexpectedInitialResponse } from "./requestUtils"; + +/** + * creates a stepping function that maps an LRO state to another. + */ +export function createGetLroStatusFromResponse( + lroPrimitives: LongRunningOperation, + config: LroConfig, + lroResourceLocationConfig?: LroResourceLocationConfig +): GetLroStatusFromResponse { + switch (config.mode) { + case "AzureAsync": { + return processAzureAsyncOperationResult( + lroPrimitives, + config.resourceLocation, + lroResourceLocationConfig + ); + } + case "Location": { + return processLocationPollingOperationResult; + } + case "Body": { + return processBodyPollingOperationResult; + } + default: { + return processPassthroughOperationResult; + } + } +} + +/** + * Creates a polling operation. + */ +export function createPoll( + lroPrimitives: LongRunningOperation +): ( + pollingURL: string, + pollerConfig: PollerConfig, + getLroStatusFromResponse: GetLroStatusFromResponse +) => Promise> { + return async ( + path: string, + pollerConfig: PollerConfig, + getLroStatusFromResponse: GetLroStatusFromResponse + ): Promise> => { + const response = await lroPrimitives.sendPollRequest(path); + const retryAfter: string | undefined = response.rawResponse.headers["retry-after"]; + if (retryAfter !== undefined) { + const retryAfterInMs = parseInt(retryAfter); + pollerConfig.intervalInMs = isNaN(retryAfterInMs) + ? calculatePollingIntervalFromDate(new Date(retryAfter), pollerConfig.intervalInMs) + : retryAfterInMs; + } + return getLroStatusFromResponse(response); + }; +} + +function calculatePollingIntervalFromDate( + retryAfterDate: Date, + defaultIntervalInMs: number +): number { + const timeNow = Math.floor(new Date().getTime()); + const retryAfterTime = retryAfterDate.getTime(); + if (timeNow < retryAfterTime) { + return retryAfterTime - timeNow; + } + return defaultIntervalInMs; +} + +/** + * Creates a callback to be used to initialize the polling operation state. + * @param state - of the polling operation + * @param operationSpec - of the LRO + * @param callback - callback to be called when the operation is done + * @returns callback that initializes the state of the polling operation + */ +export function createInitializeState( + state: ResumablePollOperationState, + requestPath: string, + requestMethod: string +): (response: LroResponse) => boolean { + return (response: LroResponse): boolean => { + if (isUnexpectedInitialResponse(response.rawResponse)) return true; + state.initialRawResponse = response.rawResponse; + state.isStarted = true; + state.pollingURL = getPollingUrl(state.initialRawResponse, requestPath); + state.config = inferLroMode(requestPath, requestMethod, state.initialRawResponse); + /** short circuit polling if body polling is done in the initial request */ + if ( + state.config.mode === undefined || + (state.config.mode === "Body" && isBodyPollingDone(state.initialRawResponse)) + ) { + state.result = response.flatResponse as TResult; + state.isCompleted = true; + } + logger.verbose(`LRO: initial state: ${JSON.stringify(state)}`); + return Boolean(state.isCompleted); + }; +} diff --git a/sdk/core/core-lro/test/engine.spec.ts b/sdk/core/core-lro/test/engine.spec.ts new file mode 100644 index 000000000000..d2559dc9bf43 --- /dev/null +++ b/sdk/core/core-lro/test/engine.spec.ts @@ -0,0 +1,556 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { assert } from "chai"; +import { mockedPoller, runMockedLro } from "./utils/router"; + +describe("Lro Engine", function() { + it("put201Succeeded", async function() { + const result = await runMockedLro("PUT", "/put/201/succeeded"); + assert.equal(result.id, "100"); + assert.equal(result.name, "foo"); + assert.equal(result.properties?.provisioningState, "Succeeded"); + }); + + describe("BodyPolling Strategy", () => { + it("put200Succeeded", async function() { + const result = await runMockedLro("PUT", "/put/200/succeeded"); + assert.equal(result.properties?.provisioningState, "Succeeded"); + }); + + it("should handle initial response with terminal state without provisioning State", async () => { + const result = await runMockedLro("PUT", "/put/200/succeeded/nostate"); + assert.deepEqual(result.id, "100"); + assert.deepEqual(result.name, "foo"); + }); + + it("should handle initial response creating followed by success through an Azure Resource", async () => { + const result = await runMockedLro("PUT", "/put/201/creating/succeeded/200"); + assert.deepEqual(result.properties?.provisioningState, "Succeeded"); + assert.deepEqual(result.id, "100"); + assert.deepEqual(result.name, "foo"); + }); + + it("should handle put200Acceptedcanceled200", async () => { + try { + await runMockedLro("PUT", "/put/200/accepted/canceled/200"); + throw new Error("should have thrown instead"); + } catch (e) { + assert.equal( + e.message, + "The long running operation has failed. The provisioning state: canceled." + ); + } + }); + + it("should handle put200UpdatingSucceeded204", async () => { + const result = await runMockedLro("PUT", "/put/200/updating/succeeded/200"); + assert.deepEqual(result.properties?.provisioningState, "Succeeded"); + assert.deepEqual(result.id, "100"); + assert.deepEqual(result.name, "foo"); + }); + + it("should handle put201CreatingFailed200", async () => { + try { + await runMockedLro("PUT", "/put/201/created/failed/200"); + throw new Error("should have thrown instead"); + } catch (e) { + assert.equal( + e.message, + "The long running operation has failed. The provisioning state: failed." + ); + } + }); + }); + + describe("Location Strategy", () => { + it("should handle post202Retry200", async () => { + const response = await runMockedLro("POST", "/post/202/retry/200"); + assert.equal(response.statusCode, 200); + }); + + it("should handle post202NoRetry204", async () => { + try { + await runMockedLro("POST", "/post/202/noretry/204"); + throw new Error("should have thrown instead"); + } catch (e) { + assert.equal( + e.message, + "Received unexpected HTTP status code 204 while polling. This may indicate a server issue." + ); + } + }); + + it("should handle deleteNoHeaderInRetry", async () => { + try { + await runMockedLro("DELETE", "/delete/noheader"); + throw new Error("should have thrown instead"); + } catch (e) { + assert.equal( + e.message, + "Received unexpected HTTP status code 204 while polling. This may indicate a server issue." + ); + } + }); + + it("should handle put202Retry200", async () => { + const response = await runMockedLro("PUT", "/put/202/retry/200"); + assert.equal(response.statusCode, 200); + }); + + it("should handle putNoHeaderInRetry", async () => { + const result = await runMockedLro("PUT", "/put/noheader/202/200"); + assert.equal(result.id, "100"); + assert.equal(result.name, "foo"); + assert.equal(result.properties?.provisioningState, "Succeeded"); + }); + + it("should handle putSubResource", async () => { + const result = await runMockedLro("PUT", "/putsubresource/202/200"); + assert.equal(result.id, "100"); + assert.equal(result.properties?.provisioningState, "Succeeded"); + }); + + it("should handle putNonResource", async () => { + const result = await runMockedLro("PUT", "/putnonresource/202/200"); + assert.equal(result.id, "100"); + assert.equal(result.name, "sku"); + }); + + it("should handle delete202Retry200", async () => { + const response = await runMockedLro("DELETE", "/delete/202/retry/200"); + assert.equal(response.statusCode, 200); + }); + + it("should handle delete202NoRetry204", async () => { + try { + await runMockedLro("DELETE", "/delete/202/noretry/204"); + throw new Error("should have thrown instead"); + } catch (e) { + assert.equal( + e.message, + "Received unexpected HTTP status code 204 while polling. This may indicate a server issue." + ); + } + }); + + it("should handle deleteProvisioning202Accepted200Succeeded", async () => { + const response = await runMockedLro( + "DELETE", + "/delete/provisioning/202/accepted/200/succeeded" + ); + assert.equal(response.statusCode, 200); + }); + + it("should handle deleteProvisioning202DeletingFailed200", async () => { + const result = await runMockedLro("DELETE", "/delete/provisioning/202/deleting/200/failed"); + assert.equal(result.properties?.provisioningState, "Failed"); + }); + + it("should handle deleteProvisioning202Deletingcanceled200", async () => { + const result = await runMockedLro("DELETE", "/delete/provisioning/202/deleting/200/canceled"); + assert.equal(result.properties?.provisioningState, "Canceled"); + }); + }); + + describe("Passthrough strategy", () => { + it("should handle delete204Succeeded", async () => { + const response = await runMockedLro("DELETE", "/delete/204/succeeded"); + assert.equal(response.statusCode, 204); + }); + }); + + describe("Azure Async Operation Strategy", () => { + it("should handle postDoubleHeadersFinalLocationGet", async () => { + const result = await runMockedLro("POST", "/LROPostDoubleHeadersFinalLocationGet"); + assert.equal(result.id, "100"); + assert.equal(result.name, "foo"); + }); + + it("should handle postDoubleHeadersFinalAzureHeaderGet", async () => { + const result = await runMockedLro( + "POST", + "/LROPostDoubleHeadersFinalAzureHeaderGet", + undefined, + "azure-async-operation" + ); + assert.equal(result.id, "100"); + }); + + it("should handle post200WithPayload", async () => { + const result = await runMockedLro("POST", "/post/payload/200"); + assert.equal(result.id, "1"); + assert.equal(result.name, "product"); + }); + + it("should handle postDoubleHeadersFinalAzureHeaderGetDefault", async () => { + const result = await runMockedLro("POST", "/LROPostDoubleHeadersFinalAzureHeaderGetDefault"); + assert.equal(result.id, "100"); + assert.equal(result.statusCode, 200); + }); + + it("should handle deleteAsyncRetrySucceeded", async () => { + const response = await runMockedLro("DELETE", "/deleteasync/retry/succeeded"); + assert.equal(response.statusCode, 200); + }); + + it("should handle deleteAsyncNoRetrySucceeded", async () => { + const response = await runMockedLro("DELETE", "/deleteasync/noretry/succeeded"); + assert.equal(response.statusCode, 200); + }); + + it("should handle deleteAsyncRetrycanceled", async () => { + try { + await runMockedLro("DELETE", "/deleteasync/retry/canceled"); + throw new Error("should have thrown instead"); + } catch (e) { + assert.equal( + e.message, + "The long running operation has failed. The provisioning state: canceled." + ); + } + }); + + it("should handle DeleteAsyncRetryFailed", async () => { + try { + await runMockedLro("DELETE", "/deleteasync/retry/failed"); + throw new Error("should have thrown instead"); + } catch (e) { + assert.equal( + e.message, + "The long running operation has failed. The provisioning state: failed." + ); + } + }); + + it("should handle putAsyncRetrySucceeded", async () => { + const result = await runMockedLro("PUT", "/putasync/noretry/succeeded"); + assert.equal(result.id, "100"); + assert.equal(result.name, "foo"); + assert.equal(result.properties?.provisioningState, "Succeeded"); + }); + + it("should handle put201Succeeded", async () => { + const result = await runMockedLro("PUT", "/put/201/succeeded"); + assert.equal(result.id, "100"); + assert.equal(result.name, "foo"); + assert.equal(result.properties?.provisioningState, "Succeeded"); + }); + + it("should handle post202List", async () => { + const result = await runMockedLro("POST", "/list"); + assert.equal((result as any)[0].id, "100"); + assert.equal((result as any)[0].name, "foo"); + }); + + it("should handle putAsyncRetryFailed", async () => { + try { + await runMockedLro("PUT", "/putasync/retry/failed"); + throw new Error("should have thrown instead"); + } catch (e) { + assert.equal( + e.message, + "The long running operation has failed. The provisioning state: failed." + ); + } + }); + + it("should handle putAsyncNonResource", async () => { + const result = await runMockedLro("PUT", "/putnonresourceasync/202/200"); + assert.equal(result.name, "sku"); + assert.equal(result.id, "100"); + }); + + it("should handle putAsyncNoHeaderInRetry", async () => { + const result = await runMockedLro("PUT", "/putasync/noheader/201/200"); + assert.equal(result.name, "foo"); + assert.equal(result.id, "100"); + assert.deepEqual(result.properties?.provisioningState, "Succeeded"); + }); + + it("should handle putAsyncNoRetrySucceeded", async () => { + const result = await runMockedLro("PUT", "/putasync/noretry/succeeded"); + assert.equal(result.name, "foo"); + assert.equal(result.id, "100"); + }); + + it("should handle putAsyncNoRetrycanceled", async () => { + try { + await runMockedLro("PUT", "/putasync/noretry/canceled"); + throw new Error("should have thrown instead"); + } catch (e) { + assert.equal( + e.message, + "The long running operation has failed. The provisioning state: canceled." + ); + } + }); + + it("should handle putAsyncSubResource", async () => { + const result = await runMockedLro("PUT", "/putsubresourceasync/202/200"); + assert.equal(result.id, "100"); + assert.equal(result.properties?.provisioningState, "Succeeded"); + }); + + it("should handle deleteAsyncNoHeaderInRetry", async () => { + const response = await runMockedLro("DELETE", "/deleteasync/noheader/202/204"); + assert.equal(response.statusCode, 200); + }); + + it("should handle postAsyncNoRetrySucceeded", async () => { + const result = await runMockedLro("POST", "/postasync/noretry/succeeded"); + assert.deepInclude(result, { id: "100", name: "foo" }); + }); + + it("should handle postAsyncRetryFailed", async () => { + try { + await runMockedLro("POST", "/postasync/retry/failed"); + throw new Error("should have thrown instead"); + } catch (e) { + assert.equal( + e.message, + "The long running operation has failed. The provisioning state: failed." + ); + } + }); + + it("should handle postAsyncRetrySucceeded", async () => { + const result = await runMockedLro("POST", "/postasync/retry/succeeded"); + + assert.deepInclude(result, { id: "100", name: "foo" }); + }); + + it("should handle postAsyncRetrycanceled", async () => { + try { + await runMockedLro("POST", "/postasync/retry/canceled"); + throw new Error("should have thrown instead"); + } catch (e) { + assert.equal( + e.message, + "The long running operation has failed. The provisioning state: canceled." + ); + } + }); + }); + + describe("LRO Sad scenarios", () => { + it("should handle PutNonRetry400 ", async () => { + try { + await runMockedLro("PUT", "/nonretryerror/put/400"); + } catch (error) { + assert.equal(error.statusCode, 400); + } + }); + + it("should handle putNonRetry201Creating400 ", async () => { + try { + await runMockedLro("PUT", "/nonretryerror/put/201/creating/400"); + } catch (error) { + assert.equal(error.statusCode, 400); + } + }); + + it("should throw with putNonRetry201Creating400InvalidJson ", async () => { + try { + await runMockedLro("PUT", "/nonretryerror/put/201/creating/400/invalidjson"); + } catch (error) { + assert.equal(error.statusCode, 400); + } + }); + + it("should handle putAsyncRelativeRetry400 ", async () => { + try { + await runMockedLro("PUT", "/nonretryerror/putasync/retry/400"); + } catch (error) { + assert.equal(error.statusCode, 400); + } + }); + + it("should handle delete202NonRetry400 ", async () => { + try { + await runMockedLro("DELETE", "/nonretryerror/delete/202/retry/400"); + assert.fail("Scenario should throw"); + } catch (error) { + assert.equal(error.statusCode, 400); + } + }); + + it("should handle deleteNonRetry400 ", async () => { + try { + await runMockedLro("DELETE", "/nonretryerror/delete/400"); + assert.fail("Scenario should throw"); + } catch (error) { + assert.equal(error.statusCode, 400); + } + }); + + it("should handle deleteAsyncRelativeRetry400 ", async () => { + try { + await runMockedLro("DELETE", "/nonretryerror/deleteasync/retry/400"); + assert.fail("Scenario should throw"); + } catch (error) { + assert.equal(error.statusCode, 400); + } + }); + + it("should handle postNonRetry400 ", async () => { + try { + await runMockedLro("POST", "/nonretryerror/post/400"); + assert.fail("Scenario should throw"); + } catch (error) { + assert.equal(error.statusCode, 400); + } + }); + + it("should handle post202NonRetry400 ", async () => { + try { + await runMockedLro("POST", "/nonretryerror/post/202/retry/400"); + assert.fail("Scenario should throw"); + } catch (error) { + assert.equal(error.statusCode, 400); + } + }); + + it("should handle postAsyncRelativeRetry400 ", async () => { + try { + await runMockedLro("POST", "/nonretryerror/postasync/retry/400"); + assert.fail("Scenario should throw"); + } catch (error) { + assert.equal(error.statusCode, 400); + } + }); + + it("should handle PutError201NoProvisioningStatePayload ", async () => { + const response = await runMockedLro("PUT", "/error/put/201/noprovisioningstatepayload"); + assert.equal(response.statusCode, 201); // weird! + }); + + it("should handle putAsyncRelativeRetryNoStatusPayload ", async () => { + const response = await runMockedLro("PUT", "/error/putasync/retry/nostatuspayload"); + assert.equal(response.statusCode, 200); + }); + + it("should handle putAsyncRelativeRetryNoStatus ", async () => { + const response = await runMockedLro("PUT", "/error/putasync/retry/nostatus"); + assert.equal(response.statusCode, 200); + }); + + it("should handle delete204Succeeded ", async () => { + const response = await runMockedLro("DELETE", "/error/delete/204/nolocation"); + assert.equal(response.statusCode, 204); + }); + + it("should handle deleteAsyncRelativeRetryNoStatus ", async () => { + const response = await runMockedLro("DELETE", "/error/deleteasync/retry/nostatus"); + assert.equal(response.statusCode, 200); + }); + + it("should handle post202NoLocation ", async () => { + const response = await runMockedLro("POST", "/error/post/202/nolocation"); + assert.equal(response.statusCode, 202); + }); + + it("should handle postAsyncRelativeRetryNoPayload ", async () => { + const response = await runMockedLro("POST", "/error/postasync/retry/nopayload"); + assert.equal(response.statusCode, 200); + }); + + it("should handle put200InvalidJson ", async () => { + try { + await runMockedLro("PUT", "/error/put/200/invalidjson"); + assert.fail("Scenario should throw"); + } catch (error) { + assert.equal(error.message, "Unexpected end of JSON input"); + } + }); + + it("should handle putAsyncRelativeRetryInvalidHeader ", async () => { + try { + await runMockedLro("PUT", "/error/putasync/retry/invalidheader"); + assert.fail("Scenario should throw"); + } catch (error) { + assert.equal(error.statusCode, 404); + // assert.equal(error.statusCode, 404); // core-client would have validated the retry-after header + } + }); + + it("should handle putAsyncRelativeRetryInvalidJsonPolling ", async () => { + try { + await runMockedLro("PUT", "/error/putasync/retry/invalidjsonpolling"); + assert.fail("Scenario should throw"); + } catch (error) { + assert.equal(error.message, "Unexpected end of JSON input"); + } + }); + + it("should handle delete202RetryInvalidHeader ", async () => { + try { + await runMockedLro("DELETE", "/error/delete/202/retry/invalidheader"); + assert.fail("Scenario should throw"); + } catch (error) { + assert.equal(error.statusCode, 404); + } + }); + + it("should handle deleteAsyncRelativeRetryInvalidHeader ", async () => { + try { + await runMockedLro("DELETE", "/error/deleteasync/retry/invalidheader"); + assert.fail("Scenario should throw"); + } catch (error) { + assert.equal(error.statusCode, 404); + } + }); + + it("should handle DeleteAsyncRelativeRetryInvalidJsonPolling ", async () => { + try { + await runMockedLro("DELETE", "/error/deleteasync/retry/invalidjsonpolling"); + assert.fail("Scenario should throw"); + } catch (error) { + assert.equal(error.message, "Unexpected end of JSON input"); + } + }); + + it("should handle post202RetryInvalidHeader ", async () => { + try { + await runMockedLro("POST", "/error/post/202/retry/invalidheader"); + assert.fail("Scenario should throw"); + } catch (error) { + assert.equal(error.statusCode, 404); + } + }); + + it("should handle postAsyncRelativeRetryInvalidHeader ", async () => { + try { + await runMockedLro("POST", "/error/postasync/retry/invalidheader"); + assert.fail("Scenario should throw"); + } catch (error) { + assert.equal(error.statusCode, 404); + } + }); + + it("should handle postAsyncRelativeRetryInvalidJsonPolling ", async () => { + try { + await runMockedLro("POST", "/error/postasync/retry/invalidjsonpolling"); + assert.fail("Scenario should throw"); + } catch (error) { + assert.equal(error.message, "Unexpected end of JSON input"); + } + }); + }); + + describe("serialized state", () => { + let state: any, serializedState: string; + it("should handle serializing the state", async () => { + const poller = mockedPoller("PUT", "/put/200/succeeded"); + poller.onProgress((currentState) => { + if (state === undefined && serializedState === undefined) { + state = currentState; + serializedState = JSON.stringify({ state: currentState }); + assert.equal(serializedState, poller.toString()); + } + }); + await poller.pollUntilDone(); + assert.ok(state.initialRawResponse); + }); + }); +}); diff --git a/sdk/core/core-lro/test/utils/coreRestPipelineLro.ts b/sdk/core/core-lro/test/utils/coreRestPipelineLro.ts new file mode 100644 index 000000000000..033c5bd91596 --- /dev/null +++ b/sdk/core/core-lro/test/utils/coreRestPipelineLro.ts @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { PipelineRequest } from "@azure/core-rest-pipeline"; +import { LongRunningOperation, LroResponse } from "../../src"; + +export type SendOperationFn = (request: PipelineRequest) => Promise>; + +export class CoreRestPipelineLro implements LongRunningOperation { + constructor( + private sendOperationFn: SendOperationFn, + private req: PipelineRequest, + public requestPath: string = req.url, + public requestMethod: string = req.method + ) {} + public async sendInitialRequest(): Promise> { + return this.sendOperationFn(this.req); + } + + public async sendPollRequest(url: string): Promise> { + return this.sendOperationFn({ + ...this.req, + method: "GET", + url + }); + } +} diff --git a/sdk/core/core-lro/test/utils/router.ts b/sdk/core/core-lro/test/utils/router.ts new file mode 100644 index 000000000000..5f833a81e31b --- /dev/null +++ b/sdk/core/core-lro/test/utils/router.ts @@ -0,0 +1,97 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { + createHttpHeaders, + HttpClient, + HttpMethods, + PipelineRequest, + PipelineResponse, + RestError +} from "@azure/core-rest-pipeline"; +import { LroEngine, PollerLike, PollOperationState } from "../../src"; +import { LroResourceLocationConfig, LroBody, LroResponse } from "../../src/lroEngine/models"; +import { CoreRestPipelineLro } from "./coreRestPipelineLro"; +import { paramRoutes } from "./router/paramRoutes"; +import { routes, routesTable } from "./router/routesTable"; +import { applyScenarios } from "./router/utils"; + +/** + * Re-implementation of the lro routes in Autorest test server located in https://github.com/Azure/autorest.testserver/blob/main/legacy/routes/lros.js + */ + +const lroClient: HttpClient = { + async sendRequest(request: PipelineRequest): Promise { + const reqPath = request.url; + const reqMethod = request.method; + if (routesTable.has(reqPath) === true) { + const route = routesTable.get(reqPath)!; + if (route.method === reqMethod) { + return route.process(request); + } else { + for (const { method, path, process } of routes) { + if (method === reqMethod && path === reqPath) { + return process(request); + } + } + } + } + const response = applyScenarios(request, paramRoutes); + if (response) { + return response; + } + throw new RestError(`Route for ${reqMethod} request to ${reqPath} was not found`, { + statusCode: 404 + }); + } +}; + +export type Response = LroBody & { statusCode: number }; + +async function runRouter(request: PipelineRequest): Promise> { + const response = await lroClient.sendRequest(request); + const parsedBody: LroBody = response.bodyAsText + ? JSON.parse(response.bodyAsText) + : response.bodyAsText; + const headers = response.headers.toJSON(); + return { + flatResponse: { ...parsedBody, ...headers, statusCode: response.status }, + rawResponse: { + headers: headers, + statusCode: response.status, + body: parsedBody + } + }; +} + +export function mockedPoller( + method: HttpMethods, + url: string, + lroResourceLocationConfig?: LroResourceLocationConfig +): PollerLike, Response> { + const lro = new CoreRestPipelineLro(runRouter, { + method: method, + url: url, + headers: createHttpHeaders(), + requestId: "", + timeout: 0, + withCredentials: false + }); + return new LroEngine(lro, { + intervalInMs: 0, + lroResourceLocationConfig: lroResourceLocationConfig + }); +} + +export async function runMockedLro( + method: HttpMethods, + url: string, + onProgress?: (state: PollOperationState) => void, + lroResourceLocationConfig?: LroResourceLocationConfig +): Promise { + const poller = mockedPoller(method, url, lroResourceLocationConfig); + if (onProgress !== undefined) { + poller.onProgress(onProgress); + } + return poller.pollUntilDone(); +} diff --git a/sdk/core/core-lro/test/utils/router/paramRoutes.ts b/sdk/core/core-lro/test/utils/router/paramRoutes.ts new file mode 100644 index 000000000000..cc0bc6406538 --- /dev/null +++ b/sdk/core/core-lro/test/utils/router/paramRoutes.ts @@ -0,0 +1,384 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { createHttpHeaders, PipelineRequest, PipelineResponse } from "@azure/core-rest-pipeline"; + +import { buildResponse, getPascalCase, parseUri } from "./utils"; + +function putBody(request: PipelineRequest): PipelineResponse | undefined { + function isValidRequest( + initialCode: number, + initialState: string, + finalState: string, + finalCode: number + ): boolean { + return ( + (initialCode === 201 && + initialState === "Creating" && + finalState === "Succeeded" && + finalCode === 200) || + (initialCode === 200 && + initialState === "Updating" && + finalState === "Succeeded" && + finalCode === 200) || + (initialCode === 201 && + initialState === "Created" && + finalState === "Failed" && + finalCode === 200) || + (initialCode === 200 && + initialState === "Accepted" && + finalState === "Canceled" && + finalCode === 200) + ); + } + const path = request.url; + const pieces = path.substr(1).split("/"); + if (pieces.length === 5) { + try { + const initialCode: number = JSON.parse(pieces[1]); + const initialState = getPascalCase(pieces[2]); + const finalState = getPascalCase(pieces[3]); + const finalCode: number = JSON.parse(pieces[4]); + if (!isValidRequest(initialCode, initialState, finalState, finalCode)) { + return undefined; + } + if (request.method === "PUT") { + return buildResponse( + request, + initialCode, + `{ "properties": { "provisioningState": "` + + initialState + + `"}, "id": "100", "name": "foo" }` + ); + } else if (request.method === "GET") { + return buildResponse( + request, + finalCode, + `{ "properties": { "provisioningState": "` + + finalState + + `"}, "id": "100", "name": "foo" }` + ); + } + } catch (e) { + return undefined; + } + } + return undefined; +} + +function retries(request: PipelineRequest): PipelineResponse | undefined { + function isValidRequest(initialCode: number, retry: string, finalCode: number): boolean { + return ( + (initialCode === 202 && retry === "retry" && finalCode === 200) || + (initialCode === 202 && retry === "noretry" && finalCode === 204) + ); + } + const pieces = parseUri(request.url); + try { + if (pieces.length === 4) { + const methodInUri = pieces[0]; + const initialCode: number = JSON.parse(pieces[1]); + const retry = pieces[2]; + const finalCode: number = JSON.parse(pieces[3]); + if (!isValidRequest(initialCode, retry, finalCode)) return undefined; + const method = request.method.toLowerCase(); + const headers = createHttpHeaders({ + Location: `/${methodInUri}${ + method === "get" ? "/newuri" : "" + }/${initialCode}/${retry}/${finalCode}` + }); + if (retry === "retry") { + headers.set("Retry-After", "0"); + } + return { + request: request, + headers: headers, + status: initialCode + }; + } else if (pieces.length === 5 && pieces[1] === "newuri") { + const methodInUri = pieces[0]; + const initialCode: number = JSON.parse(pieces[2]); + const retry = pieces[3]; + const finalCode: number = JSON.parse(pieces[4]); + if (!isValidRequest(initialCode, retry, finalCode)) return undefined; + if (request.method === "GET") { + if (finalCode === 200) { + if (methodInUri === "post") { + return buildResponse( + request, + 200, + `{ "properties": { "provisioningState": "Succeeded"}, "id": "100", "name": "foo" }` + ); + } else if (methodInUri === "delete") { + return buildResponse(request, 200); + } + } + return buildResponse(request, finalCode); + } + } + } catch (e) { + return undefined; + } + return undefined; +} + +function deleteProvisioning(request: PipelineRequest): PipelineResponse | undefined { + function isValidRequest( + initialCode: number, + initialState: string, + finalState: string, + finalCode: number + ): boolean { + return ( + (initialCode === 202 && + initialState === "Accepted" && + finalState === "Succeeded" && + finalCode === 200) || + (initialCode === 202 && + initialState === "Deleting" && + finalState === "Failed" && + finalCode === 200) || + (initialCode === 202 && + initialState === "Deleting" && + finalState === "Canceled" && + finalCode === 200) + ); + } + const pieces = parseUri(request.url); + try { + if (pieces.length === 6 && pieces[0] === "delete" && pieces[1] === "provisioning") { + const initialCode = JSON.parse(pieces[2]); + const initialState = getPascalCase(pieces[3]); + const finalCode = JSON.parse(pieces[4]); + const finalState = getPascalCase(pieces[5]); + if (!isValidRequest(initialCode, initialState, finalState, finalCode)) return undefined; + if (request.method === "DELETE") { + return { + request: request, + headers: createHttpHeaders({ + Location: `/delete/provisioning/${initialCode}/${initialState.toLowerCase()}/${finalCode}/${finalState.toLowerCase()}`, + "Retry-After": "0" + }), + status: initialCode, + bodyAsText: `{ "properties": { "provisioningState": "${initialState}"}, "id": "100", "name": "foo" }` + }; + } else if (request.method === "GET") { + return buildResponse( + request, + finalCode, + `{ "properties": { "provisioningState": "${finalState}"}, "id": "100", "name": "foo" }` + ); + } + } + } catch (e) { + return undefined; + } + return undefined; +} + +function deleteasyncRetry(request: PipelineRequest): PipelineResponse | undefined { + function isValidRequest(retry: string, finalState: string): boolean { + return ( + (retry === "retry" || retry === "noretry") && + (finalState === "succeeded" || finalState === "canceled" || finalState === "failed") + ); + } + const pieces = parseUri(request.url); + if (pieces[0] !== "deleteasync") { + return undefined; + } + const retry = pieces[1]; + const finalState = pieces[2]; + if (!isValidRequest(retry, finalState)) return undefined; + if (pieces.length === 3) { + const pollingUri = `/deleteasync/${retry}/${finalState.toLowerCase()}/operationResults/200/`; + const headers = createHttpHeaders({ + "Azure-AsyncOperation": pollingUri, + Location: pollingUri + }); + if (retry === "retry") { + headers.set("Retry-After", "0"); + } + return buildResponse(request, 202, undefined, headers); + } else if (pieces[3] === "operationResults") { + try { + const finalCode: number = JSON.parse(pieces[4]); + if (deleteasyncRetry.internalCounter === 0) { + return buildResponse(request, finalCode, `{ "status": "${getPascalCase(finalState)}"}`); + } else { + --deleteasyncRetry.internalCounter; + const pollingUri = `/deleteasync/${retry}/${finalState.toLowerCase()}/operationResults/${finalCode}`; + const headers = createHttpHeaders({ + "Azure-AsyncOperation": pollingUri, + Location: pollingUri + }); + if (retry === "retry") { + headers.set("Retry-After", "0"); + } + return buildResponse(request, 202, `{ "status": "Accepted"}`, headers); + } + } catch (e) { + return undefined; + } + } + return undefined; +} + +// eslint-disable-next-line @azure/azure-sdk/ts-no-namespaces +namespace deleteasyncRetry { + export let internalCounter: number = 1; // eslint-disable-line prefer-const +} + +function putasyncRetry(request: PipelineRequest): PipelineResponse | undefined { + function isValidRequest(retry: string, finalState: string): boolean { + return ( + (retry === "retry" || retry === "noretry") && + (finalState === "succeeded" || finalState === "canceled" || finalState === "failed") + ); + } + const pieces = parseUri(request.url); + if (pieces[0] !== "putasync") { + return undefined; + } + const retry = pieces[1]; + const finalState = pieces[2]; + if (!isValidRequest(retry, finalState)) return undefined; + if (pieces.length === 3) { + const method = request.method; + if (method === "GET") { + return buildResponse( + request, + 200, + `{ "properties": { "provisioningState": "${getPascalCase( + finalState + )}"}, "id": "100", "name": "foo" }` + ); + } else if (method === "PUT") { + const pollingUri = `/putasync/${retry}/${finalState.toLowerCase()}/operationResults/200/`; + const headers = createHttpHeaders({ + "Azure-AsyncOperation": pollingUri, + Location: pollingUri + }); + if (retry === "retry") { + headers.set("Retry-After", "0"); + } + return buildResponse( + request, + 200, + `{ "properties": { "provisioningState": "Accepted"}, "id": "100", "name": "foo" }`, + headers + ); + } + } else if (pieces[3] === "operationResults") { + try { + const finalCode: number = JSON.parse(pieces[4]); + if (putasyncRetry.internalCounter === 0) { + return buildResponse(request, finalCode, `{ "status": "${getPascalCase(finalState)}"}`); + } else { + --putasyncRetry.internalCounter; + const pollingUri = `/putasync/${retry}/${finalState.toLowerCase()}/operationResults/${finalCode}`; + const headers = createHttpHeaders({ + "Azure-AsyncOperation": pollingUri, + Location: pollingUri + }); + if (retry === "retry") { + headers.set("Retry-After", "0"); + } + return buildResponse(request, 202, `{ "status": "Accepted"}`, headers); + } + } catch (e) { + return undefined; + } + } + return undefined; +} + +// eslint-disable-next-line @azure/azure-sdk/ts-no-namespaces +namespace putasyncRetry { + export let internalCounter: number = 1; // eslint-disable-line prefer-const +} + +function postasyncRetry(request: PipelineRequest): PipelineResponse | undefined { + function isValidRequest(retry: string, finalState: string): boolean { + return ( + (retry === "retry" || retry === "noretry") && + (finalState === "succeeded" || finalState === "canceled" || finalState === "failed") + ); + } + const pieces = parseUri(request.url); + if (pieces[0] !== "postasync") { + return undefined; + } + const retry = pieces[1]; + const finalState = pieces[2]; + if (!isValidRequest(retry, finalState)) return undefined; + if (pieces.length === 3) { + const method = request.method; + if (method === "GET") { + return buildResponse( + request, + 200, + finalState === "Failed" + ? `{ "status": "${finalState}", "error": { "code": 500, "message": "Internal Server Error"}}` + : `{ "status": "${finalState}", "properties": { "provisioningState": "Succeeded"}, "id": "100", "name": "foo" }` + ); + } else if (method === "POST") { + const headers = createHttpHeaders({ + "Azure-AsyncOperation": `/postasync/${retry}/${finalState.toLowerCase()}/operationResults/200/`, + Location: `/postasync/${retry}/succeeded/operationResults/foo/200/` + }); + if (retry === "retry") { + headers.set("Retry-After", "0"); + } + return buildResponse( + request, + 202, + `{ "properties": { "provisioningState": "Accepted"}, "id": "100", "name": "foo" }`, + headers + ); + } + } else if (pieces[4] === "foo") { + const finalCode: number = JSON.parse(pieces[5]); + return buildResponse( + request, + finalCode, + `{ "properties": { "provisioningState": "${getPascalCase( + finalState + )}"}, "id": "100", "name": "foo" }` + ); + } else if (pieces[3] === "operationResults") { + try { + const finalCode: number = JSON.parse(pieces[4]); + if (putasyncRetry.internalCounter === 0) { + return buildResponse(request, finalCode, `{ "status": "${getPascalCase(finalState)}"}`); + } else { + --putasyncRetry.internalCounter; + const headers = createHttpHeaders({ + "Azure-AsyncOperation": `/postasync/${retry}/${finalState.toLowerCase()}/operationResults/foo/${finalCode}`, + Location: `/postasync/${retry}/succeeded/operationResults/foo/200/` + }); + if (retry === "retry") { + headers.set("Retry-After", "0"); + } + return buildResponse(request, 202, `{ "status": "Accepted"}`, headers); + } + } catch (e) { + return undefined; + } + } + return undefined; +} + +// eslint-disable-next-line @azure/azure-sdk/ts-no-namespaces +namespace postasyncRetry { + export let internalCounter: number = 1; // eslint-disable-line prefer-const +} + +export const paramRoutes = [ + putBody, + retries, + deleteProvisioning, + deleteasyncRetry, + putasyncRetry, + postasyncRetry +]; diff --git a/sdk/core/core-lro/test/utils/router/routesProcesses.ts b/sdk/core/core-lro/test/utils/router/routesProcesses.ts new file mode 100644 index 000000000000..0115f4fb6ed2 --- /dev/null +++ b/sdk/core/core-lro/test/utils/router/routesProcesses.ts @@ -0,0 +1,661 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { createHttpHeaders, PipelineRequest, PipelineResponse } from "@azure/core-rest-pipeline"; +import { buildProcessMultipleRequests, buildResponse } from "./utils"; + +export function put200Succeeded(request: PipelineRequest): PipelineResponse { + return buildResponse( + request, + 200, + `{ "properties": { "provisioningState": "Succeeded"}, "id": "100", "name": "foo" }` + ); +} + +export function put201Succeeded(request: PipelineRequest): PipelineResponse { + return buildResponse( + request, + 201, + `{ "properties": { "provisioningState": "Succeeded"}, "id": "100", "name": "foo" }` + ); +} + +export function put201SucceededNoState(request: PipelineRequest): PipelineResponse { + return buildResponse(request, 200, `{"id": "100", "name": "foo" }`); +} + +export function deleteNoHeader(request: PipelineRequest): PipelineResponse { + return buildResponse( + request, + 200, + undefined, + createHttpHeaders({ Location: "/delete/noheader/operationresults/123" }) + ); +} + +export function put202Retry200(request: PipelineRequest): PipelineResponse { + return buildResponse( + request, + 202, + undefined, + createHttpHeaders({ Location: "/put/202/retry/operationResults/200" }) + ); +} + +export function put202RetryOperationResults200(request: PipelineRequest): PipelineResponse { + return buildResponse(request, 200, `{"id": "100", "name": "foo" }`); +} + +export function putNoHeader202200(request: PipelineRequest): PipelineResponse { + return buildResponse( + request, + 202, + `{ "properties": { "provisioningState": "Accepted"}, "id": "100", "name": "foo" }`, + createHttpHeaders({ Location: "/put/noheader/operationresults" }) + ); +} + +export function putSubresource202200(request: PipelineRequest): PipelineResponse { + return buildResponse( + request, + 202, + `{ "properties": { "provisioningState": "Accepted"}, "id": "100", "subresource": "sub1" }`, + createHttpHeaders({ Location: "/putsubresource/operationresults" }) + ); +} + +export function putNonresource20200(request: PipelineRequest): PipelineResponse { + return buildResponse( + request, + 202, + undefined, + createHttpHeaders({ Location: "/putnonresource/operationresults" }) + ); +} + +export function delete204Succeeded(request: PipelineRequest): PipelineResponse { + return buildResponse(request, 204); +} + +export function postDoubleHeadersFinalLocationGet(request: PipelineRequest): PipelineResponse { + return { + request: request, + status: 202, + headers: createHttpHeaders({ + "Azure-AsyncOperation": `/LROPostDoubleHeadersFinalLocationGet/asyncOperationUrl`, + Location: `/LROPostDoubleHeadersFinalLocationGet/location` + }) + }; +} + +export function postDoubleHeadersFinalLocationGetAsyncOperationUrl( + request: PipelineRequest +): PipelineResponse { + return buildResponse(request, 200, `{ "status": "succeeded" }`); +} + +export function postDoubleHeadersFinalLocationGetLocation( + request: PipelineRequest +): PipelineResponse { + return buildResponse(request, 200, `{ "id": "100", "name": "foo" }`); +} + +export function postDoubleHeadersFinalAzureHeaderGet(request: PipelineRequest): PipelineResponse { + return buildResponse( + request, + 202, + "", + createHttpHeaders({ + "Azure-AsyncOperation": `/LROPostDoubleHeadersFinalAzureHeaderGet/asyncOperationUrl`, + Location: `/LROPostDoubleHeadersFinalAzureHeaderGet/location` + }) + ); +} + +export function postDoubleHeadersFinalAzureHeaderGetAsyncOperationUrl( + request: PipelineRequest +): PipelineResponse { + return buildResponse(request, 200, `{ "status": "succeeded", "id": "100"}`); +} + +export function postDoubleHeadersFinalAzureHeaderGetLocation( + _request: PipelineRequest +): PipelineResponse { + throw new Error( + "You must NOT do a final GET on Location in LROPostDoubleHeadersFinalAzureHeaderGet" + ); +} + +export function postPayload200(request: PipelineRequest): PipelineResponse { + return buildResponse( + request, + 202, + undefined, + createHttpHeaders({ Location: `/post/payload/200`, "Retry-After": "0" }) + ); +} + +export function getPayload200(request: PipelineRequest): PipelineResponse { + return buildResponse(request, 200, `{"id":"1", "name":"product"}`); +} + +export function postDoubleHeadersFinalAzureHeaderGetDefault( + request: PipelineRequest +): PipelineResponse { + return buildResponse( + request, + 202, + "", + createHttpHeaders({ + Location: "/LROPostDoubleHeadersFinalAzureHeaderGetDefault/location", + "Azure-AsyncOperation": "/LROPostDoubleHeadersFinalAzureHeaderGetDefault/asyncOperationUrl" + }) + ); +} + +export function getDoubleHeadersFinalAzureHeaderGetDefaultAsyncOperationUrl( + request: PipelineRequest +): PipelineResponse { + return buildResponse(request, 200, `{ "status": "succeeded"}`); +} + +export function getDoubleHeadersFinalAzureHeaderGetDefaultLocation( + request: PipelineRequest +): PipelineResponse { + return buildResponse(request, 200, `{ "id": "100", "name": "foo" }`); +} + +export function postList(request: PipelineRequest): PipelineResponse { + return buildResponse( + request, + 200, + undefined, + createHttpHeaders({ + "Azure-AsyncOperation": `/list/pollingGet`, + Location: `/list/finalGet` + }) + ); +} + +export function getListPollingGet(request: PipelineRequest): PipelineResponse { + return buildResponse(request, 200, `{ "status": "Succeeded" }`); +} + +export function getListFinalGet(request: PipelineRequest): PipelineResponse { + return buildResponse(request, 200, `[{ "id": "100", "name": "foo" }]`); +} + +export function putNonresourceAsync202200(request: PipelineRequest): PipelineResponse { + return buildResponse( + request, + 202, + undefined, + createHttpHeaders({ + "Azure-AsyncOperation": `/putnonresourceasync/operationresults/123`, + Location: `somethingBadWhichShouldNotBeUsed` + }) + ); +} + +export function getNonresourceAsync202200(request: PipelineRequest): PipelineResponse { + return buildResponse(request, 200, `{ "name": "sku" , "id": "100" }`); +} + +export const putNonresourceAsyncOperationresults123 = buildProcessMultipleRequests( + (req) => buildResponse(req, 200, `{ "status": "InProgress"}`), + (req) => buildResponse(req, 200, `{ "status": "Succeeded"}`) +); + +export function putasyncNoheader201200(request: PipelineRequest): PipelineResponse { + return buildResponse( + request, + 201, + `{ "properties": { "provisioningState": "Accepted"}, "id": "100", "name": "foo" }`, + createHttpHeaders({ + "Azure-AsyncOperation": `/putasync/noheader/operationresults/123`, + Location: `somethingBadWhichShouldNotBeUsed` + }) + ); +} + +export function getasyncNoheader201200(request: PipelineRequest): PipelineResponse { + return buildResponse( + request, + 200, + `{ "properties": { "provisioningState": "Succeeded"}, "id": "100", "name": "foo" }` + ); +} + +export const putasyncNoheaderOperationresults123 = buildProcessMultipleRequests( + (req) => buildResponse(req, 200, `{ "status": "InProgress"}`), + (req) => buildResponse(req, 200, `{ "status": "Succeeded"}`) +); + +export function putSubresourceAsync202200(request: PipelineRequest): PipelineResponse { + return buildResponse( + request, + 202, + `{ "properties": { "provisioningState": "Accepted"}, "id": "100", "subresource": "sub1" }`, + createHttpHeaders({ + "Azure-AsyncOperation": `/putsubresourceasync/operationresults/123`, + Location: `somethingBadWhichShouldNotBeUsed` + }) + ); +} + +export function getSubresourceAsync202200(request: PipelineRequest): PipelineResponse { + return buildResponse( + request, + 200, + `{ "properties": { "provisioningState": "Succeeded"}, "id": "100", "subresource": "sub1" }` + ); +} + +export const putSubresourceasyncOperationresults123 = buildProcessMultipleRequests( + (req) => buildResponse(req, 200, `{ "status": "InProgress"}`), + (req) => buildResponse(req, 200, `{ "status": "Succeeded"}`) +); + +export function deleteasyncNoheader202204(request: PipelineRequest): PipelineResponse { + return buildResponse( + request, + 202, + undefined, + createHttpHeaders({ + "Azure-AsyncOperation": `/deleteasync/noheader/operationresults/123`, + Location: `somethingBadWhichShouldNotBeUsed` + }) + ); +} + +export const deleteNoHeaderOperationResults = buildProcessMultipleRequests( + (req) => buildResponse(req, 202), + (req) => buildResponse(req, 204) +); +export const putNoHeaderOperationResults = buildProcessMultipleRequests( + (req) => buildResponse(req, 202), + (req) => + buildResponse( + req, + 200, + `{ "properties": { "provisioningState": "Succeeded"}, "id": "100", "name": "foo" }` + ) +); +export const putSubresourceOperationResults = buildProcessMultipleRequests( + (req) => buildResponse(req, 202), + (req) => + buildResponse( + req, + 200, + `{ "properties": { "provisioningState": "Succeeded"}, "id": "100", "subresource": "sub1" }` + ) +); +export const putNonresourceOperationResults = buildProcessMultipleRequests( + (req) => buildResponse(req, 202), + (req) => buildResponse(req, 200, `{ "name": "sku" , "id": "100" }`) +); + +export const deleteasyncNoheaderOperationresults123 = buildProcessMultipleRequests( + (req: PipelineRequest) => buildResponse(req, 200, `{ "status": "InProgress"}`), + (req: PipelineRequest) => buildResponse(req, 200, `{ "status": "Succeeded"}`) +); + +export function nonretryerrorPut400(request: PipelineRequest): PipelineResponse { + return buildResponse(request, 400); +} + +export function nonretryerrorPut201creating400(request: PipelineRequest): PipelineResponse { + return buildResponse( + request, + 201, + `{ "properties": { "provisioningState": "Creating"}, "id": "100", "name": "foo" }` + ); +} + +export function getNonretryerrorPut201creating400(request: PipelineRequest): PipelineResponse { + return buildResponse(request, 400, `{ "message" : "Error from the server" }`); +} + +export function nonretryerrorPut201creating400invalidjson( + request: PipelineRequest +): PipelineResponse { + return buildResponse( + request, + 201, + `{ "properties": { "provisioningState": "Creating"}, "id": "100", "name": "foo" }` + ); +} + +export function getNonretryerrorPut201creating400invalidjson( + request: PipelineRequest +): PipelineResponse { + return buildResponse(request, 400, `{ "message" : "Error from the server" }`); +} + +export function nonretryerrorPutasyncRetry400(request: PipelineRequest): PipelineResponse { + const pollingUri = `/nonretryerror/putasync/retry/failed/operationResults/400`; + return buildResponse( + request, + 200, + `{ "properties": { "provisioningState": "Creating"}, "id": "100", "name": "foo" }`, + createHttpHeaders({ + "Azure-AsyncOperation": pollingUri, + Location: pollingUri, + "Retry-After": "0" + }) + ); +} + +export function nonretryerrorPutasyncRetryFailedOperationResults400( + request: PipelineRequest +): PipelineResponse { + return buildResponse(request, 400); +} + +export function nonretryerrorDelete202retry400(request: PipelineRequest): PipelineResponse { + return buildResponse( + request, + 202, + `{ "properties": { "provisioningState": "Creating"}, "id": "100", "name": "foo" }`, + createHttpHeaders({ + Location: `/nonretryerror/delete/202/retry/400`, + "Retry-After": "0" + }) + ); +} + +export function getNonretryerrorDelete202retry400(request: PipelineRequest): PipelineResponse { + return buildResponse(request, 400, `{ "message" : "Expected bad request message" }`); +} + +export function nonretryerrorDelete400(request: PipelineRequest): PipelineResponse { + return buildResponse(request, 400, `{ "message" : "Expected bad request message" }`); +} + +export function nonretryerrorDeleteasyncRetry400(request: PipelineRequest): PipelineResponse { + const pollingUri = `/nonretryerror/deleteasync/retry/failed/operationResults/400`; + return buildResponse( + request, + 202, + `{ "properties": { "provisioningState": "Creating"}, "id": "100", "name": "foo" }`, + createHttpHeaders({ + "Azure-AsyncOperation": pollingUri, + Location: pollingUri, + "Retry-After": "0" + }) + ); +} + +export function nonretryerrorDeleteasyncRetryFailedOperationResults400( + request: PipelineRequest +): PipelineResponse { + return buildResponse(request, 400, `{ "message" : "Expected bad request message" }`); +} + +export function nonretryerrorPost400(request: PipelineRequest): PipelineResponse { + return buildResponse(request, 400, `{ "message" : "Expected bad request message" }`); +} + +export function nonretryerrorPost202retry400(request: PipelineRequest): PipelineResponse { + return buildResponse( + request, + 202, + `{ "properties": { "provisioningState": "Creating"}, "id": "100", "name": "foo" }`, + createHttpHeaders({ + Location: `/nonretryerror/post/202/retry/400`, + "Retry-After": "0" + }) + ); +} + +export function getNonretryerrorPost202retry400(request: PipelineRequest): PipelineResponse { + return buildResponse(request, 400, `{ "message" : "Expected bad request message" }`); +} + +export function nonretryerrorPostasyncRetry400(request: PipelineRequest): PipelineResponse { + const pollingUri = `/nonretryerror/postasync/retry/failed/operationResults/400`; + return buildResponse( + request, + 202, + `{ "properties": { "provisioningState": "Creating"}, "id": "100", "name": "foo" }`, + createHttpHeaders({ + "Azure-AsyncOperation": pollingUri, + Location: pollingUri, + "Retry-After": "0" + }) + ); +} + +export function nonretryerrorPostasyncRetryFailedOperationResults400( + request: PipelineRequest +): PipelineResponse { + return buildResponse(request, 400, `{ "message" : "Expected bad request message" }`); +} + +export function errorPut201noprovisioningstatepayload(request: PipelineRequest): PipelineResponse { + return buildResponse(request, 201); +} + +export function errorPutasyncRetryNostatuspayload(request: PipelineRequest): PipelineResponse { + const pollingUri = `/error/putasync/retry/failed/operationResults/nostatuspayload`; + return buildResponse( + request, + 200, + `{ "properties": { "provisioningState": "Creating"}, "id": "100", "name": "foo" }`, + createHttpHeaders({ + "Azure-AsyncOperation": pollingUri, + Location: pollingUri, + "Retry-After": "0" + }) + ); +} + +export function errorPutasyncRetryFailedOperationResultsNostatuspayload( + request: PipelineRequest +): PipelineResponse { + return buildResponse(request, 200); +} + +export function errorPutasyncRetryNostatus(request: PipelineRequest): PipelineResponse { + const pollingUri = `/error/putasync/retry/failed/operationResults/nostatus`; + return buildResponse( + request, + 200, + `{ "properties": { "provisioningState": "Creating"}, "id": "100", "name": "foo" }`, + createHttpHeaders({ + "Azure-AsyncOperation": pollingUri, + Location: pollingUri, + "Retry-After": "0" + }) + ); +} + +export function errorPutasyncRetryFailedOperationResultsNostatus( + request: PipelineRequest +): PipelineResponse { + return buildResponse(request, 200, `{ }`); +} + +export function errorDelete204nolocation(request: PipelineRequest): PipelineResponse { + return buildResponse(request, 204); +} + +export function errorDeleteasyncRetryNostatus(request: PipelineRequest): PipelineResponse { + const pollingUri = `/error/deleteasync/retry/failed/operationResults/nostatus`; + return buildResponse( + request, + 202, + undefined, + createHttpHeaders({ + "Azure-AsyncOperation": pollingUri, + Location: pollingUri, + "Retry-After": "0" + }) + ); +} + +export function errorDeleteasyncRetryFailedOperationResultsNostatus( + request: PipelineRequest +): PipelineResponse { + return buildResponse(request, 200, `{ }`); +} + +export function errorPost202nolocation(request: PipelineRequest): PipelineResponse { + return buildResponse(request, 202); +} + +export function errorPostasyncRetryNopayload(request: PipelineRequest): PipelineResponse { + const pollingUri = `/error/postasync/retry/failed/operationResults/nopayload`; + return buildResponse( + request, + 202, + undefined, + createHttpHeaders({ + "Azure-AsyncOperation": pollingUri, + Location: pollingUri, + "Retry-After": "0" + }) + ); +} + +export function errorPostasyncRetryFailedOperationResultsNopayload( + request: PipelineRequest +): PipelineResponse { + return buildResponse(request, 200); +} + +export function errorPut200invalidjson(request: PipelineRequest): PipelineResponse { + return buildResponse( + request, + 200, + `{ "properties": { "provisioningState": "Creating"}, "id": "100", "name": "foo"` + ); +} + +export function errorPutasyncRetryInvalidheader(request: PipelineRequest): PipelineResponse { + const pollingUri = `/foo`; + return buildResponse( + request, + 200, + `{ "properties": { "provisioningState": "Creating"}, "id": "100", "name": "foo" }`, + createHttpHeaders({ + "Azure-AsyncOperation": pollingUri, + Location: pollingUri, + "Retry-After": "/bar" + }) + ); +} + +export function errorPutasyncRetryInvalidjsonpolling(request: PipelineRequest): PipelineResponse { + const pollingUri = `/error/putasync/retry/failed/operationResults/invalidjsonpolling`; + return buildResponse( + request, + 200, + `{ "properties": { "provisioningState": "Creating"}, "id": "100", "name": "foo" }`, + createHttpHeaders({ + "Azure-AsyncOperation": pollingUri, + Location: pollingUri, + "Retry-After": "0" + }) + ); +} + +export function errorPutasyncRetryFailedOperationResultsInvalidjsonpolling( + request: PipelineRequest +): PipelineResponse { + return buildResponse(request, 200, `{ "status": "Accepted"`); +} + +export function errorDelete202RetryInvalidheader(request: PipelineRequest): PipelineResponse { + return buildResponse( + request, + 202, + undefined, + createHttpHeaders({ + Location: `/foo`, + "Retry-After": "/bar" + }) + ); +} + +export function errorDeleteasyncRetryInvalidheader(request: PipelineRequest): PipelineResponse { + const pollingUri = `/foo`; + return buildResponse( + request, + 202, + undefined, + createHttpHeaders({ + "Azure-AsyncOperation": pollingUri, + Location: pollingUri, + "Retry-After": "/bar" + }) + ); +} + +export function errorDeleteasyncRetryInvalidjsonpolling( + request: PipelineRequest +): PipelineResponse { + const pollingUri = `/error/deleteasync/retry/failed/operationResults/invalidjsonpolling`; + return buildResponse( + request, + 202, + undefined, + createHttpHeaders({ + "Azure-AsyncOperation": pollingUri, + Location: pollingUri, + "Retry-After": "0" + }) + ); +} + +export function errorDeleteasyncRetryFailedOperationResultsInvalidjsonpolling( + request: PipelineRequest +): PipelineResponse { + return buildResponse(request, 200, `{ "status": "Accepted"`); +} + +export function errorPost202RetryInvalidheader(request: PipelineRequest): PipelineResponse { + return buildResponse( + request, + 202, + undefined, + createHttpHeaders({ + Location: `/foo`, + "Retry-After": "/bar" + }) + ); +} + +export function errorPostasyncRetryInvalidheader(request: PipelineRequest): PipelineResponse { + const pollingUri = `/foo`; + return buildResponse( + request, + 202, + undefined, + createHttpHeaders({ + "Azure-AsyncOperation": pollingUri, + Location: pollingUri, + "Retry-After": "/bar" + }) + ); +} + +export function errorPostasyncRetryInvalidjsonpolling(request: PipelineRequest): PipelineResponse { + const pollingUri = `/error/postasync/retry/failed/operationResults/invalidjsonpolling`; + return buildResponse( + request, + 202, + undefined, + createHttpHeaders({ + "Azure-AsyncOperation": pollingUri, + Location: pollingUri, + "Retry-After": "/bar" + }) + ); +} + +export function errorPostasyncRetryFailedOperationResultsInvalidjsonpolling( + request: PipelineRequest +): PipelineResponse { + return buildResponse(request, 200, `{ "status": "Accepted"`); +} diff --git a/sdk/core/core-lro/test/utils/router/routesTable.ts b/sdk/core/core-lro/test/utils/router/routesTable.ts new file mode 100644 index 000000000000..1a072c3e26c2 --- /dev/null +++ b/sdk/core/core-lro/test/utils/router/routesTable.ts @@ -0,0 +1,390 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { PipelineRequest, PipelineResponse } from "@azure/core-rest-pipeline"; +import { + delete204Succeeded, + deleteasyncNoheader202204, + deleteasyncNoheaderOperationresults123, + deleteNoHeader, + deleteNoHeaderOperationResults, + errorDelete202RetryInvalidheader, + errorDelete204nolocation, + errorDeleteasyncRetryFailedOperationResultsInvalidjsonpolling, + errorDeleteasyncRetryFailedOperationResultsNostatus, + errorDeleteasyncRetryInvalidheader, + errorDeleteasyncRetryInvalidjsonpolling, + errorDeleteasyncRetryNostatus, + errorPost202nolocation, + errorPost202RetryInvalidheader, + errorPostasyncRetryFailedOperationResultsInvalidjsonpolling, + errorPostasyncRetryFailedOperationResultsNopayload, + errorPostasyncRetryInvalidheader, + errorPostasyncRetryInvalidjsonpolling, + errorPostasyncRetryNopayload, + errorPut200invalidjson, + errorPut201noprovisioningstatepayload, + errorPutasyncRetryFailedOperationResultsInvalidjsonpolling, + errorPutasyncRetryFailedOperationResultsNostatus, + errorPutasyncRetryFailedOperationResultsNostatuspayload, + errorPutasyncRetryInvalidheader, + errorPutasyncRetryInvalidjsonpolling, + errorPutasyncRetryNostatus, + errorPutasyncRetryNostatuspayload, + getasyncNoheader201200, + getDoubleHeadersFinalAzureHeaderGetDefaultAsyncOperationUrl, + getDoubleHeadersFinalAzureHeaderGetDefaultLocation, + getListFinalGet, + getListPollingGet, + getNonresourceAsync202200, + getNonretryerrorDelete202retry400, + getNonretryerrorPost202retry400, + getNonretryerrorPut201creating400, + getNonretryerrorPut201creating400invalidjson, + getPayload200, + getSubresourceAsync202200, + nonretryerrorDelete202retry400, + nonretryerrorDelete400, + nonretryerrorDeleteasyncRetry400, + nonretryerrorDeleteasyncRetryFailedOperationResults400, + nonretryerrorPost202retry400, + nonretryerrorPost400, + nonretryerrorPostasyncRetry400, + nonretryerrorPostasyncRetryFailedOperationResults400, + nonretryerrorPut201creating400, + nonretryerrorPut201creating400invalidjson, + nonretryerrorPut400, + nonretryerrorPutasyncRetry400, + nonretryerrorPutasyncRetryFailedOperationResults400, + postDoubleHeadersFinalAzureHeaderGet, + postDoubleHeadersFinalAzureHeaderGetAsyncOperationUrl, + postDoubleHeadersFinalAzureHeaderGetDefault, + postDoubleHeadersFinalAzureHeaderGetLocation, + postDoubleHeadersFinalLocationGet, + postDoubleHeadersFinalLocationGetAsyncOperationUrl, + postDoubleHeadersFinalLocationGetLocation, + postList, + postPayload200, + put200Succeeded, + put201Succeeded, + put201SucceededNoState, + put202Retry200, + put202RetryOperationResults200, + putasyncNoheader201200, + putasyncNoheaderOperationresults123, + putNoHeader202200, + putNoHeaderOperationResults, + putNonresource20200, + putNonresourceAsync202200, + putNonresourceAsyncOperationresults123, + putNonresourceOperationResults, + putSubresource202200, + putSubresourceAsync202200, + putSubresourceasyncOperationresults123, + putSubresourceOperationResults +} from "./routesProcesses"; + +interface LroRoute { + method: string; + path: string; + process: (request: PipelineRequest) => PipelineResponse; +} + +export const routes: LroRoute[] = [ + { method: "PUT", path: "/put/200/succeeded", process: put200Succeeded }, + { method: "PUT", path: "/put/201/succeeded", process: put201Succeeded }, + { method: "PUT", path: "/put/200/succeeded/nostate", process: put201SucceededNoState }, + { method: "DELETE", path: "/delete/noheader", process: deleteNoHeader }, + { + method: "GET", + path: "/delete/noheader/operationresults/123", + process: deleteNoHeaderOperationResults + }, + { method: "PUT", path: "/put/202/retry/200", process: put202Retry200 }, + { + method: "GET", + path: "/put/202/retry/operationResults/200", + process: put202RetryOperationResults200 + }, + { method: "PUT", path: "/put/noheader/202/200", process: putNoHeader202200 }, + { method: "GET", path: "/put/noheader/operationresults", process: putNoHeaderOperationResults }, + { method: "PUT", path: "/putsubresource/202/200", process: putSubresource202200 }, + { + method: "GET", + path: "/putsubresource/operationresults", + process: putSubresourceOperationResults + }, + { method: "PUT", path: "/putnonresource/202/200", process: putNonresource20200 }, + { + method: "GET", + path: "/putnonresource/operationresults", + process: putNonresourceOperationResults + }, + { method: "DELETE", path: "/delete/204/succeeded", process: delete204Succeeded }, + { + method: "POST", + path: "/LROPostDoubleHeadersFinalLocationGet", + process: postDoubleHeadersFinalLocationGet + }, + { + method: "GET", + path: "/LROPostDoubleHeadersFinalLocationGet/asyncOperationUrl", + process: postDoubleHeadersFinalLocationGetAsyncOperationUrl + }, + { + method: "GET", + path: "/LROPostDoubleHeadersFinalLocationGet/location", + process: postDoubleHeadersFinalLocationGetLocation + }, + { + method: "POST", + path: "/LROPostDoubleHeadersFinalAzureHeaderGet", + process: postDoubleHeadersFinalAzureHeaderGet + }, + { + method: "GET", + path: "/LROPostDoubleHeadersFinalAzureHeaderGet/asyncOperationUrl", + process: postDoubleHeadersFinalAzureHeaderGetAsyncOperationUrl + }, + { + method: "GET", + path: "/LROPostDoubleHeadersFinalAzureHeaderGet/location", + process: postDoubleHeadersFinalAzureHeaderGetLocation + }, + { method: "POST", path: "/post/payload/200", process: postPayload200 }, + { method: "GET", path: "/post/payload/200", process: getPayload200 }, + { + method: "POST", + path: "/LROPostDoubleHeadersFinalAzureHeaderGetDefault", + process: postDoubleHeadersFinalAzureHeaderGetDefault + }, + { + method: "GET", + path: "/LROPostDoubleHeadersFinalAzureHeaderGetDefault/asyncOperationUrl", + process: getDoubleHeadersFinalAzureHeaderGetDefaultAsyncOperationUrl + }, + { + method: "GET", + path: "/LROPostDoubleHeadersFinalAzureHeaderGetDefault/location", + process: getDoubleHeadersFinalAzureHeaderGetDefaultLocation + }, + { method: "POST", path: "/list", process: postList }, + { method: "GET", path: "/list/pollingGet", process: getListPollingGet }, + { method: "GET", path: "/list/finalGet", process: getListFinalGet }, + { method: "PUT", path: "/putnonresourceasync/202/200", process: putNonresourceAsync202200 }, + { + method: "GET", + path: "/putnonresourceasync/operationresults/123", + process: putNonresourceAsyncOperationresults123 + }, + { method: "GET", path: "/putnonresourceasync/202/200", process: getNonresourceAsync202200 }, + { method: "PUT", path: "/putasync/noheader/201/200", process: putasyncNoheader201200 }, + { method: "GET", path: "/putasync/noheader/201/200", process: getasyncNoheader201200 }, + { + method: "GET", + path: "/putasync/noheader/operationresults/123", + process: putasyncNoheaderOperationresults123 + }, + { method: "PUT", path: "/putsubresourceasync/202/200", process: putSubresourceAsync202200 }, + { method: "GET", path: "/putsubresourceasync/202/200", process: getSubresourceAsync202200 }, + { + method: "GET", + path: "/putsubresourceasync/operationresults/123", + process: putSubresourceasyncOperationresults123 + }, + { method: "DELETE", path: "/deleteasync/noheader/202/204", process: deleteasyncNoheader202204 }, + { + method: "GET", + path: "/deleteasync/noheader/operationresults/123", + process: deleteasyncNoheaderOperationresults123 + }, + { method: "PUT", path: "/nonretryerror/put/400", process: nonretryerrorPut400 }, + { + method: "PUT", + path: "/nonretryerror/put/201/creating/400", + process: nonretryerrorPut201creating400 + }, + { + method: "GET", + path: "/nonretryerror/put/201/creating/400", + process: getNonretryerrorPut201creating400 + }, + { + method: "PUT", + path: "/nonretryerror/put/201/creating/400/invalidjson", + process: nonretryerrorPut201creating400invalidjson + }, + { + method: "GET", + path: "/nonretryerror/put/201/creating/400/invalidjson", + process: getNonretryerrorPut201creating400invalidjson + }, + { + method: "PUT", + path: "/nonretryerror/putasync/retry/400", + process: nonretryerrorPutasyncRetry400 + }, + { + method: "GET", + path: "/nonretryerror/putasync/retry/failed/operationResults/400", + process: nonretryerrorPutasyncRetryFailedOperationResults400 + }, + { + method: "DELETE", + path: "/nonretryerror/delete/202/retry/400", + process: nonretryerrorDelete202retry400 + }, + { + method: "GET", + path: "/nonretryerror/delete/202/retry/400", + process: getNonretryerrorDelete202retry400 + }, + { method: "DELETE", path: "/nonretryerror/delete/400", process: nonretryerrorDelete400 }, + { + method: "DELETE", + path: "/nonretryerror/deleteasync/retry/400", + process: nonretryerrorDeleteasyncRetry400 + }, + { + method: "GET", + path: "/nonretryerror/deleteasync/retry/failed/operationResults/400", + process: nonretryerrorDeleteasyncRetryFailedOperationResults400 + }, + { method: "POST", path: "/nonretryerror/post/400", process: nonretryerrorPost400 }, + { + method: "POST", + path: "/nonretryerror/post/202/retry/400", + process: nonretryerrorPost202retry400 + }, + { + method: "GET", + path: "/nonretryerror/post/202/retry/400", + process: getNonretryerrorPost202retry400 + }, + { + method: "POST", + path: "/nonretryerror/postasync/retry/400", + process: nonretryerrorPostasyncRetry400 + }, + { + method: "GET", + path: "/nonretryerror/postasync/retry/failed/operationResults/400", + process: nonretryerrorPostasyncRetryFailedOperationResults400 + }, + { + method: "PUT", + path: "/error/put/201/noprovisioningstatepayload", + process: errorPut201noprovisioningstatepayload + }, + { + method: "PUT", + path: "/error/putasync/retry/nostatuspayload", + process: errorPutasyncRetryNostatuspayload + }, + { + method: "GET", + path: "/error/putasync/retry/nostatuspayload", + process: errorPutasyncRetryFailedOperationResultsNostatuspayload + }, + { + method: "GET", + path: "/error/putasync/retry/failed/operationResults/nostatuspayload", + process: errorPutasyncRetryFailedOperationResultsNostatuspayload + }, + { method: "PUT", path: "/error/putasync/retry/nostatus", process: errorPutasyncRetryNostatus }, + { + method: "GET", + path: "/error/putasync/retry/nostatus", + process: errorPutasyncRetryFailedOperationResultsNostatus + }, + { + method: "GET", + path: "/error/putasync/retry/failed/operationResults/nostatus", + process: errorPutasyncRetryFailedOperationResultsNostatus + }, + { method: "DELETE", path: "/error/delete/204/nolocation", process: errorDelete204nolocation }, + { + method: "DELETE", + path: "/error/deleteasync/retry/nostatus", + process: errorDeleteasyncRetryNostatus + }, + { + method: "GET", + path: "/error/deleteasync/retry/failed/operationResults/nostatus", + process: errorDeleteasyncRetryFailedOperationResultsNostatus + }, + { method: "POST", path: "/error/post/202/nolocation", process: errorPost202nolocation }, + { + method: "POST", + path: "/error/postasync/retry/nopayload", + process: errorPostasyncRetryNopayload + }, + { + method: "GET", + path: "/error/postasync/retry/failed/operationResults/nopayload", + process: errorPostasyncRetryFailedOperationResultsNopayload + }, + { + method: "GET", + path: "/error/postasync/retry/failed/operationResults/nopayload", + process: errorPostasyncRetryFailedOperationResultsNopayload + }, + { method: "PUT", path: "/error/put/200/invalidjson", process: errorPut200invalidjson }, + { + method: "PUT", + path: "/error/putasync/retry/invalidheader", + process: errorPutasyncRetryInvalidheader + }, + { + method: "PUT", + path: "/error/putasync/retry/invalidjsonpolling", + process: errorPutasyncRetryInvalidjsonpolling + }, + { + method: "GET", + path: "/error/putasync/retry/failed/operationResults/invalidjsonpolling", + process: errorPutasyncRetryFailedOperationResultsInvalidjsonpolling + }, + { + method: "DELETE", + path: "/error/delete/202/retry/invalidheader", + process: errorDelete202RetryInvalidheader + }, + { + method: "DELETE", + path: "/error/deleteasync/retry/invalidheader", + process: errorDeleteasyncRetryInvalidheader + }, + { + method: "DELETE", + path: "/error/deleteasync/retry/invalidjsonpolling", + process: errorDeleteasyncRetryInvalidjsonpolling + }, + { + method: "GET", + path: "/error/deleteasync/retry/failed/operationResults/invalidjsonpolling", + process: errorDeleteasyncRetryFailedOperationResultsInvalidjsonpolling + }, + { + method: "POST", + path: "/error/post/202/retry/invalidheader", + process: errorPost202RetryInvalidheader + }, + { + method: "POST", + path: "/error/postasync/retry/invalidheader", + process: errorPostasyncRetryInvalidheader + }, + { + method: "POST", + path: "/error/postasync/retry/invalidjsonpolling", + process: errorPostasyncRetryInvalidjsonpolling + }, + { + method: "GET", + path: "/error/postasync/retry/failed/operationResults/invalidjsonpolling", + process: errorPostasyncRetryFailedOperationResultsInvalidjsonpolling + } +]; + +export const routesTable = new Map(routes.map((route) => [route.path, route])); diff --git a/sdk/core/core-lro/test/utils/router/utils.ts b/sdk/core/core-lro/test/utils/router/utils.ts new file mode 100644 index 000000000000..75aa90e9e77f --- /dev/null +++ b/sdk/core/core-lro/test/utils/router/utils.ts @@ -0,0 +1,60 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { + createHttpHeaders, + HttpHeaders, + PipelineRequest, + PipelineResponse +} from "@azure/core-rest-pipeline"; + +export function buildResponse( + request: PipelineRequest, + status: number, + body?: string, + headers?: HttpHeaders +): PipelineResponse { + return { + request: request, + headers: headers ? headers : createHttpHeaders(), + status: status, + bodyAsText: body + }; +} + +export function buildProcessMultipleRequests( + processRequest: (request: PipelineRequest) => PipelineResponse, + processLastRequest: (request: PipelineRequest) => PipelineResponse, + count: number = 1 +): (request: PipelineRequest) => PipelineResponse { + let internalCounter = count; + return (request: PipelineRequest) => { + if (internalCounter === 0) { + return processLastRequest(request); + } else { + --internalCounter; + return processRequest(request); + } + }; +} + +export function applyScenarios( + request: PipelineRequest, + scenarios: ((request: PipelineRequest) => PipelineResponse | undefined)[] +): PipelineResponse | undefined { + for (const scenario of scenarios) { + const response = scenario(request); + if (response) { + return response; + } + } + return undefined; +} + +export function getPascalCase(inString: string): string { + return "" + inString.substring(0, 1).toUpperCase() + inString.substring(1); +} + +export function parseUri(path: string): string[] { + return path.substr(1).split("/"); +}