├── .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 | [![NuGet version (FSharp.DurableExtensions)](https://img.shields.io/nuget/v/FSharp.DurableExtensions.svg?style=flat-square)](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 | --------------------------------------------------------------------------------