├── .github
└── FUNDING.yml
├── .gitignore
├── LICENSE
├── src
├── FSharp.DurableExtensions.sln
└── FSharp.DurableExtensions
│ ├── FSharp.DurableExtensions.fsproj
│ └── DurableExtensions.fs
└── README.md
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | # These are supported funding model platforms
2 |
3 | github: JordanMarr
4 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | build_output
2 | Directory.Build.props
3 | *.suo
4 | *.user
5 | *.dotCover
6 |
7 |
8 | bin
9 | obj
10 | _ReSharper*
11 |
12 | *.psess
13 | *.vspx
14 |
15 | *wpftmp.csproj
16 | *.csproj.user
17 | *.resharper.user
18 | *.resharper
19 | *.ReSharper
20 | *.cache
21 | *.g.cs
22 | *~
23 | *.swp
24 | *.bak
25 | *.orig
26 |
27 | *.userprefs
28 | *.log.txt
29 |
30 | .fake
31 | packages
32 | TestResults
33 | run
34 |
35 | .vs
36 | paket-files/
37 | .paket/paket.bootstrapper.exe
38 |
39 | .idea/
40 |
41 | deploy/
42 |
43 | BenchmarkDotNet.Artifacts/
44 |
45 | # Ionide (cross platform F# VS Code tools) working folder
46 | .ionide/
47 |
48 | ### VS Code ###
49 | .vscode/*
50 | !.vscode/settings.json
51 | !.vscode/tasks.json
52 | !.vscode/launch.json
53 | !.vscode/extensions.json
54 | *.code-workspace
55 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 Jordan Marr
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/src/FSharp.DurableExtensions.sln:
--------------------------------------------------------------------------------
1 |
2 | Microsoft Visual Studio Solution File, Format Version 12.00
3 | # Visual Studio Version 17
4 | VisualStudioVersion = 17.6.33829.357
5 | MinimumVisualStudioVersion = 10.0.40219.1
6 | Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "FSharp.DurableExtensions", "FSharp.DurableExtensions\FSharp.DurableExtensions.fsproj", "{B67E280D-5781-4D96-A5B8-4A71755B8359}"
7 | EndProject
8 | Global
9 | GlobalSection(SolutionConfigurationPlatforms) = preSolution
10 | Debug|Any CPU = Debug|Any CPU
11 | Release|Any CPU = Release|Any CPU
12 | EndGlobalSection
13 | GlobalSection(ProjectConfigurationPlatforms) = postSolution
14 | {B67E280D-5781-4D96-A5B8-4A71755B8359}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
15 | {B67E280D-5781-4D96-A5B8-4A71755B8359}.Debug|Any CPU.Build.0 = Debug|Any CPU
16 | {B67E280D-5781-4D96-A5B8-4A71755B8359}.Release|Any CPU.ActiveCfg = Release|Any CPU
17 | {B67E280D-5781-4D96-A5B8-4A71755B8359}.Release|Any CPU.Build.0 = Release|Any CPU
18 | EndGlobalSection
19 | GlobalSection(SolutionProperties) = preSolution
20 | HideSolutionNode = FALSE
21 | EndGlobalSection
22 | GlobalSection(ExtensibilityGlobals) = postSolution
23 | SolutionGuid = {7CBD21E6-59B7-4ED4-84AE-EF7C6ABB3D22}
24 | EndGlobalSection
25 | EndGlobal
26 |
--------------------------------------------------------------------------------
/src/FSharp.DurableExtensions/FSharp.DurableExtensions.fsproj:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | net6.0
5 | true
6 | True
7 | Jordan Marr
8 | 1.3.0
9 | F# extensions for Azure Durable Functions for strongly typed orchestration and activity calls.
10 | https://github.com/JordanMarr/FSharp.DurableExtensions
11 | fsharp;F#;Azure Functions;Durable Functions;Azure Durable Functions;
12 | LICENSE
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 | True
22 | \
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
--------------------------------------------------------------------------------
/src/FSharp.DurableExtensions/DurableExtensions.fs:
--------------------------------------------------------------------------------
1 | module FSharp.DurableExtensions
2 |
3 | open System.Threading.Tasks
4 | open Microsoft.FSharp.Quotations
5 | open Microsoft.Azure.WebJobs.Extensions.DurableTask
6 |
7 | []
8 | module private Helpers =
9 | /// Tries to get the custom FunctionName attribute name, else uses the method name.
10 | let getFunctionName (expr: Expr<'a>) =
11 | match expr with
12 | | DerivedPatterns.Lambdas(_, Patterns.Call(_,mi,_)) ->
13 | mi.CustomAttributes |> Seq.tryFind (fun a -> a.AttributeType.Name = "FunctionNameAttribute")
14 | |> Option.bind (fun att -> Seq.tryHead att.ConstructorArguments)
15 | |> Option.map (fun nameArg -> string nameArg.Value)
16 | |> Option.defaultValue mi.Name
17 | | _ ->
18 | failwith "Invalid function: unable to get function name."
19 |
20 | /// Calls either CallActivityAsync or CallActivityWithRetryAsync depending on whether a RetryOptions is provided.
21 | let callActivity<'Output> (ctx: IDurableOrchestrationContext) (functionName: string) (retry: RetryOptions option) (input: obj) =
22 | match retry with
23 | | Some retry ->
24 | ctx.CallActivityWithRetryAsync<'Output>(functionName, retry, input)
25 | | None ->
26 | ctx.CallActivityAsync<'Output>(functionName, input)
27 |
28 | /// Calls either CallSubOrchestratorAsync or CallSubOrchestratorWithRetryAsync depending on whether a RetryOptions is provided.
29 | let callSubOrchestrator<'Output> (ctx: IDurableOrchestrationContext) (functionName: string) (retry: RetryOptions option) (instanceId: string option) (input: obj) =
30 | match retry, instanceId with
31 | | Some retry, Some instanceId ->
32 | ctx.CallSubOrchestratorWithRetryAsync<'Output>(functionName, retry, instanceId, input)
33 | | Some retry, None ->
34 | ctx.CallSubOrchestratorWithRetryAsync<'Output>(functionName, retry, input)
35 | | None, Some instanceId ->
36 | ctx.CallSubOrchestratorAsync<'Output>(functionName, instanceId, input)
37 | | None, None ->
38 | ctx.CallSubOrchestratorAsync<'Output>(functionName, input)
39 |
40 | /// Extension methods for calling ActivityTrigger functions with strongly typed inputs and outputs.
41 | type IDurableOrchestrationContext with
42 |
43 | /// Calls a function with no ActivityTrigger input.
44 | member ctx.CallActivity<'Args, 'Output> ([] azureFn: Expr<'Args -> Task<'Output>>, ?retry: RetryOptions) : Task<'Output> =
45 | let functionName = getFunctionName azureFn
46 | callActivity<'Output> ctx functionName retry null
47 |
48 | /// Calls a function with an ActivityTrigger input parameter and no dependency parameters.
49 | member ctx.CallActivity<'Input, 'Output> ([] azureFn: Expr<'Input -> Task<'Output>>, input: 'Input, ?retry: RetryOptions) : Task<'Output> =
50 | let functionName = getFunctionName azureFn
51 | callActivity<'Output> ctx functionName retry input
52 |
53 | /// Calls a function with an ActivityTrigger input parameter and one dependency parameter.
54 | member ctx.CallActivity<'Input, 'D1, 'Output> ([] azureFn: Expr<'Input * 'D1 -> Task<'Output>>, input: 'Input, ?retry: RetryOptions) : Task<'Output> =
55 | let functionName = getFunctionName azureFn
56 | callActivity<'Output> ctx functionName retry input
57 |
58 | /// Calls a function with an ActivityTrigger input parameter and two dependency parameters.
59 | member ctx.CallActivity<'Input, 'D1, 'D2, 'Output> ([] azureFn: Expr<'Input * 'D1 * 'D2 -> Task<'Output>>, input: 'Input, ?retry: RetryOptions) : Task<'Output> =
60 | let functionName = getFunctionName azureFn
61 | callActivity<'Output> ctx functionName retry input
62 |
63 | /// Calls a function with an ActivityTrigger input parameter and three dependency parameters.
64 | member ctx.CallActivity<'Input, 'D1, 'D2, 'D3, 'Output> ([] azureFn: Expr<'Input * 'D1 * 'D2 * 'D3 -> Task<'Output>>, input: 'Input, ?retry: RetryOptions) : Task<'Output> =
65 | let functionName = getFunctionName azureFn
66 | callActivity<'Output> ctx functionName retry input
67 |
68 | /// Calls a sub-orchestrator function with no input.
69 | member ctx.CallSubOrchestrator<'Args, 'Output> ([] azureFn: Expr<'Args -> Task<'Output>>, ?retry: RetryOptions, ?instanceId: string) : Task<'Output> =
70 | let functionName = getFunctionName azureFn
71 | callSubOrchestrator<'Output> ctx functionName retry instanceId null
72 |
73 | /// Calls a sub-orchestrator function with an input parameter.
74 | member ctx.CallSubOrchestrator<'Args, 'Output> ([] azureFn: Expr<'Args -> Task<'Output>>, input: obj, ?retry: RetryOptions, ?instanceId: string) : Task<'Output> =
75 | let functionName = getFunctionName azureFn
76 | callSubOrchestrator<'Output> ctx functionName retry instanceId input
77 |
78 | /// Extension methods for calling OrchestrationTrigger functions with strongly typed inputs.
79 | type IDurableOrchestrationClient with
80 |
81 | /// Calls a function with no input.
82 | member client.StartNew<'Args> ([] azureFn: Expr<'Args -> Task>) : Task =
83 | let functionName = getFunctionName azureFn
84 | client.StartNewAsync(functionName, null)
85 |
86 | /// Calls a function with an input parameter.
87 | member client.StartNew<'Args, 'Input when 'Input : not struct> ([] azureFn: Expr<'Args -> Task>, input: 'Input) : Task =
88 | let functionName = getFunctionName azureFn
89 | client.StartNewAsync<'Input>(functionName, input)
90 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | ## FSharp.DurableExtensions
2 | [](https://www.nuget.org/packages/FSharp.DurableExtensions/)
3 |
4 | This library adds the following extension methods:
5 | * IDurableOrchestrationClient
6 | * `StartNew` for starting an OrchestratorTrigger function.
7 | * IDurableOrchestrationContext
8 | * `CallActivity` for calling an ActivityTrigger function.
9 | * `CallSubOrchestrator` for calling a sub OrchestratorTrigger function.
10 |
11 | ```F#
12 | []
13 | member this.Start([] req: HttpRequest, [] client: IDurableOrchestrationClient) =
14 | task {
15 | let! instanceId = client.StartNew(this.Orchestrator)
16 | return client.CreateCheckStatusResponse(req, instanceId)
17 | }
18 |
19 | []
20 | member this.Orchestrator ([] context: IDurableOrchestrationContext, logger: ILogger) =
21 | task {
22 | let! addResp = context.CallActivity(this.AddFive, { NumberToAdd = 2 })
23 | let! mltResp = context.CallActivity(this.MultiplyByTwo, { NumberToMultiply = addResp.Sum })
24 | logger.LogInformation $"Result: {mltResp.Product}"
25 | }
26 | ```
27 |
28 | ## What problem does this library solve?
29 | Calling activity functions from a durable "orchestrator" normally involves calling the function by passing its name as a string, its input as an `obj`, and then manually specifying the expected output type using a generic argument. This approach can lead to runtime errors.
30 |
31 | ### Normal Usage Example: (Magic Strings + Manually Entered Generic Arguments)
32 |
33 | ```F#
34 | type AddFiveRequest = { NumberToAdd: int }
35 | type AddFiveResponse = { Sum: int }
36 | type MultiplyByTwoRequest = { NumberToMultiply: int }
37 | type MultiplyByTwoResponse = { Product: int }
38 |
39 | type Fns() =
40 | []
41 | member this.Orchestrator ([] ctx: IDurableOrchestrationContext, logger: ILogger) =
42 | task {
43 | let! addResp = context.CallActivityAsync("add-five", { NumberToAdd = 2 })
44 | let! mltResp = context.CallActivityAsync("multiply-by-two", { NumberToMultiply = addResp.Sum })
45 | logger.LogInformation $"Result: {mltResp.Product}"
46 | }
47 |
48 | []
49 | member this.AddFive([] req: AddFiveRequest, logger: ILogger) : Task =
50 | task {
51 | logger.LogInformation $"Adding 5 to {req.NumberToAdd}"
52 | return { Sum = req.NumberToAdd + 5 }
53 | }
54 |
55 | []
56 | member this.MultiplyByTwo([] req: MultiplyByTwoRequest, logger: ILogger) : Task =
57 | task {
58 | logger.LogInformation $"Multiplying {req.NumberToMultiply} by 2"
59 | return { Product = req.NumberToMultiply * 2 }
60 | }
61 | ```
62 |
63 | ### Problems with this approach:
64 | * Using magic strings to call functions provides no compile-time safety and can easily result in runtime errors if the strings are incorrect.
65 | * Not refactor-proof: changing the function name may break the orchestration.
66 | * Specifying the wrong input or output generic arguments can result in runtime errors.
67 | * Hard to navigate: using string identifiers makes it difficult to navigate to the target function because you cannot take advantage of IDE features like "Go to definition".
68 | * Bloated code: It is common to create constants to hold function names which bloats the code and still doesn't solve the problems listed above.
69 |
70 | ## The Solution: FSharp.DurableExtensions
71 | This library addresses all the above problems with the new `CallActivity`, `CallSubOrchestrator` and `StartNew` extension methods.
72 | `CallActivity` allows you to directly pass the function you are calling, and infers both the input and output types for you. This completely eliminates runtime errors by utilizing the compiler to catch mismatched inputs/outputs at design-time as build errors, and also makes it easy to navigate directly to the referenced function via "F12" / "Go to definition".
73 |
74 | ```F#
75 | open FSharp.DurableExtensions
76 |
77 | type AddFiveRequest = { NumberToAdd: int }
78 | type AddFiveResponse = { Sum: int }
79 | type MultiplyByTwoRequest = { NumberToMultiply: int }
80 | type MultiplyByTwoResponse = { Product: int }
81 |
82 | type Fns() =
83 | []
84 | member this.Orchestrator ([] ctx: IDurableOrchestrationContext, logger: ILogger) =
85 | task {
86 | let! addResp = context.CallActivity(this.AddFive, { NumberToAdd = 2 })
87 | let! mltResp = context.CallActivity(this.MultiplyByTwo, { NumberToMultiply = addResp.Sum })
88 | logger.LogInformation $"Result: {mltResp.Product}"
89 | }
90 |
91 | []
92 | member this.AddFive([] req: AddFiveRequest, logger: ILogger) : Task =
93 | task {
94 | logger.LogInformation $"Adding 5 to {req.NumberToAdd}"
95 | return { Sum = req.NumberToAdd + 5 }
96 | }
97 |
98 | []
99 | member this.MultiplyByTwo([] req: MultiplyByTwoRequest, logger: ILogger) : Task =
100 | task {
101 | logger.LogInformation $"Multiplying {req.NumberToMultiply} by 2"
102 | return { Product = req.NumberToMultiply * 2 }
103 | }
104 | ```
105 |
106 | ## Retry Options
107 | `RetryOptions` may optionally be passed in:
108 |
109 | ```F#
110 | type Fns() =
111 | []
112 | member this.Orchestrator ([] ctx: IDurableOrchestrationContext, logger: ILogger) =
113 | task {
114 | let retry = RetryOptions(TimeSpan.FromSeconds 5, 3)
115 | let! addResp = context.CallActivity(this.AddFive, { NumberToAdd = 2 }, retry)
116 | let! mltResp = context.CallActivity(this.MultiplyByTwo, { NumberToMultiply = addResp.Sum }, retry)
117 | logger.LogInformation $"Result: {mltResp.Product}"
118 | }
119 | ```
120 |
121 |
--------------------------------------------------------------------------------