Repository: Azure/azure-functions-durable-js
Pull request #97 (2019-07-25 07:26:05)

Durable HTTP for JavaScript
This is the initial implementation for JavaScript support of the [Durable HTTP](https://github.com/Azure/azure-functions-durable-extension/issues/469) feature. I was interested in learning how to contribute to Durable JS, and figured this would be a good opportunity.

Things that still need to be figured out:
- [x] How to incorporate managed identities
- [x] How to incorporate `DurableHttpResponse`
- [ ] ~~What is an appropriate version number for the NPM package since this depends on Durable 2.0?~~ We'll probably just add this as a minor release and document that it requires v2 of the extension.

The first iteration was validated using the following JS orchestrator function:

```js
const df = require("durable-functions");

module.exports = df.orchestrator(function*(context){
    const req = new df.DurableHttpRequest('GET', 'https://httpstat.us/200', null);
    const res = yield context.df.callHttp(req);
    return res;
});
```

FYI @mhoeger, @bachuv, @ConnorMcMahon, we can use this as a starting point for the hackathon project.


Patch:
diff --git a/package.json b/package.json
index fe6ec48..61bcfb0 100644
--- a/package.json
+++ b/package.json
@@ -4,7 +4,7 @@
   "description": "Durable Functions library for Node.js Azure Functions",
   "license": "MIT",
   "repository": "github:Azure/azure-functions-durable-js",
-  "author": "kashimiz",
+  "author": "Microsoft Corporation",
   "keywords": [
     "azure-functions"
   ],
diff --git a/src/actions/actiontype.ts b/src/actions/actiontype.ts
index 87f9eb8..90b0206 100644
--- a/src/actions/actiontype.ts
+++ b/src/actions/actiontype.ts
@@ -15,4 +15,6 @@ export enum ActionType {
     ContinueAsNew = 4,
     CreateTimer = 5,
     WaitForExternalEvent = 6,
+    CallEntity = 7,
+    CallHttp = 8,
 }
diff --git a/src/actions/callhttpaction.ts b/src/actions/callhttpaction.ts
new file mode 100644
index 0000000..83913fe
--- /dev/null
+++ b/src/actions/callhttpaction.ts
@@ -0,0 +1,10 @@
+import { ActionType, DurableHttpRequest, IAction, Utils } from "../classes";
+
+/** @hidden */
+export class CallHttpAction implements IAction {
+    public readonly actionType: ActionType = ActionType.CallHttp;
+
+    constructor(
+        public readonly httpRequest: DurableHttpRequest,
+    ) { }
+}
diff --git a/src/classes.ts b/src/classes.ts
index 4a109b7..9f6189c 100644
--- a/src/classes.ts
+++ b/src/classes.ts
@@ -14,6 +14,7 @@ export { CallActivityAction } from "./actions/callactivityaction";
 export { CallActivityWithRetryAction } from "./actions/callactivitywithretryaction";
 export { CallSubOrchestratorAction } from "./actions/callsuborchestratoraction";
 export { CallSubOrchestratorWithRetryAction } from "./actions/callsuborchestratorwithretryaction";
+export { CallHttpAction } from "./actions/callhttpaction";
 export { ContinueAsNewAction } from "./actions/continueasnewaction";
 export { CreateTimerAction } from "./actions/createtimeraction";
 export { WaitForExternalEventAction } from "./actions/waitforexternaleventaction";
@@ -57,3 +58,6 @@ export { OrchestrationRuntimeStatus } from "./orchestrationruntimestatus";
 export { PurgeHistoryResult } from "./purgehistoryresult";
 
 export { GuidManager } from "./guidmanager";
+
+export { DurableHttpRequest } from "./durablehttprequest";
+export { DurableHttpResponse } from "./durablehttpresponse";
diff --git a/src/durablehttprequest.ts b/src/durablehttprequest.ts
new file mode 100644
index 0000000..e07e71b
--- /dev/null
+++ b/src/durablehttprequest.ts
@@ -0,0 +1,32 @@
+import { TokenSource } from "./tokensource";
+
+/**
+ * Data structure representing a durable HTTP request.
+ */
+export class DurableHttpRequest {
+
+    /**
+     * Creates a new instance of DurableHttpRequest with the
+     * specified parameters.
+     *
+     * @param method The HTTP request method.
+     * @param uri The HTTP request URL.
+     * @param content The HTTP request content.
+     * @param headers The HTTP request headers.
+     * @param tokenSource The source of OAuth tokens to add to the request.
+     */
+    constructor(
+        /** The HTTP request method. */
+        public readonly method: string,
+        /** The HTTP request URL. */
+        public readonly uri: string,
+        /** The HTTP request content. */
+        public readonly content?: string,
+        /** The HTTP request headers. */
+        public readonly headers?: {
+            [key: string]: string;
+        },
+        /** The source of OAuth token to add to the request. */
+        public readonly tokenSource?: TokenSource,
+    ) { }
+}
diff --git a/src/durablehttpresponse.ts b/src/durablehttpresponse.ts
new file mode 100644
index 0000000..d7accb7
--- /dev/null
+++ b/src/durablehttpresponse.ts
@@ -0,0 +1,25 @@
+/**
+ * Data structure representing a durable HTTP response.
+ */
+export class DurableHttpResponse {
+    /**
+     * Creates a new instance of DurableHttpResponse with the
+     * specified parameters.
+     *
+     * @param statusCode The HTTP response status code.
+     * @param content The HTTP response content.
+     * @param headers The HTTP response headers.
+     */
+    constructor(
+        /** The HTTP response status code. */
+        public statusCode: number,
+
+        /** The HTTP response content. */
+        public content?: string,
+
+        /** The HTTP response headers. */
+        public headers?: {
+            [key: string]: string;
+        },
+    ) { }
+}
diff --git a/src/durableorchestrationclient.ts b/src/durableorchestrationclient.ts
index 745a7b8..3ffb87a 100644
--- a/src/durableorchestrationclient.ts
+++ b/src/durableorchestrationclient.ts
@@ -16,12 +16,12 @@ import { Constants, DurableOrchestrationStatus, HttpCreationPayload, HttpManagem
  * ```javascript
  * const df = require("durable-functions");
  *
- * module.exports = df.orchestrator(function*(context) {
+ * module.exports = async function (context, req) {
  *     const client = df.getClient(context);
  *     const instanceId = await client.startNew(req.params.functionName, undefined, req.body);
  *
- *     return client.createCheckStatusResponse(instanceId);
- * });
+ *     return client.createCheckStatusResponse(req, instanceId);
+ * };
  * ```
  */
 export function getClient(context: unknown): DurableOrchestrationClient {
diff --git a/src/durableorchestrationcontext.ts b/src/durableorchestrationcontext.ts
index 66fc786..a87130c 100644
--- a/src/durableorchestrationcontext.ts
+++ b/src/durableorchestrationcontext.ts
@@ -1,4 +1,4 @@
-import { Constants, ITaskMethods, RetryOptions, Task, TimerTask } from "./classes";
+import { DurableHttpRequest, ITaskMethods, RetryOptions, Task, TimerTask } from "./classes";
 
 /**
  * Parameter data for orchestration bindings that can be used to schedule
@@ -112,6 +112,15 @@ export class DurableOrchestrationContext {
             throw new Error("This is a placeholder.");
         }
 
+    /**
+     * Schedules a durable HTTP call to the specified endpoint.
+     *
+     * @param req The durable HTTP request to schedule.
+     */
+    public callHttp(req: DurableHttpRequest): Task {
+        throw new Error("This is a placeholder");
+    }
+
     /**
      * Restarts the orchestration by clearing its history.
      *
diff --git a/src/index.ts b/src/index.ts
index bceb9d9..fbfc07b 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,9 +1,13 @@
-import { OrchestrationRuntimeStatus, RetryOptions } from "./classes";
+import { DurableHttpRequest, DurableHttpResponse, OrchestrationRuntimeStatus, RetryOptions } from "./classes";
 import { getClient } from "./durableorchestrationclient";
 import { orchestrator } from "./shim";
+import { ManagedIdentityTokenSource } from "./tokensource";
 
 export {
+    DurableHttpRequest,
+    DurableHttpResponse,
     getClient,
+    ManagedIdentityTokenSource,
     orchestrator,
     OrchestrationRuntimeStatus,
     RetryOptions,
diff --git a/src/orchestrator.ts b/src/orchestrator.ts
index ee1b91a..19f8b5f 100644
--- a/src/orchestrator.ts
+++ b/src/orchestrator.ts
@@ -1,6 +1,6 @@
 import * as debug from "debug";
-import { CallActivityAction, CallActivityWithRetryAction, CallSubOrchestratorAction,
-    CallSubOrchestratorWithRetryAction, ContinueAsNewAction, CreateTimerAction,
+import { CallActivityAction, CallActivityWithRetryAction, CallHttpAction, CallSubOrchestratorAction,
+    CallSubOrchestratorWithRetryAction, ContinueAsNewAction, CreateTimerAction, DurableHttpRequest,
     DurableOrchestrationBindingInfo, EventRaisedEvent, GuidManager, HistoryEvent,
     HistoryEventType, IAction, IFunctionContext, OrchestratorState, RetryOptions,
     SubOrchestrationInstanceCompletedEvent, SubOrchestrationInstanceCreatedEvent,
@@ -8,6 +8,7 @@ import { CallActivityAction, CallActivityWithRetryAction, CallSubOrchestratorAct
     TaskScheduledEvent, TaskSet, TimerCreatedEvent, TimerFiredEvent, TimerTask,
     Utils, WaitForExternalEventAction,
 } from "./classes";
+import { TokenSource } from "./tokensource";
 
 /** @hidden */
 const log = debug("orchestrator");
@@ -55,6 +56,7 @@ export class Orchestrator {
             callActivityWithRetry: this.callActivityWithRetry.bind(this, state),
             callSubOrchestrator: this.callSubOrchestrator.bind(this, state),
             callSubOrchestratorWithRetry: this.callSubOrchestratorWithRetry.bind(this, state),
+            callHttp: this.callHttp.bind(this, state),
             continueAsNew: this.continueAsNew.bind(this, state),
             createTimer: this.createTimer.bind(this, state),
             getInput: this.getInput.bind(this, input),
@@ -307,6 +309,49 @@ export class Orchestrator {
         );
     }
 
+    private callHttp(
+        state: HistoryEvent[],
+        method: string,
+        uri: string,
+        content?: string | object,
+        headers?: { [key: string]: string },
+        tokenSource?: TokenSource) {
+        if (content && typeof content !== "string") {
+            content = JSON.stringify(content);
+        }
+
+        const req = new DurableHttpRequest(method, uri, content as string, headers, tokenSource);
+        const newAction = new CallHttpAction(req);
+
+        // callHttp is internally implemented as a well-known activity function
+        const httpScheduled = this.findTaskScheduled(state, "BuiltIn::HttpActivity");
+        const httpCompleted = this.findTaskCompleted(state, httpScheduled);
+        const httpFailed = this.findTaskFailed(state, httpScheduled);
+        this.setProcessed([httpScheduled, httpCompleted, httpFailed]);
+
+        if (httpCompleted) {
+            const result = this.parseHistoryEvent(httpCompleted);
+
+            return new Task(true, false, newAction, result, httpCompleted.Timestamp, httpCompleted.TaskScheduledId);
+        } else if (httpFailed) {
+            return new Task(
+                true,
+                true,
+                newAction,
+                httpFailed.Reason,
+                httpFailed.Timestamp,
+                httpFailed.TaskScheduledId,
+                new Error(httpFailed.Reason),
+            );
+        } else {
+            return new Task(
+                false,
+                false,
+                newAction,
+            );
+        }
+    }
+
     private continueAsNew(state: HistoryEvent[], input: unknown): Task {
         const newAction = new ContinueAsNewAction(input);
 
diff --git a/src/tokensource.ts b/src/tokensource.ts
new file mode 100644
index 0000000..928bd0a
--- /dev/null
+++ b/src/tokensource.ts
@@ -0,0 +1,36 @@
+/**
+ * Token Source implementation for [Azure Managed Identities](https://docs.microsoft.com/azure/active-directory/managed-identities-azure-resources/overview).
+ *
+ * @example Get a list of Azure Subscriptions by calling the Azure Resource Manager HTTP API.
+ * ```javascript
+ * const df = require("durable-functions");
+ *
+ * module.exports = df.orchestrator(function*(context) {
+ *     return yield context.df.callHttp(
+ *         "GET",
+ *         "https://management.azure.com/subscriptions?api-version=2019-06-01",
+ *         undefined,
+ *         undefined,
+ *         df.ManagedIdentityTokenSource("https://management.core.windows.net"));
+ * });
+ * ```
+ */
+export class ManagedIdentityTokenSource {
+    /** @hidden */
+    public kind: "AzureManagedIdentity";
+
+    /**
+     * Returns a `ManagedIdentityTokenSource` object.
+     * @param resource The Azure Active Directory resource identifier of the web API being invoked.
+     */
+    constructor(
+        /**
+         * The Azure Active Directory resource identifier of the web API being invoked.
+         * For example, `https://management.core.windows.net/` or `https://graph.microsoft.com/`.
+         */
+        public readonly resource: string,
+    ) { }
+}
+
+// Over time we will likely add more implementations
+export type TokenSource = ManagedIdentityTokenSource;


Test patch:
diff --git a/test/integration/orchestrator-spec.ts b/test/integration/orchestrator-spec.ts
index 9c9a82c..0d2201e 100644
--- a/test/integration/orchestrator-spec.ts
+++ b/test/integration/orchestrator-spec.ts
@@ -2,11 +2,12 @@ import { expect } from "chai";
 import "mocha";
 import * as moment from "moment";
 import * as uuidv1 from "uuid/v1";
+import { ManagedIdentityTokenSource } from "../../src";
 import {
-    CallActivityAction, CallActivityWithRetryAction, CallSubOrchestratorAction,
+    CallActivityAction, CallActivityWithRetryAction, CallHttpAction, CallSubOrchestratorAction,
     CallSubOrchestratorWithRetryAction, Constants, ContinueAsNewAction, CreateTimerAction,
-    DurableOrchestrationBindingInfo, DurableOrchestrationContext, IOrchestratorState,
-    OrchestratorState, RetryOptions, WaitForExternalEventAction,
+    DurableHttpRequest, DurableHttpResponse, DurableOrchestrationBindingInfo, DurableOrchestrationContext,
+    IOrchestratorState, OrchestratorState, RetryOptions, WaitForExternalEventAction,
 } from "../../src/classes";
 import { TestHistories } from "../testobjects/testhistories";
 import { TestOrchestrations } from "../testobjects/TestOrchestrations";
@@ -477,7 +478,7 @@ describe("Orchestrator", () => {
             );
         });
 
-        it("schedules an activity funtion if < max attempts", async () => {
+        it("schedules an activity function if < max attempts", async () => {
             const orchestrator = TestOrchestrations.SayHelloWithActivityRetry;
             const name = "World";
             const retryOptions = new RetryOptions(10000, 2);
@@ -608,6 +609,102 @@ describe("Orchestrator", () => {
         });
     });
 
+    describe("callHttp()", () => {
+        it("schedules simple HTTP GET calls", async () => {
+            const orchestrator = TestOrchestrations.SendHttpRequest;
+            const req = new DurableHttpRequest("GET", "https://bing.com");
+            const mockContext = new MockContext({
+                context: new DurableOrchestrationBindingInfo(
+                    TestHistories.GetOrchestratorStart(
+                        "SendHttpRequest",
+                        moment.utc().toDate(),
+                        req),
+                    req,
+                ),
+            });
+
+            orchestrator(mockContext);
+
+            expect(mockContext.doneValue).to.be.deep.equal(
+                new OrchestratorState({
+                    isDone: false,
+                    output: undefined,
+                    actions:
+                    [
+                        [ new CallHttpAction(req) ],
+                    ],
+                }),
+            );
+        });
+
+        it("schedules authenticated HTTP POST calls with headers", async () => {
+            const orchestrator = TestOrchestrations.SendHttpRequest;
+            const req = new DurableHttpRequest(
+                "POST",
+                "https://example.com/api",
+                JSON.stringify({ foo: "bar" }),
+                { "Content-Type": "application/json", "Accept": "application/json"},
+                new ManagedIdentityTokenSource("https://management.core.windows.net"));
+            const mockContext = new MockContext({
+                context: new DurableOrchestrationBindingInfo(
+                    TestHistories.GetOrchestratorStart(
+                        "SendHttpRequest",
+                        moment.utc().toDate(),
+                        req),
+                    req,
+                ),
+            });
+
+            orchestrator(mockContext);
+
+            expect(mockContext.doneValue).to.be.deep.equal(
+                new OrchestratorState({
+                    isDone: false,
+                    output: undefined,
+                    actions:
+                    [
+                        [ new CallHttpAction(req) ],
+                    ],
+                }),
+            );
+        });
+
+        it("handles a completed HTTP request", async () => {
+            const orchestrator = TestOrchestrations.SendHttpRequest;
+            const req = new DurableHttpRequest("GET", "https://bing.com");
+            const res = new DurableHttpResponse(
+                200,
+                "<!DOCTYPE html><html lang=\"en\">...</html>",
+                {
+                    "Content-Type": "text/html; charset=utf-8",
+                    "Cache-control": "private, max-age=8",
+                });
+            const mockContext = new MockContext({
+                context: new DurableOrchestrationBindingInfo(
+                    TestHistories.GetSendHttpRequestReplayOne(
+                        "SendHttpRequest",
+                        moment.utc().toDate(),
+                        req,
+                        res),
+                    req,
+                ),
+            });
+
+            orchestrator(mockContext);
+
+            expect(mockContext.doneValue).to.be.deep.equal(
+                new OrchestratorState({
+                    isDone: true,
+                    actions:
+                    [
+                        [ new CallHttpAction(req) ],
+                    ],
+                    output: res,
+                }),
+            );
+        });
+    });
+
     describe("callSubOrchestrator()", () => {
         it("schedules a suborchestrator function", async () => {
             const orchestrator = TestOrchestrations.SayHelloWithSubOrchestrator;
diff --git a/test/testobjects/TestOrchestrations.ts b/test/testobjects/TestOrchestrations.ts
index bea13ad..7ccff18 100644
--- a/test/testobjects/TestOrchestrations.ts
+++ b/test/testobjects/TestOrchestrations.ts
@@ -134,6 +134,17 @@ export class TestOrchestrations {
         return output;
     });
 
+    public static SendHttpRequest: any = df.orchestrator(function*(context: any) {
+        const input = context.df.getInput() as df.DurableHttpRequest;
+        const output = yield context.df.callHttp(
+            input.method,
+            input.uri,
+            input.content,
+            input.headers,
+            input.tokenSource);
+        return output;
+    });
+
     /**
      * This orchestrator and its corresponding history replicate conditions under
      * which there are not sufficient OrchestratorStartedEvents in the history
diff --git a/test/testobjects/testhistories.ts b/test/testobjects/testhistories.ts
index edcaeec..127e8ab 100644
--- a/test/testobjects/testhistories.ts
+++ b/test/testobjects/testhistories.ts
@@ -1,5 +1,5 @@
 import * as moment from "moment";
-import { EventRaisedEvent, ExecutionStartedEvent, HistoryEvent, OrchestratorCompletedEvent,
+import { DurableHttpRequest, DurableHttpResponse, EventRaisedEvent, ExecutionStartedEvent, HistoryEvent, OrchestratorCompletedEvent,
     OrchestratorStartedEvent, SubOrchestrationInstanceCompletedEvent, SubOrchestrationInstanceCreatedEvent,
     SubOrchestrationInstanceFailedEvent, TaskCompletedEvent, TaskFailedEvent, TaskScheduledEvent,
     TimerCreatedEvent, TimerFiredEvent } from "../../src/classes";
@@ -1294,6 +1294,66 @@ export class TestHistories {
         ];
     }
 
+    public static GetSendHttpRequestReplayOne(
+        name: string,
+        firstTimestamp: Date,
+        request: DurableHttpRequest,
+        response: DurableHttpResponse)
+        : HistoryEvent[] {
+        const firstMoment = moment(firstTimestamp);
+
+        return [
+            new OrchestratorStartedEvent(
+                {
+                    eventId: -1,
+                    timestamp: firstTimestamp,
+                    isPlayed: false,
+                },
+            ),
+            new ExecutionStartedEvent(
+                {
+                    eventId: -1,
+                    timestamp: firstTimestamp,
+                    isPlayed: true,
+                    name,
+                    input: JSON.stringify(request),
+                },
+            ),
+            new TaskScheduledEvent(
+                {
+                    eventId: 0,
+                    timestamp: firstTimestamp,
+                    isPlayed: false,
+                    name: "BuiltIn::HttpActivity",
+                    input: JSON.stringify(request),
+                },
+            ),
+            new OrchestratorCompletedEvent(
+                {
+                    eventId: -1,
+                    timestamp: firstTimestamp,
+                    isPlayed: false,
+                },
+            ),
+            new OrchestratorStartedEvent(
+                {
+                    eventId: -1,
+                    timestamp: firstMoment.add(1, "s").toDate(),
+                    isPlayed: false,
+                },
+            ),
+            new TaskCompletedEvent(
+                {
+                    eventId: -1,
+                    timestamp: firstMoment.add(1, "s").toDate(),
+                    isPlayed: false,
+                    result: JSON.stringify(response),
+                    taskScheduledId: 0,
+                },
+            ),
+        ];
+    }
+
     public static GetSayHelloWithSubOrchestratorReplayOne(
         firstTimestamp: Date,
         orchestratorName: string,