From dfe7feef0033b4e2ea6679b6333a0b70bef02c36 Mon Sep 17 00:00:00 2001 From: Whit Waldo Date: Fri, 1 Nov 2024 12:08:59 -0500 Subject: [PATCH 1/2] Add .NET client for Dapr Jobs API (#1384) * Package addition + updates Signed-off-by: Whit Waldo * Added Dapr.Jobs project Signed-off-by: Whit Waldo * Initial commit - unable to proceed without update on master from streaming sub PR Signed-off-by: Whit Waldo * Added class to Dapr.Common, fixed compilation errors Signed-off-by: Whit Waldo * Added unit tests for Dapr.Common enum extensions Signed-off-by: Whit Waldo * Added unit tests Signed-off-by: Whit Waldo * Added missing copyright header Signed-off-by: Whit Waldo * Added sample Jobs project Signed-off-by: Whit Waldo * Added documentation Signed-off-by: Whit Waldo * Added missing copyright header Signed-off-by: Whit Waldo * Downgraded Roslyn packages since master doesn't yet have the incremental source generator updates Signed-off-by: Whit Waldo * Missed a reference regarding incremental source generators Signed-off-by: Whit Waldo * Downgraded packages to fix nullability issues on build Signed-off-by: Whit Waldo * Downgraded from 8.* packages back to 6.* packages for the various Microsoft.Extensions.* packages to fix build issues Signed-off-by: Whit Waldo * Removed unnecessary assignment Signed-off-by: Whit Waldo * Added braces for clarity Signed-off-by: Whit Waldo * Added more curley braces Signed-off-by: Whit Waldo * More curly braces again Signed-off-by: Whit Waldo * Marked two properties as static Signed-off-by: Whit Waldo * Updated to handle any order of parameters to endpoint route builder delegate Signed-off-by: Whit Waldo * Updated default cancellation token value Signed-off-by: Whit Waldo * Added missing package version in Directory.Packages Signed-off-by: Whit Waldo * Fixed unit tests Signed-off-by: Whit Waldo * Added test to ensure that even if cancellation token is provided, it'll handle the mapping properly Signed-off-by: Whit Waldo --------- Signed-off-by: Whit Waldo --- Directory.Packages.props | 94 ++-- all.sln | 24 + daprdocs/content/en/dotnet-sdk-docs/_index.md | 7 + .../en/dotnet-sdk-docs/dotnet-jobs/_index.md | 8 + .../dotnet-jobs/dotnet-jobs-howto.md | 288 ++++++++++ .../dotnet-jobs/dotnet-jobsclient-usage.md | 168 ++++++ examples/Jobs/JobsSample/JobsSample.csproj | 13 + examples/Jobs/JobsSample/Program.cs | 58 +++ .../JobsSample/Properties/launchSettings.json | 38 ++ src/Dapr.Common/AssemblyInfo.cs | 4 +- src/Dapr.Common/DaprDefaults.cs | 2 + src/Dapr.Common/DaprGenericClientBuilder.cs | 213 ++++++++ src/Dapr.Common/Extensions/EnumExtensions.cs | 27 + src/Dapr.Jobs/AssemblyInfo.cs | 16 + src/Dapr.Jobs/CronExpressionBuilder.cs | 492 ++++++++++++++++++ src/Dapr.Jobs/Dapr.Jobs.csproj | 29 ++ src/Dapr.Jobs/DaprJobsClient.cs | 99 ++++ src/Dapr.Jobs/DaprJobsClientBuilder.cs | 37 ++ src/Dapr.Jobs/DaprJobsGrpcClient.cs | 248 +++++++++ .../DaprJobsServiceCollectionExtensions.cs | 78 +++ .../Extensions/DaprSerializationExtensions.cs | 78 +++ .../EndpointRouteBuilderExtensions.cs | 95 ++++ src/Dapr.Jobs/Extensions/StringExtensions.cs | 27 + .../Extensions/TimeSpanExtensions.cs | 117 +++++ .../DaprJobScheduleConverter.cs | 41 ++ .../Iso8601DateTimeJsonConverter.cs | 61 +++ src/Dapr.Jobs/Models/DaprJobSchedule.cs | 146 ++++++ .../Models/Responses/DaprJobDetails.cs | 97 ++++ .../Extensions/EnumExtensionsTest.cs | 38 ++ .../CronExpressionBuilderTests.cs | 386 ++++++++++++++ test/Dapr.Jobs.Test/Dapr.Jobs.Test.csproj | 28 + .../DaprJobsClientBuilderTests.cs | 122 +++++ .../Dapr.Jobs.Test/DaprJobsGrpcClientTests.cs | 172 ++++++ ...aprJobsServiceCollectionExtensionsTests.cs | 85 +++ .../EndpointRouteBuilderExtensionsTests.cs | 179 +++++++ .../Extensions/StringExtensionsTests.cs | 46 ++ .../Extensions/TimeSpanExtensionsTests.cs | 146 ++++++ .../Models/DaprJobScheduleTests.cs | 172 ++++++ .../Responses/DaprJobDetailsTests.cs | 48 ++ 39 files changed, 3981 insertions(+), 46 deletions(-) create mode 100644 daprdocs/content/en/dotnet-sdk-docs/dotnet-jobs/_index.md create mode 100644 daprdocs/content/en/dotnet-sdk-docs/dotnet-jobs/dotnet-jobs-howto.md create mode 100644 daprdocs/content/en/dotnet-sdk-docs/dotnet-jobs/dotnet-jobsclient-usage.md create mode 100644 examples/Jobs/JobsSample/JobsSample.csproj create mode 100644 examples/Jobs/JobsSample/Program.cs create mode 100644 examples/Jobs/JobsSample/Properties/launchSettings.json create mode 100644 src/Dapr.Common/DaprGenericClientBuilder.cs create mode 100644 src/Dapr.Common/Extensions/EnumExtensions.cs create mode 100644 src/Dapr.Jobs/AssemblyInfo.cs create mode 100644 src/Dapr.Jobs/CronExpressionBuilder.cs create mode 100644 src/Dapr.Jobs/Dapr.Jobs.csproj create mode 100644 src/Dapr.Jobs/DaprJobsClient.cs create mode 100644 src/Dapr.Jobs/DaprJobsClientBuilder.cs create mode 100644 src/Dapr.Jobs/DaprJobsGrpcClient.cs create mode 100644 src/Dapr.Jobs/Extensions/DaprJobsServiceCollectionExtensions.cs create mode 100644 src/Dapr.Jobs/Extensions/DaprSerializationExtensions.cs create mode 100644 src/Dapr.Jobs/Extensions/EndpointRouteBuilderExtensions.cs create mode 100644 src/Dapr.Jobs/Extensions/StringExtensions.cs create mode 100644 src/Dapr.Jobs/Extensions/TimeSpanExtensions.cs create mode 100644 src/Dapr.Jobs/JsonConverters/DaprJobScheduleConverter.cs create mode 100644 src/Dapr.Jobs/JsonConverters/Iso8601DateTimeJsonConverter.cs create mode 100644 src/Dapr.Jobs/Models/DaprJobSchedule.cs create mode 100644 src/Dapr.Jobs/Models/Responses/DaprJobDetails.cs create mode 100644 test/Dapr.Common.Test/Extensions/EnumExtensionsTest.cs create mode 100644 test/Dapr.Jobs.Test/CronExpressionBuilderTests.cs create mode 100644 test/Dapr.Jobs.Test/Dapr.Jobs.Test.csproj create mode 100644 test/Dapr.Jobs.Test/DaprJobsClientBuilderTests.cs create mode 100644 test/Dapr.Jobs.Test/DaprJobsGrpcClientTests.cs create mode 100644 test/Dapr.Jobs.Test/Extensions/DaprJobsServiceCollectionExtensionsTests.cs create mode 100644 test/Dapr.Jobs.Test/Extensions/EndpointRouteBuilderExtensionsTests.cs create mode 100644 test/Dapr.Jobs.Test/Extensions/StringExtensionsTests.cs create mode 100644 test/Dapr.Jobs.Test/Extensions/TimeSpanExtensionsTests.cs create mode 100644 test/Dapr.Jobs.Test/Models/DaprJobScheduleTests.cs create mode 100644 test/Dapr.Jobs.Test/Responses/DaprJobDetailsTests.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 332939a5b..4a9c47ad4 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,47 +1,51 @@ - - true - true - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + true + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/all.sln b/all.sln index 1dd0ab3c5..6e55f247b 100644 --- a/all.sln +++ b/all.sln @@ -119,6 +119,14 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dapr.Common", "src\Dapr.Com EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dapr.Common.Test", "test\Dapr.Common.Test\Dapr.Common.Test.csproj", "{CDB47863-BEBD-4841-A807-46D868962521}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dapr.Jobs", "src\Dapr.Jobs\Dapr.Jobs.csproj", "{C8BB6A85-A7EA-40C0-893D-F36F317829B3}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dapr.Jobs.Test", "test\Dapr.Jobs.Test\Dapr.Jobs.Test.csproj", "{BF9828E9-5597-4D42-AA6E-6E6C12214204}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Jobs", "Jobs", "{D9697361-232F-465D-A136-4561E0E88488}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "JobsSample", "examples\Jobs\JobsSample\JobsSample.csproj", "{9CAF360E-5AD3-4C4F-89A0-327EEB70D673}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -303,6 +311,18 @@ Global {CDB47863-BEBD-4841-A807-46D868962521}.Debug|Any CPU.Build.0 = Debug|Any CPU {CDB47863-BEBD-4841-A807-46D868962521}.Release|Any CPU.ActiveCfg = Release|Any CPU {CDB47863-BEBD-4841-A807-46D868962521}.Release|Any CPU.Build.0 = Release|Any CPU + {C8BB6A85-A7EA-40C0-893D-F36F317829B3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C8BB6A85-A7EA-40C0-893D-F36F317829B3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C8BB6A85-A7EA-40C0-893D-F36F317829B3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C8BB6A85-A7EA-40C0-893D-F36F317829B3}.Release|Any CPU.Build.0 = Release|Any CPU + {BF9828E9-5597-4D42-AA6E-6E6C12214204}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BF9828E9-5597-4D42-AA6E-6E6C12214204}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BF9828E9-5597-4D42-AA6E-6E6C12214204}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BF9828E9-5597-4D42-AA6E-6E6C12214204}.Release|Any CPU.Build.0 = Release|Any CPU + {9CAF360E-5AD3-4C4F-89A0-327EEB70D673}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9CAF360E-5AD3-4C4F-89A0-327EEB70D673}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9CAF360E-5AD3-4C4F-89A0-327EEB70D673}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9CAF360E-5AD3-4C4F-89A0-327EEB70D673}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -359,6 +379,10 @@ Global {DFBABB04-50E9-42F6-B470-310E1B545638} = {27C5D71D-0721-4221-9286-B94AB07B58CF} {B445B19C-A925-4873-8CB7-8317898B6970} = {27C5D71D-0721-4221-9286-B94AB07B58CF} {CDB47863-BEBD-4841-A807-46D868962521} = {DD020B34-460F-455F-8D17-CF4A949F100B} + {C8BB6A85-A7EA-40C0-893D-F36F317829B3} = {27C5D71D-0721-4221-9286-B94AB07B58CF} + {BF9828E9-5597-4D42-AA6E-6E6C12214204} = {DD020B34-460F-455F-8D17-CF4A949F100B} + {D9697361-232F-465D-A136-4561E0E88488} = {D687DDC4-66C5-4667-9E3A-FD8B78ECAA78} + {9CAF360E-5AD3-4C4F-89A0-327EEB70D673} = {D9697361-232F-465D-A136-4561E0E88488} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {65220BF2-EAE1-4CB2-AA58-EBE80768CB40} diff --git a/daprdocs/content/en/dotnet-sdk-docs/_index.md b/daprdocs/content/en/dotnet-sdk-docs/_index.md index 121dde310..72e8b71d9 100644 --- a/daprdocs/content/en/dotnet-sdk-docs/_index.md +++ b/daprdocs/content/en/dotnet-sdk-docs/_index.md @@ -69,6 +69,13 @@ Put the Dapr .NET SDK to the test. Walk through the .NET quickstarts and tutoria +
+
+
Jobs
+

Create and manage the scheduling and orchestration of jobs in .NET.

+ +
+
## More information diff --git a/daprdocs/content/en/dotnet-sdk-docs/dotnet-jobs/_index.md b/daprdocs/content/en/dotnet-sdk-docs/dotnet-jobs/_index.md new file mode 100644 index 000000000..049994221 --- /dev/null +++ b/daprdocs/content/en/dotnet-sdk-docs/dotnet-jobs/_index.md @@ -0,0 +1,8 @@ +--- +type: docs +title: "Dapr Jobs .NET SDK" +linkTitle: "Jobs" +weight: 50000 +description: Get up and running with Dapr Jobs and the Dapr .NET SDK +--- + diff --git a/daprdocs/content/en/dotnet-sdk-docs/dotnet-jobs/dotnet-jobs-howto.md b/daprdocs/content/en/dotnet-sdk-docs/dotnet-jobs/dotnet-jobs-howto.md new file mode 100644 index 000000000..c8bc66175 --- /dev/null +++ b/daprdocs/content/en/dotnet-sdk-docs/dotnet-jobs/dotnet-jobs-howto.md @@ -0,0 +1,288 @@ +--- +type: docs +title: "How to: Author and manage Dapr Jobs in the .NET SDK" +linkTitle: "How to: Author & manage jobs" +weight: 10000 +description: Learn how to author and manage Dapr Jobs using the .NET SDK +--- + +Let's create an endpoint that will be invoked by Dapr Jobs when it triggers, then schedule the job in the same app. We'll use the [simple example provided here](https://github.com/dapr/dotnet-sdk/tree/master/examples/Jobs), for the following demonstration and walk through it as an explainer of how you can schedule one-time or recurring jobs using either an interval or Cron expression yourself. In this guide, +you will: + +- Deploy a .NET Web API application ([JobsSample](https://github.com/dapr/dotnet-sdk/tree/master/examples/Jobs/JobsSample)) +- Utilize the .NET Jobs SDK to schedule a job invocation and set up the endpoint to be triggered + +In the .NET example project: +- The main [`Program.cs`](https://github.com/dapr/dotnet-sdk/tree/master/examples/Jobs/JobsSample/Program.cs) file comprises the entirety of this demonstration. + +## Prerequisites +- [.NET 6+](https://dotnet.microsoft.com/download) installed +- [Dapr CLI](https://docs.dapr.io/getting-started/install-dapr-cli/) +- [Initialized Dapr environment](https://docs.dapr.io/getting-started/install-dapr-selfhost) +- [Dapr Jobs .NET SDK](https://github.com/dapr/dotnet-sdk) + +## Set up the environment +Clone the [.NET SDK repo](https://github.com/dapr/dotnet-sdk). + +```sh +git clone https://github.com/dapr/dotnet-sdk.git +``` + +From the .NET SDK root directory, navigate to the Dapr Jobs example. + +```sh +cd examples/Jobs +``` + +## Run the application locally + +To run the Dapr application, you need to start the .NET program and a Dapr sidecar. Navigate to the `JobsSample` directory. + +```sh +cd JobsSample +``` + +We'll run a command that starts both the Dapr sidecar and the .NET program at the same time. + +```sh +dapr run --app-id jobsapp --dapr-grpc-port 4001 --dapr-http-port 3500 -- dotnet run +``` +> Dapr listens for HTTP requests at `http://localhost:3500` and internal Jobs gRPC requests at `http://localhost:4001`. +## Register the Dapr Jobs client with dependency injection +The Dapr Jobs SDK provides an extension method to simplify the registration of the Dapr Jobs client. Before completing the dependency injection registration in `Program.cs`, add the following line: + +```cs +var builder = WebApplication.CreateBuilder(args); + +//Add anywhere between these two +builder.Services.AddDaprJobsClient(); //That's it +var app = builder.Build(); +``` + +> Note that in today's implementation of the Jobs API, the app that schedules the job will also be the app that receives the trigger notification. In other words, you cannot schedule a trigger to run in another application. As a result, while you don't explicitly need the Dapr Jobs client to be registered in your application to schedule a trigger invocation endpoint, your endpoint will never be invoked without the same app also scheduling the job somehow (whether via this Dapr Jobs .NET SDK or an HTTP call to the sidecar). +It's possible that you may want to provide some configuration options to the Dapr Jobs client that +should be present with each call to the sidecar such as a Dapr API token or you want to use a non-standard +HTTP or gRPC endpoint. This is possible through an overload of the register method that allows configuration of a `DaprJobsClientBuilder` instance: + +```cs +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddDaprJobsClient(daprJobsClientBuilder => +{ + daprJobsClientBuilder.UseDaprApiToken("abc123"); + daprJobsClientBuilder.UseHttpEndpoint("http://localhost:8512"); //Non-standard sidecar HTTP endpoint +}); + +var app = builder.Build(); +``` + +Still, it's possible that whatever values you wish to inject need to be retrieved from some other source, itself registered as a dependency. There's one more overload you can use to inject an `IServiceProvider` into the configuration action method. In the following example, we register a fictional singleton that can retrieve secrets from somewhere and pass it into the configuration method for `AddDaprJobClient` so +we can retrieve our Dapr API token from somewhere else for registration here: + +```cs +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddSingleton(); +builder.Services.AddDaprJobsClient((serviceProvider, daprJobsClientBuilder) => +{ + var secretRetriever = serviceProvider.GetRequiredService(); + var daprApiToken = secretRetriever.GetSecret("DaprApiToken").Value; + daprJobsClientBuilder.UseDaprApiToken(daprApiToken); + + daprJobsClientBuilder.UseHttpEndpoint("http://localhost:8512"); +}); + +var app = builder.Build(); +``` + +## Use the Dapr Jobs client without relying on dependency injection +While the use of dependency injection simplifies the use of complex types in .NET and makes it easier to +deal with complicated configurations, you're not required to register the `DaprJobsClient` in this way. Rather, you can also elect to create an instance of it from a `DaprJobsClientBuilder` instance as demonstrated below: + +```cs + +public class MySampleClass +{ + public void DoSomething() + { + var daprJobsClientBuilder = new DaprJobsClientBuilder(); + var daprJobsClient = daprJobsClientBuilder.Build(); + + //Do something with the `daprJobsClient` + } +} + +``` + +## Set up a endpoint to be invoked when the job is triggered + +It's easy to set up a jobs endpoint if you're at all familiar with [minimal APIs in ASP.NET Core](https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/overview) as the syntax is the same between the two. + +Once dependency injection registration has been completed, configure the application the same way you would to handle mapping an HTTP request via the minimal API functionality in ASP.NET Core. Implemented as an extension method, +pass the name of the job it should be responsive to and a delegate. Services can be injected into the delegate's arguments as you wish and you can optionally pass a `JobDetails` to get information about the job that has been triggered (e.g. access its scheduling setup or payload). + +There are two delegates you can use here. One provides an `IServiceProvider` in case you need to inject other services into the handler: + +```cs +//We have this from the example above +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddDaprJobsClient(); + +var app = builder.Build(); + +//Add our endpoint registration +app.MapDaprScheduledJob("myJob", (IServiceProvider serviceProvider, string? jobName, JobDetails? jobDetails) => { + var logger = serviceProvider.GetService(); + logger?.LogInformation("Received trigger invocation for '{jobName}'", "myJob"); + + //Do something... +}); + +app.Run(); +``` + +The other overload of the delegate doesn't require an `IServiceProvider` if not necessary: + +```cs +//We have this from the example above +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddDaprJobsClient(); + +var app = builder.Build(); + +//Add our endpoint registration +app.MapDaprScheduledJob("myJob", (string? jobName, JobDetails? jobDetails) => { + //Do something... +}); + +app.Run(); +``` + +## Register the job + +Finally, we have to register the job we want scheduled. Note that from here, all SDK methods have cancellation token support and use a default token if not otherwise set. + +There are three different ways to set up a job that vary based on how you want to configure the schedule: + +### One-time job +A one-time job is exactly that; it will run at a single point in time and will not repeat. This approach requires that you select a job name and specify a time it should be triggered. + +| Argument Name | Type | Description | Required | +|---|---|---|---| +| jobName | string | The name of the job being scheduled. | Yes | +| scheduledTime | DateTime | The point in time when the job should be run. | Yes | +| payload | ReadOnlyMemory | Job data provided to the invocation endpoint when triggered. | No | +| cancellationToken | CancellationToken | Used to cancel out of the operation early, e.g. because of an operation timeout. | No | + +One-time jobs can be scheduled from the Dapr Jobs client as in the following example: + +```cs +public class MyOperation(DaprJobsClient daprJobsClient) +{ + public async Task ScheduleOneTimeJobAsync(CancellationToken cancellationToken) + { + var today = DateTime.UtcNow; + var threeDaysFromNow = today.AddDays(3); + + await daprJobsClient.ScheduleOneTimeJobAsync("myJobName", threeDaysFromNow, cancellationToken: cancellationToken); + } +} +``` + +### Interval-based job +An interval-based job is one that runs on a recurring loop configured as a fixed amount of time, not unlike how [reminders](https://docs.dapr.io/developing-applications/building-blocks/actors/actors-timers-reminders/#actor-reminders) work in the Actors building block today. These jobs can be scheduled with a number of optional arguments as well: + +| Argument Name | Type | Description | Required | +|---|---|---|---| +| jobName | string | The name of the job being scheduled. | Yes | +| interval | TimeSpan | The interval at which the job should be triggered. | Yes | +| startingFrom | DateTime | The point in time from which the job schedule should start. | No | +| repeats | int | The maximum number of times the job should be triggered. | No | +| ttl | When the job should expires and no longer trigger. | No | +| payload | ReadOnlyMemory | Job data provided to the invocation endpoint when triggered. | No | +| cancellationToken | CancellationToken | Used to cancel out of the operation early, e.g. because of an operation timeout. | No | + +Interval-based jobs can be scheduled from the Dapr Jobs client as in the following example: + +```cs +public class MyOperation(DaprJobsClient daprJobsClient) +{ + + public async Task ScheduleIntervalJobAsync(CancellationToken cancellationToken) + { + var hourlyInterval = TimeSpan.FromHours(1); + + //Trigger the job hourly, but a maximum of 5 times + await daprJobsClient.ScheduleIntervalJobAsync("myJobName", hourlyInterval, repeats: 5), cancellationToken: cancellationToken; + } +} +``` + +### Cron-based job +A Cron-based job is scheduled using a Cron expression. This gives more calendar-based control over when the job is triggered as it can used calendar-based values in the expression. Like the other options, these jobs can be scheduled with a number of optional arguments as well: + +| Argument Name | Type | Description | Required | +|---|---|---|---| +| jobName | string | The name of the job being scheduled. | Yes | +| cronExpression | string | The systemd Cron-like expression indicating when the job should be triggered. | Yes | +| startingFrom | DateTime | The point in time from which the job schedule should start. | No | +| repeats | int | The maximum number of times the job should be triggered. | No | +| ttl | When the job should expires and no longer trigger. | No | +| payload | ReadOnlyMemory | Job data provided to the invocation endpoint when triggered. | No | +| cancellationToken | CancellationToken | Used to cancel out of the operation early, e.g. because of an operation timeout. | No | + +A Cron-based job can be scheduled from the Dapr Jobs client as follows: + +```cs +public class MyOperation(DaprJobsClient daprJobsClient) +{ + public async Task ScheduleCronJobAsync(CancellationToken cancellationToken) + { + //At the top of every other hour on the fifth day of the month + const string cronSchedule = "0 */2 5 * *"; + + //Don't start this until next month + var now = DateTime.UtcNow; + var oneMonthFromNow = now.AddMonths(1); + var firstOfNextMonth = new DateTime(oneMonthFromNow.Year, oneMonthFromNow.Month, 1, 0, 0, 0); + + //Trigger the job hourly, but a maximum of 5 times + await daprJobsClient.ScheduleCronJobAsync("myJobName", cronSchedule, dueTime: firstOfNextMonth, cancellationToken: cancellationToken); + } +} +``` + +## Get details of already-scheduled job +If you know the name of an already-scheduled job, you can retrieve its metadata without waiting for it to +be triggered. The returned `JobDetails` exposes a few helpful properties for consuming the information from the Dapr Jobs API: + +- If the `Schedule` property contains a Cron expression, the `IsCronExpression` property will be true and the expression will also be available in the `CronExpression` property. +- If the `Schedule` property contains a duration value, the `IsIntervalExpression` property will instead be true and the value will be converted to a `TimeSpan` value accessible from the `Interval` property. + +This can be done by using the following: + +```cs +public class MyOperation(DaprJobsClient daprJobsClient) +{ + public async Task GetJobDetailsAsync(string jobName, CancellationToken cancellationToken) + { + var jobDetails = await daprJobsClient.GetJobAsync(jobName, canecllationToken); + return jobDetails; + } +} +``` + +## Delete a scheduled job +To delete a scheduled job, you'll need to know its name. From there, it's as simple as calling the `DeleteJobAsync` method on the Dapr Jobs client: + +```cs +public class MyOperation(DaprJobsClient daprJobsClient) +{ + public async Task DeleteJobAsync(string jobName, CancellationToken cancellationToken) + { + await daprJobsClient.DeleteJobAsync(jobName, cancellationToken); + } +} +``` \ No newline at end of file diff --git a/daprdocs/content/en/dotnet-sdk-docs/dotnet-jobs/dotnet-jobsclient-usage.md b/daprdocs/content/en/dotnet-sdk-docs/dotnet-jobs/dotnet-jobsclient-usage.md new file mode 100644 index 000000000..4c28e6595 --- /dev/null +++ b/daprdocs/content/en/dotnet-sdk-docs/dotnet-jobs/dotnet-jobsclient-usage.md @@ -0,0 +1,168 @@ +--- +type: docs +title: "DaprJobsClient usage" +linkTitle: "DaprJobsClient usage" +weight: 5000 +description: Essential tips and advice for using DaprJobsClient +--- + +## Lifetime management + +A `DaprJobsClient` is a version of the Dapr client that is dedicated to interacting with the Dapr Jobs API. It can be registered alongside a `DaprClient` without issue. + +It maintains access to networking resources in the form of TCP sockets used to communicate with the Dapr sidecar and implements `IDisposable` to support the eager cleanup of resources. + +For best performance, create a single long-lived instance of `DaprJobsClient` and provide access to that shared instance throughout your application. `DaprJobsClient` instances are thread-safe and intended to be shared. + +Avoid creating a `DaprJobsClient` for each operation and disposing it when the operation is complete. + +## Configuring DaprJobsClient via the DaprJobsClientBuilder + +A `DaprJobsClient` can be configured by invoking methods on the `DaprJobsClientBuilder` class before calling `.Build()` to create the client itself. The settings for each `DaprJobsClient` are separate +and cannot be changed after calling `.Build()`. + +```cs +var daprJobsClient = new DaprJobsClientBuilder() + .UseDaprApiToken("abc123") // Specify the API token used to authenticate to other Dapr sidecars + .Build(); +``` + +The `DaprJobsClientBuilder` contains settings for: + +- The HTTP endpoint of the Dapr sidecar +- The gRPC endpoint of the Dapr sidecar +- The `JsonSerializerOptions` object used to configure JSON serialization +- The `GrpcChannelOptions` object used to configure gRPC +- The API token used to authenticate requests to the sidecar +- The factory method used to create the `HttpClient` instance used by the SDK +- The timeout used for the `HttpClient` instance when making requests to the sidecar + +The SDK will read the following environment variables to configure the default values: + +- `DAPR_HTTP_ENDPOINT`: used to find the HTTP endpoint of the Dapr sidecar, example: `https://dapr-api.mycompany.com` +- `DAPR_GRPC_ENDPOINT`: used to find the gRPC endpoint of the Dapr sidecar, example: `https://dapr-grpc-api.mycompany.com` +- `DAPR_HTTP_PORT`: if `DAPR_HTTP_ENDPOINT` is not set, this is used to find the HTTP local endpoint of the Dapr sidecar +- `DAPR_GRPC_PORT`: if `DAPR_GRPC_ENDPOINT` is not set, this is used to find the gRPC local endpoint of the Dapr sidecar +- `DAPR_API_TOKEN`: used to set the API token + +### Configuring gRPC channel options + +Dapr's use of `CancellationToken` for cancellation relies on the configuration of the gRPC channel options. If you need to configure these options yourself, make sure to enable the [ThrowOperationCanceledOnCancellation setting](https://grpc.github.io/grpc/csharp-dotnet/api/Grpc.Net.Client.GrpcChannelOptions.html#Grpc_Net_Client_GrpcChannelOptions_ThrowOperationCanceledOnCancellation). + +```cs +var daprJobsClient = new DaprJobsClientBuilder() + .UseGrpcChannelOptions(new GrpcChannelOptions { ... ThrowOperationCanceledOnCancellation = true }) + .Build(); +``` + +## Using cancellation with DaprJobsClient + +The APIs on DaprJobsClient perform asynchronous operations and accept an optional `CancellationToken` parameter. This follows a standard .NET idiom for cancellable operations. Note that when cancellation occurs, there is no guarantee that the remote endpoint stops processing the request, only that the client has stopped waiting for completion. + +When an operation is cancelled, it will throw an `OperationCancelledException`. + +## Configuring DaprJobsClient via dependency injection + +Using the built-in extension methods for registering the `DaprJobsClient` in a dependency injection container can provide the benefit of registering the long-lived service a single time, centralize complex configuration and improve performance by ensuring similarly long-lived resources are re-purposed when possible (e.g. `HttpClient` instances). + +There are three overloads available to give the developer the greatest flexibility in configuring the client for their scenario. Each of these will register the `IHttpClientFactory` on your behalf if not already registered, and configure the `DaprJobsClientBuilder` to use it when creating the `HttpClient` instance in order to re-use the same instance as much as possible and avoid socket exhaution and other issues. + +In the first approach, there's no configuration done by the developer and the `DaprJobsClient` is configured with the default settings. + +```cs +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddDaprJobsClient(); //Registers the `DaprJobsClient` to be injected as needed +var app = builder.Build(); +``` + +Sometimes the developer will need to configure the created client using the various configuration options detailed above. This is done through an overload that passes in the `DaprJobsClientBuiler` and exposes methods for configuring the necessary options. + +```cs +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddDaprJobsClient(daprJobsClientBuilder => { + //Set the API token + daprJobsClientBuilder.UseDaprApiToken("abc123"); + //Specify a non-standard HTTP endpoint + daprJobsClientBuilder.UseHttpEndpoint("http://dapr.my-company.com"); +}); + +var app = builder.Build(); +``` + +Finally, it's possible that the developer may need to retrieve information from another service in order to populate these configuration values. That value may be provided from a `DaprClient` instance, a vendor-specific SDK or some local service, but as long as it's also registered in DI, it can be injected into this configuration operation via the last overload: + +```cs +var builder = WebApplication.CreateBuilder(args); + +//Register a fictional service that retrieves secrets from somewhere +builder.Services.AddSingleton(); + +builder.Services.AddDaprJobsClient((serviceProvider, daprJobsClientBuilder) => { + //Retrieve an instance of the `SecretService` from the service provider + var secretService = serviceProvider.GetRequiredService(); + var daprApiToken = secretService.GetSecret("DaprApiToken").Value; + + //Configure the `DaprJobsClientBuilder` + daprJobsClientBuilder.UseDaprApiToken(daprApiToken); +}); + +var app = builder.Build(); +``` + +## Understanding payload serialization on DaprJobsClient + +While there are many methods on the `DaprClient` that automatically serialize and deserialize data using the `System.Text.Json` serializer, this SDK takes a different philosophy. Instead, the relevant methods accept an optional payload of `ReadOnlyMemory` meaning that serialization is an exercise left to the developer and is not generally handled by the SDK. + +That said, there are some helper extension methods available for each of the scheduling methods. If you know that you want to use a type that's JSON-serializable, you can use the `Schedule*WithPayloadAsync` method for each scheduling type that accepts an `object` as a payload and an optional `JsonSerializerOptions` to use when serializing the value. This will convert the value to UTF-8 encoded bytes for you as a convenience. Here's an example of what this might look like when scheduling a Cron expression: + +```cs +public sealed record Doodad (string Name, int Value); + +//... +var doodad = new Doodad("Thing", 100); +await daprJobsClient.ScheduleCronJobWithPayloadAsync("myJob", "5 * * * *", doodad); +``` + +In the same vein, if you have a plain string value, you can use an overload of the same method to serialize a string-typed payload and the JSON serialization step will be skipped and it'll only be encoded to an array of UTF-8 encoded bytes. Here's an exampe of what this might look like when scheduling a one-time job: + +```cs +var now = DateTime.UtcNow; +var oneWeekFromNow = now.AddDays(7); +await daprJobsClient.ScheduleOneTimeJobWithPayloadAsync("myOtherJob", oneWeekFromNow, "This is a test!"); +``` + +The `JobDetails` type returns the data as a `ReadOnlyMemory?` so the developer has the freedom to deserialize as they wish, but there are again two helper extensions included that can deserialize this to either a JSON-compatible type or a string. Both methods assume that the developer encoded the originally scheduled job (perhaps using the helper serialization methods) as these methods will not force the bytes to represent something they're not. + +To deserialize the bytes to a string, the following helper method can be used: +```cs +if (jobDetails.Payload is not null) +{ + string payloadAsString = jobDetails.Payload.DeserializeToString(); //If successful, returns a string value with the value +} +``` + +To deserialize JSON-encoded UTF-8 bytes to the corresponding type, the following helper method can be used. An overload argument is available that permits the developer to pass in their own `JsonSerializerOptions` to be applied during deserialization. + +```cs +public sealed record Doodad (string Name, int Value); + +//... +if (jobDetails.Payload is not null) +{ + var deserializedDoodad = jobDetails.Payload.DeserializeFromJsonBytes(); +} +``` + +## Error handling + +Methods on `DaprJobsClient` will throw a `DaprJobsServiceException` if an issue is encountered between the SDK and the Jobs API service running on the Dapr sidecar. If a failure is encountered because of a poorly formatted request made to the Jobs API service through this SDK, a `DaprMalformedJobException` will be thrown. In case of illegal argument values, the appropriate standard exception will be thrown (e.g. `ArgumentOutOfRangeException` or `ArgumentNullException`) with the name of the offending argument. And for anything else, a `DaprException` will be thrown. + +The most common cases of failure will be related to: + +- Incorrect argument formatting while engaging with the Jobs API +- Transient failures such as a networking problem +- Invalid data, such as a failure to deserialize a value into a type it wasn't originally serialized from + +In any of these cases, you can examine more exception details through the `.InnerException` property. diff --git a/examples/Jobs/JobsSample/JobsSample.csproj b/examples/Jobs/JobsSample/JobsSample.csproj new file mode 100644 index 000000000..4663d1d5b --- /dev/null +++ b/examples/Jobs/JobsSample/JobsSample.csproj @@ -0,0 +1,13 @@ + + + + net8.0 + enable + enable + + + + + + + diff --git a/examples/Jobs/JobsSample/Program.cs b/examples/Jobs/JobsSample/Program.cs new file mode 100644 index 000000000..30ca85ba0 --- /dev/null +++ b/examples/Jobs/JobsSample/Program.cs @@ -0,0 +1,58 @@ +// ------------------------------------------------------------------------ +// Copyright 2024 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +#pragma warning disable CS0618 // Type or member is obsolete +using System.Text; +using Dapr.Jobs; +using Dapr.Jobs.Extensions; +using Dapr.Jobs.Models; +using Dapr.Jobs.Models.Responses; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddDaprJobsClient(); + +var app = builder.Build(); + +//Set a handler to deal with incoming jobs +var cancellationTokenSource = new CancellationTokenSource(TimeSpan.FromSeconds(5)); +app.MapDaprScheduledJobHandler((string? jobName, DaprJobDetails? jobDetails, ILogger? logger, CancellationToken cancellationToken) => +{ + logger?.LogInformation("Received trigger invocation for job '{jobName}'", jobName); + if (jobDetails?.Payload is not null) + { + var deserializedPayload = Encoding.UTF8.GetString(jobDetails.Payload); + logger?.LogInformation("Received invocation for the job '{jobName}' with payload '{deserializedPayload}'", + jobName, deserializedPayload); + //Do something that needs the cancellation token + } + else + { + logger?.LogWarning("Failed to deserialize payload for job '{jobName}'", jobName); + } + return Task.CompletedTask; +}, cancellationTokenSource.Token); + +app.Run(); + +await using var scope = app.Services.CreateAsyncScope(); +var logger = scope.ServiceProvider.GetRequiredService(); +var daprJobsClient = scope.ServiceProvider.GetRequiredService(); + +logger.LogInformation("Scheduling one-time job 'myJob' to execute 10 seconds from now"); +await daprJobsClient.ScheduleJobAsync("myJob", DaprJobSchedule.FromDateTime(DateTime.UtcNow.AddSeconds(10)), + Encoding.UTF8.GetBytes("This is a test")); +logger.LogInformation("Scheduled one-time job 'myJob'"); + + +#pragma warning restore CS0618 // Type or member is obsolete diff --git a/examples/Jobs/JobsSample/Properties/launchSettings.json b/examples/Jobs/JobsSample/Properties/launchSettings.json new file mode 100644 index 000000000..f45ea5b32 --- /dev/null +++ b/examples/Jobs/JobsSample/Properties/launchSettings.json @@ -0,0 +1,38 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:6382", + "sslPort": 44324 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5140", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:7241;http://localhost:5140", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/Dapr.Common/AssemblyInfo.cs b/src/Dapr.Common/AssemblyInfo.cs index a18d03bbc..5044876a9 100644 --- a/src/Dapr.Common/AssemblyInfo.cs +++ b/src/Dapr.Common/AssemblyInfo.cs @@ -16,8 +16,9 @@ [assembly: InternalsVisibleTo("Dapr.Actors, PublicKey=0024000004800000940000000602000000240000525341310004000001000100b1f597635c44597fcecb493e2b1327033b29b1a98ac956a1a538664b68f87d45fbaada0438a15a6265e62864947cc067d8da3a7d93c5eb2fcbb850e396c8684dba74ea477d82a1bbb18932c0efb30b64ff1677f85ae833818707ac8b49ad8062ca01d2c89d8ab1843ae73e8ba9649cd28666b539444dcdee3639f95e2a099bb2")] [assembly: InternalsVisibleTo("Dapr.Actors.Generators, PublicKey=0024000004800000940000000602000000240000525341310004000001000100b1f597635c44597fcecb493e2b1327033b29b1a98ac956a1a538664b68f87d45fbaada0438a15a6265e62864947cc067d8da3a7d93c5eb2fcbb850e396c8684dba74ea477d82a1bbb18932c0efb30b64ff1677f85ae833818707ac8b49ad8062ca01d2c89d8ab1843ae73e8ba9649cd28666b539444dcdee3639f95e2a099bb2")] [assembly: InternalsVisibleTo("Dapr.Actors.AspNetCore, PublicKey=0024000004800000940000000602000000240000525341310004000001000100b1f597635c44597fcecb493e2b1327033b29b1a98ac956a1a538664b68f87d45fbaada0438a15a6265e62864947cc067d8da3a7d93c5eb2fcbb850e396c8684dba74ea477d82a1bbb18932c0efb30b64ff1677f85ae833818707ac8b49ad8062ca01d2c89d8ab1843ae73e8ba9649cd28666b539444dcdee3639f95e2a099bb2")] -[assembly: InternalsVisibleTo("Dapr.Client, PublicKey=0024000004800000940000000602000000240000525341310004000001000100b1f597635c44597fcecb493e2b1327033b29b1a98ac956a1a538664b68f87d45fbaada0438a15a6265e62864947cc067d8da3a7d93c5eb2fcbb850e396c8684dba74ea477d82a1bbb18932c0efb30b64ff1677f85ae833818707ac8b49ad8062ca01d2c89d8ab1843ae73e8ba9649cd28666b539444dcdee3639f95e2a099bb2")] [assembly: InternalsVisibleTo("Dapr.AspNetCore, PublicKey=0024000004800000940000000602000000240000525341310004000001000100b1f597635c44597fcecb493e2b1327033b29b1a98ac956a1a538664b68f87d45fbaada0438a15a6265e62864947cc067d8da3a7d93c5eb2fcbb850e396c8684dba74ea477d82a1bbb18932c0efb30b64ff1677f85ae833818707ac8b49ad8062ca01d2c89d8ab1843ae73e8ba9649cd28666b539444dcdee3639f95e2a099bb2")] +[assembly: InternalsVisibleTo("Dapr.Client, PublicKey=0024000004800000940000000602000000240000525341310004000001000100b1f597635c44597fcecb493e2b1327033b29b1a98ac956a1a538664b68f87d45fbaada0438a15a6265e62864947cc067d8da3a7d93c5eb2fcbb850e396c8684dba74ea477d82a1bbb18932c0efb30b64ff1677f85ae833818707ac8b49ad8062ca01d2c89d8ab1843ae73e8ba9649cd28666b539444dcdee3639f95e2a099bb2")] +[assembly: InternalsVisibleTo("Dapr.Jobs, PublicKey=0024000004800000940000000602000000240000525341310004000001000100b1f597635c44597fcecb493e2b1327033b29b1a98ac956a1a538664b68f87d45fbaada0438a15a6265e62864947cc067d8da3a7d93c5eb2fcbb850e396c8684dba74ea477d82a1bbb18932c0efb30b64ff1677f85ae833818707ac8b49ad8062ca01d2c89d8ab1843ae73e8ba9649cd28666b539444dcdee3639f95e2a099bb2")] [assembly: InternalsVisibleTo("Dapr.Extensions.Configuration, PublicKey=0024000004800000940000000602000000240000525341310004000001000100b1f597635c44597fcecb493e2b1327033b29b1a98ac956a1a538664b68f87d45fbaada0438a15a6265e62864947cc067d8da3a7d93c5eb2fcbb850e396c8684dba74ea477d82a1bbb18932c0efb30b64ff1677f85ae833818707ac8b49ad8062ca01d2c89d8ab1843ae73e8ba9649cd28666b539444dcdee3639f95e2a099bb2")] [assembly: InternalsVisibleTo("Dapr.Workflow, PublicKey=0024000004800000940000000602000000240000525341310004000001000100b1f597635c44597fcecb493e2b1327033b29b1a98ac956a1a538664b68f87d45fbaada0438a15a6265e62864947cc067d8da3a7d93c5eb2fcbb850e396c8684dba74ea477d82a1bbb18932c0efb30b64ff1677f85ae833818707ac8b49ad8062ca01d2c89d8ab1843ae73e8ba9649cd28666b539444dcdee3639f95e2a099bb2")] @@ -38,3 +39,4 @@ [assembly: InternalsVisibleTo("Dapr.E2E.Test.App.Grpc, PublicKey=0024000004800000940000000602000000240000525341310004000001000100b1f597635c44597fcecb493e2b1327033b29b1a98ac956a1a538664b68f87d45fbaada0438a15a6265e62864947cc067d8da3a7d93c5eb2fcbb850e396c8684dba74ea477d82a1bbb18932c0efb30b64ff1677f85ae833818707ac8b49ad8062ca01d2c89d8ab1843ae73e8ba9649cd28666b539444dcdee3639f95e2a099bb2")] [assembly: InternalsVisibleTo("Dapr.E2E.Test.App.ReentrantActors, PublicKey=0024000004800000940000000602000000240000525341310004000001000100b1f597635c44597fcecb493e2b1327033b29b1a98ac956a1a538664b68f87d45fbaada0438a15a6265e62864947cc067d8da3a7d93c5eb2fcbb850e396c8684dba74ea477d82a1bbb18932c0efb30b64ff1677f85ae833818707ac8b49ad8062ca01d2c89d8ab1843ae73e8ba9649cd28666b539444dcdee3639f95e2a099bb2")] [assembly: InternalsVisibleTo("Dapr.Extensions.Configuration.Test, PublicKey=0024000004800000940000000602000000240000525341310004000001000100b1f597635c44597fcecb493e2b1327033b29b1a98ac956a1a538664b68f87d45fbaada0438a15a6265e62864947cc067d8da3a7d93c5eb2fcbb850e396c8684dba74ea477d82a1bbb18932c0efb30b64ff1677f85ae833818707ac8b49ad8062ca01d2c89d8ab1843ae73e8ba9649cd28666b539444dcdee3639f95e2a099bb2")] +[assembly: InternalsVisibleTo("Dapr.Jobs.Test, PublicKey=0024000004800000940000000602000000240000525341310004000001000100b1f597635c44597fcecb493e2b1327033b29b1a98ac956a1a538664b68f87d45fbaada0438a15a6265e62864947cc067d8da3a7d93c5eb2fcbb850e396c8684dba74ea477d82a1bbb18932c0efb30b64ff1677f85ae833818707ac8b49ad8062ca01d2c89d8ab1843ae73e8ba9649cd28666b539444dcdee3639f95e2a099bb2")] diff --git a/src/Dapr.Common/DaprDefaults.cs b/src/Dapr.Common/DaprDefaults.cs index 85a4b18c8..2dd8dd378 100644 --- a/src/Dapr.Common/DaprDefaults.cs +++ b/src/Dapr.Common/DaprDefaults.cs @@ -123,7 +123,9 @@ private static string BuildEndpoint(string? endpoint, int endpointPort) //Attempt to retrieve first from the configuration var configurationValue = configuration?[name]; if (configurationValue is not null) + { return configurationValue; + } //Fall back to the environment variable with the same name or default to an empty string return Environment.GetEnvironmentVariable(name); diff --git a/src/Dapr.Common/DaprGenericClientBuilder.cs b/src/Dapr.Common/DaprGenericClientBuilder.cs new file mode 100644 index 000000000..254953241 --- /dev/null +++ b/src/Dapr.Common/DaprGenericClientBuilder.cs @@ -0,0 +1,213 @@ +using System.Text.Json; +using Grpc.Net.Client; +using Microsoft.Extensions.Configuration; + +namespace Dapr.Common; + +/// +/// Builder for building a generic Dapr client. +/// +public abstract class DaprGenericClientBuilder where TClientBuilder : class +{ + /// + /// Initializes a new instance of the class. + /// + protected DaprGenericClientBuilder(IConfiguration? configuration = null) + { + this.GrpcEndpoint = DaprDefaults.GetDefaultGrpcEndpoint(); + this.HttpEndpoint = DaprDefaults.GetDefaultHttpEndpoint(); + + this.GrpcChannelOptions = new GrpcChannelOptions() + { + // The gRPC client doesn't throw the right exception for cancellation + // by default, this switches that behavior on. + ThrowOperationCanceledOnCancellation = true, + }; + + this.JsonSerializerOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web); + this.DaprApiToken = DaprDefaults.GetDefaultDaprApiToken(configuration); + } + + /// + /// Property exposed for testing purposes. + /// + internal string GrpcEndpoint { get; private set; } + + /// + /// Property exposed for testing purposes. + /// + internal string HttpEndpoint { get; private set; } + + /// + /// Property exposed for testing purposes. + /// + internal Func? HttpClientFactory { get; set; } + + /// + /// Property exposed for testing purposes. + /// + public JsonSerializerOptions JsonSerializerOptions { get; private set; } + + /// + /// Property exposed for testing purposes. + /// + internal GrpcChannelOptions GrpcChannelOptions { get; private set; } + + /// + /// Property exposed for testing purposes. + /// + public string DaprApiToken { get; private set; } + + /// + /// Property exposed for testing purposes. + /// + internal TimeSpan Timeout { get; private set; } + + /// + /// Overrides the HTTP endpoint used by the Dapr client for communicating with the Dapr runtime. + /// + /// + /// The URI endpoint to use for HTTP calls to the Dapr runtime. The default value will be + /// DAPR_HTTP_ENDPOINT first, or http://127.0.0.1:DAPR_HTTP_PORT as fallback + /// where DAPR_HTTP_ENDPOINT and DAPR_HTTP_PORT represents the value of the + /// corresponding environment variables. + /// + /// The instance. + public DaprGenericClientBuilder UseHttpEndpoint(string httpEndpoint) + { + ArgumentVerifier.ThrowIfNullOrEmpty(httpEndpoint, nameof(httpEndpoint)); + this.HttpEndpoint = httpEndpoint; + return this; + } + + /// + /// Exposed internally for testing purposes. + /// + internal DaprGenericClientBuilder UseHttpClientFactory(Func factory) + { + this.HttpClientFactory = factory; + return this; + } + + /// + /// Overrides the legacy mechanism for building an HttpClient and uses the new + /// introduced in .NET Core 2.1. + /// + /// The factory used to create instances. + /// + public DaprGenericClientBuilder UseHttpClientFactory(IHttpClientFactory httpClientFactory) + { + this.HttpClientFactory = httpClientFactory.CreateClient; + return this; + } + + /// + /// Overrides the gRPC endpoint used by the Dapr client for communicating with the Dapr runtime. + /// + /// + /// The URI endpoint to use for gRPC calls to the Dapr runtime. The default value will be + /// http://127.0.0.1:DAPR_GRPC_PORT where DAPR_GRPC_PORT represents the value of the + /// DAPR_GRPC_PORT environment variable. + /// + /// The instance. + public DaprGenericClientBuilder UseGrpcEndpoint(string grpcEndpoint) + { + ArgumentVerifier.ThrowIfNullOrEmpty(grpcEndpoint, nameof(grpcEndpoint)); + this.GrpcEndpoint = grpcEndpoint; + return this; + } + + /// + /// + /// Uses the specified when serializing or deserializing using . + /// + /// + /// The default value is created using . + /// + /// + /// Json serialization options. + /// The instance. + public DaprGenericClientBuilder UseJsonSerializationOptions(JsonSerializerOptions options) + { + this.JsonSerializerOptions = options; + return this; + } + + /// + /// Uses the provided for creating the . + /// + /// The to use for creating the . + /// The instance. + public DaprGenericClientBuilder UseGrpcChannelOptions(GrpcChannelOptions grpcChannelOptions) + { + this.GrpcChannelOptions = grpcChannelOptions; + return this; + } + + /// + /// Adds the provided on every request to the Dapr runtime. + /// + /// The token to be added to the request headers/>. + /// The instance. + public DaprGenericClientBuilder UseDaprApiToken(string apiToken) + { + this.DaprApiToken = apiToken; + return this; + } + + /// + /// Sets the timeout for the HTTP client used by the Dapr client. + /// + /// + /// + public DaprGenericClientBuilder UseTimeout(TimeSpan timeout) + { + this.Timeout = timeout; + return this; + } + + /// + /// Builds out the inner DaprClient that provides the core shape of the + /// runtime gRPC client used by the consuming package. + /// + /// + protected (GrpcChannel channel, HttpClient httpClient, Uri httpEndpoint) BuildDaprClientDependencies() + { + var grpcEndpoint = new Uri(this.GrpcEndpoint); + if (grpcEndpoint.Scheme != "http" && grpcEndpoint.Scheme != "https") + { + throw new InvalidOperationException("The gRPC endpoint must use http or https."); + } + + if (grpcEndpoint.Scheme.Equals(Uri.UriSchemeHttp)) + { + // Set correct switch to make secure gRPC service calls. This switch must be set before creating the GrpcChannel. + AppContext.SetSwitch("System.Net.Http.SocketsHttpHandler.Http2UnencryptedSupport", true); + } + + var httpEndpoint = new Uri(this.HttpEndpoint); + if (httpEndpoint.Scheme != "http" && httpEndpoint.Scheme != "https") + { + throw new InvalidOperationException("The HTTP endpoint must use http or https."); + } + + var channel = GrpcChannel.ForAddress(this.GrpcEndpoint, this.GrpcChannelOptions); + + var httpClient = HttpClientFactory is not null ? HttpClientFactory() : new HttpClient(); + if (this.Timeout > TimeSpan.Zero) + { + httpClient.Timeout = this.Timeout; + } + + return (channel, httpClient, httpEndpoint); + } + + /// + /// Builds the client instance from the properties of the builder. + /// + /// The Dapr client instance. + /// + /// Builds the client instance from the properties of the builder. + /// + public abstract TClientBuilder Build(); +} diff --git a/src/Dapr.Common/Extensions/EnumExtensions.cs b/src/Dapr.Common/Extensions/EnumExtensions.cs new file mode 100644 index 000000000..ff9b43706 --- /dev/null +++ b/src/Dapr.Common/Extensions/EnumExtensions.cs @@ -0,0 +1,27 @@ +using System.Reflection; +using System.Runtime.Serialization; + +namespace Dapr.Common.Extensions; + +internal static class EnumExtensions +{ + /// + /// Reads the value of an enum out of the attached attribute. + /// + /// The enum. + /// The value of the enum to pull the value for. + /// + public static string GetValueFromEnumMember(this T value) where T : Enum + { + ArgumentNullException.ThrowIfNull(value, nameof(value)); + + var memberInfo = typeof(T).GetMember(value.ToString(), BindingFlags.Static | BindingFlags.Public | BindingFlags.DeclaredOnly); + if (memberInfo.Length <= 0) + { + return value.ToString(); + } + + var attributes = memberInfo[0].GetCustomAttributes(typeof(EnumMemberAttribute), false); + return (attributes.Length > 0 ? ((EnumMemberAttribute)attributes[0]).Value : value.ToString()) ?? value.ToString(); + } +} diff --git a/src/Dapr.Jobs/AssemblyInfo.cs b/src/Dapr.Jobs/AssemblyInfo.cs new file mode 100644 index 000000000..870a8dde4 --- /dev/null +++ b/src/Dapr.Jobs/AssemblyInfo.cs @@ -0,0 +1,16 @@ +// ------------------------------------------------------------------------ +// Copyright 2024 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Dapr.Jobs.Test, PublicKey=0024000004800000940000000602000000240000525341310004000001000100b1f597635c44597fcecb493e2b1327033b29b1a98ac956a1a538664b68f87d45fbaada0438a15a6265e62864947cc067d8da3a7d93c5eb2fcbb850e396c8684dba74ea477d82a1bbb18932c0efb30b64ff1677f85ae833818707ac8b49ad8062ca01d2c89d8ab1843ae73e8ba9649cd28666b539444dcdee3639f95e2a099bb2" )] diff --git a/src/Dapr.Jobs/CronExpressionBuilder.cs b/src/Dapr.Jobs/CronExpressionBuilder.cs new file mode 100644 index 000000000..4a165978d --- /dev/null +++ b/src/Dapr.Jobs/CronExpressionBuilder.cs @@ -0,0 +1,492 @@ +// ------------------------------------------------------------------------ +// Copyright 2024 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using System.Runtime.Serialization; +using System.Text.RegularExpressions; +using Dapr.Common.Extensions; +using ArgumentException = System.ArgumentException; +using ArgumentOutOfRangeException = System.ArgumentOutOfRangeException; + +namespace Dapr.Jobs; + +/// +/// A fluent API used to build a valid Cron expression. +/// +public sealed class CronExpressionBuilder +{ + private const string SecondsAndMinutesRegexText = @"([0-5]?\d-[0-5]?\d)|([0-5]?\d,?)|(\*(\/[0-5]?\d)?)"; + private const string HoursRegexText = @"(([0-1]?\d)|(2[0-3])-([0-1]?\d)|(2[0-3]))|(([0-1]?\d)|(2[0-3]),?)|(\*(\/([0-1]?\d)|(2[0-3]))?)"; + private const string DayOfMonthRegexText = @"\*|(\*\/(([0-2]?\d)|(3[0-1])))|(((([0-2]?\d)|(3[0-1]))(-(([0-2]?\d)|(3[0-1])))?))"; + private const string MonthRegexText = @"(^(\*\/)?((0?\d)|(1[0-2]))$)|(^\*$)|(^((0?\d)|(1[0-2]))(-((0?\d)|(1[0-2]))?)$)|(^(?:JAN|FEB|MAR|APR|MAY|JUN|JUL|AUG|SEP|OCT|NOV|DEC)(?:-(?:JAN|FEB|MAR|APR|MAY|JUN|JUL|AUG|SEP|OCT|NOV|DEC))?(?:,(?:JAN|FEB|MAR|APR|MAY|JUN|JUL|AUG|SEP|OCT|NOV|DEC)(?:-(?:JAN|FEB|MAR|APR|MAY|JUN|JUL|AUG|SEP|OCT|NOV|DEC))?)*$)"; + private const string DayOfWeekRegexText = @"\*|(\*\/(0?[0-6])|(0?[0-6](-0?[0-6])?)|((,?(SUN|MON|TUE|WED|THU|FRI|SAT))+)|((SUN|MON|TUE|WED|THU|FRI|SAT)(-(SUN|MON|TUE|WED|THU|FRI|SAT))?))"; + + private static readonly Regex cronExpressionRegex = + new( + $"{SecondsAndMinutesRegexText} {SecondsAndMinutesRegexText} {HoursRegexText} {DayOfMonthRegexText} {MonthRegexText} {DayOfWeekRegexText}", RegexOptions.Compiled); + + private string seconds = "*"; + private string minutes = "*"; + private string hours = "*"; + private string dayOfMonth = "*"; + private string month = "*"; + private string dayOfWeek = "*"; + + /// + /// Reflects an expression in which the developer specifies a series of numeric values and the period they're associated + /// with indicating when the trigger should occur. + /// + /// The period of time within which the values should be associated. + /// The numerical values of the time period on which the schedule should trigger. + /// + public CronExpressionBuilder On(OnCronPeriod period, params int[] values) + { + switch (period) + { + //Validate by period + case OnCronPeriod.Second or OnCronPeriod.Minute or OnCronPeriod.Hour when values.Any(a => a is < 0 or > 59): + throw new ArgumentOutOfRangeException(nameof(values), "All values must be within 0 and 59, inclusively."); + case OnCronPeriod.DayOfMonth when values.Any(a => a is < 0 or > 31): + throw new ArgumentOutOfRangeException(nameof(values), "All values must be within 1 and 31, inclusively."); + } + + var strValue = string.Join(',', values.Distinct().OrderBy(a => a)); + + switch (period) + { + case OnCronPeriod.Second: + seconds = strValue; + break; + case OnCronPeriod.Minute: + minutes = strValue; + break; + case OnCronPeriod.Hour: + hours = strValue; + break; + case OnCronPeriod.DayOfMonth: + dayOfMonth = strValue; + break; + } + + return this; + } + + /// + /// Reflects an expression in which the developer specifies a series of months in the year on which the trigger should occur. + /// + /// The months of the year to invoke the trigger on. + public CronExpressionBuilder On(params MonthOfYear[] months) + { + month = string.Join(',', months.Distinct().OrderBy(a => a).Select(a => a.GetValueFromEnumMember())); + return this; + } + + /// + /// Reflects an expression in which the developer specifies a series of days of the week on which the trigger should occur. + /// + /// The days of the week to invoke the trigger on. + public CronExpressionBuilder On(params DayOfWeek[] days) + { + dayOfWeek = string.Join(',', days.Distinct().OrderBy(a => a).Select(a => a.GetValueFromEnumMember())); + return this; + } + + /// + /// Reflects an expression in which the developer defines bounded range of numerical values for the specified period. + /// + /// The period of time within which the values should be associated. + /// The start of the range. + /// The end of the range. + public CronExpressionBuilder Through(ThroughCronPeriod period, int from, int to) + { + if (from > to) + { + throw new ArgumentException("The date representing the From property should precede the To property"); + } + + if (from == to) + { + throw new ArgumentException("The From and To properties should not be equivalent"); + } + + var stringValue = $"{from}-{to}"; + + switch (period) + { + case ThroughCronPeriod.Second: + seconds = stringValue; + break; + case ThroughCronPeriod.Minute: + minutes = stringValue; + break; + case ThroughCronPeriod.Hour: + hours = stringValue; + break; + case ThroughCronPeriod.DayOfMonth: + dayOfMonth = stringValue; + break; + case ThroughCronPeriod.Month: + month = stringValue; + break; + } + + return this; + } + + /// + /// Reflects an expression in which the developer defines a bounded range of days. + /// + /// The start of the range. + /// The end of the range. + public CronExpressionBuilder Through(DayOfWeek from, DayOfWeek to) + { + if (from > to) + { + throw new ArgumentException("The day representing the From property should precede the To property"); + } + + if (from == to) + { + throw new ArgumentException("The From and To properties should not be equivalent"); + } + + dayOfWeek = $"{from.GetValueFromEnumMember()}-{to.GetValueFromEnumMember()}"; + return this; + } + + /// + /// Reflects an expression in which the developer defines a bounded range of months. + /// + /// The start of the range. + /// The end of the range. + public CronExpressionBuilder Through(MonthOfYear from, MonthOfYear to) + { + if (from > to) + { + throw new ArgumentException("The month representing the From property should precede the To property"); + } + + if (from == to) + { + throw new ArgumentException("The From and To properties should not be equivalent"); + } + + month = $"{from.GetValueFromEnumMember()}-{to.GetValueFromEnumMember()}"; + return this; + } + + /// + /// Reflects an expression in which the trigger should happen each time the value of the specified period changes. + /// + /// The period of time that should be evaluated. + /// + public CronExpressionBuilder Each(CronPeriod period) + { + switch (period) + { + case CronPeriod.Second: + seconds = "*"; + break; + case CronPeriod.Minute: + minutes = "*"; + break; + case CronPeriod.Hour: + hours = "*"; + break; + case CronPeriod.DayOfMonth: + dayOfMonth = "*"; + break; + case CronPeriod.Month: + month = "*"; + break; + case CronPeriod.DayOfWeek: + dayOfWeek = "*"; + break; + } + + return this; + } + + /// + /// Reflects an expression in which the trigger should happen at a regular interval of the specified period type. + /// + /// The length of time represented in a unit interval. + /// The number of period units that should elapse between each trigger. + /// + public CronExpressionBuilder Every(EveryCronPeriod period, int interval) + { + if (interval < 0) + { + throw new ArgumentOutOfRangeException(nameof(interval)); + } + + var value = $"*/{interval}"; + + switch (period) + { + case EveryCronPeriod.Second: + seconds = value; + break; + case EveryCronPeriod.Minute: + minutes = value; + break; + case EveryCronPeriod.Hour: + hours = value; + break; + case EveryCronPeriod.Month: + month = value; + break; + case EveryCronPeriod.DayInMonth: + dayOfMonth = value; + break; + case EveryCronPeriod.DayInWeek: + dayOfWeek = value; + break; + } + + return this; + } + + /// + /// Validates whether a given expression is valid Cron syntax. + /// + /// The string to evaluate. + /// True if the expression is valid Cron syntax; false if not. + internal static bool IsCronExpression(string expression) => expression.Split(' ').Length == 6 && cronExpressionRegex.IsMatch(expression); + + /// + /// Builds the Cron expression. + /// + /// + public override string ToString() => $"{seconds} {minutes} {hours} {dayOfMonth} {month} {dayOfWeek}"; +} + +/// +/// Identifies the valid Cron periods in an "On" expression. +/// +public enum OnCronPeriod +{ + /// + /// Identifies the second value for an "On" expression. + /// + Second, + /// + /// Identifies the minute value for an "On" expression. + /// + Minute, + /// + /// Identifies the hour value for an "On" expression. + /// + Hour, + /// + /// Identifies the day in the month for an "On" expression. + /// + DayOfMonth +} + +/// +/// Identifies the valid Cron periods in an "Every" expression. +/// +public enum EveryCronPeriod +{ + /// + /// Identifies the second value in an "Every" expression. + /// + Second, + /// + /// Identifies the minute value in an "Every" expression. + /// + Minute, + /// + /// Identifies the hour value in an "Every" expression. + /// + Hour, + /// + /// Identifies the month value in an "Every" expression. + /// + Month, + /// + /// Identifies the days in the month value in an "Every" expression. + /// + DayInMonth, + /// + /// Identifies the days in the week value in an "Every" expression. + /// + DayInWeek, +} + +/// +/// Identifies the various Cron periods valid to use in a "Through" expression. +/// +public enum ThroughCronPeriod +{ + /// + /// Identifies the second value in the Cron expression. + /// + Second, + /// + /// Identifies the minute value in the Cron expression. + /// + Minute, + /// + /// Identifies the hour value in the Cron expression. + /// + Hour, + /// + /// Identifies the day of month value in the Cron expression. + /// + DayOfMonth, + /// + /// Identifies the month value in the Cron expression. + /// + Month +} + +/// +/// Identifies the various Cron periods. +/// +public enum CronPeriod +{ + /// + /// Identifies the second value in the Cron expression. + /// + Second, + /// + /// Identifies the minute value in the Cron expression. + /// + Minute, + /// + /// Identifies the hour value in the Cron expression. + /// + Hour, + /// + /// Identifies the day of month value in the Cron expression. + /// + DayOfMonth, + /// + /// Identifies the month value in the Cron expression. + /// + Month, + /// + /// Identifies the day of week value in the Cron expression. + /// + DayOfWeek +} + +/// +/// Identifies the days in the week. +/// +public enum DayOfWeek +{ + /// + /// Sunday. + /// + [EnumMember(Value = "SUN")] + Sunday = 0, + /// + /// Monday. + /// + [EnumMember(Value = "MON")] + Monday = 1, + /// + /// Tuesday. + /// + [EnumMember(Value = "TUE")] + Tuesday = 2, + /// + /// Wednesday. + /// + [EnumMember(Value = "WED")] + Wednesday = 3, + /// + /// Thursday. + /// + [EnumMember(Value = "THU")] + Thursday = 4, + /// + /// Friday. + /// + [EnumMember(Value = "FRI")] + Friday = 5, + /// + /// Saturday. + /// + [EnumMember(Value = "SAT")] + Saturday = 6 +} + +/// +/// Identifies the months in the year. +/// +public enum MonthOfYear +{ + /// + /// Month of January. + /// + [EnumMember(Value = "JAN")] + January = 1, + /// + /// Month of February. + /// + [EnumMember(Value = "FEB")] + February = 2, + /// + /// Month of March. + /// + [EnumMember(Value = "MAR")] + March = 3, + /// + /// Month of April. + /// + [EnumMember(Value = "APR")] + April = 4, + /// + /// Month of May. + /// + [EnumMember(Value = "MAY")] + May = 5, + /// + /// Month of June. + /// + [EnumMember(Value = "JUN")] + June = 6, + /// + /// Month of July. + /// + [EnumMember(Value = "JUL")] + July = 7, + /// + /// Month of August. + /// + [EnumMember(Value = "AUG")] + August = 8, + /// + /// Month of September. + /// + [EnumMember(Value = "SEP")] + September = 9, + /// + /// Month of October. + /// + [EnumMember(Value = "OCT")] + October = 10, + /// + /// Month of November. + /// + [EnumMember(Value = "NOV")] + November = 11, + /// + /// Month of December. + /// + [EnumMember(Value = "DEC")] + December = 12 +} diff --git a/src/Dapr.Jobs/Dapr.Jobs.csproj b/src/Dapr.Jobs/Dapr.Jobs.csproj new file mode 100644 index 000000000..74c9bec23 --- /dev/null +++ b/src/Dapr.Jobs/Dapr.Jobs.csproj @@ -0,0 +1,29 @@ + + + + net6;net8 + enable + enable + Dapr.Jobs + Dapr Jobs Authoring SDK + Dapr Jobs SDK for scheduling jobs and tasks with Dapr + alpha + + + + + + + + + + + + + + + + + + + diff --git a/src/Dapr.Jobs/DaprJobsClient.cs b/src/Dapr.Jobs/DaprJobsClient.cs new file mode 100644 index 000000000..4dd4abd70 --- /dev/null +++ b/src/Dapr.Jobs/DaprJobsClient.cs @@ -0,0 +1,99 @@ +// ------------------------------------------------------------------------ +// Copyright 2024 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using Dapr.Jobs.Models; +using Dapr.Jobs.Models.Responses; + +namespace Dapr.Jobs; + +/// +/// +/// Defines client operations for managing Dapr jobs. +/// Use to create a or register +/// for use with dependency injection via +/// DaprJobsServiceCollectionExtensions.AddDaprJobsClient. +/// +/// +/// Implementations of implement because the +/// client accesses network resources. For best performance, create a single long-lived client instance +/// and share it for the lifetime of the application. This is done for you if created via the DI extensions. Avoid +/// creating a disposing a client instance for each operation that the application performs - this can lead to socket +/// exhaustion and other problems. +/// +/// +public abstract class DaprJobsClient : IDisposable +{ + private bool disposed; + + /// + /// Schedules a job with Dapr. + /// + /// The name of the job being scheduled. + /// The schedule defining when the job will be triggered. + /// The main payload of the job. + /// The optional point-in-time from which the job schedule should start. + /// The optional number of times the job should be triggered. + /// Represents when the job should expire. If both this and DueTime are set, TTL needs to represent a later point in time. + /// Cancellation token. + [Obsolete( + "The API is currently not stable as it is in the Alpha stage. This attribute will be removed once it is stable.")] + public abstract Task ScheduleJobAsync(string jobName, DaprJobSchedule schedule, + ReadOnlyMemory? payload = null, DateTimeOffset? startingFrom = null, int? repeats = null, + DateTimeOffset? ttl = null, + CancellationToken cancellationToken = default); + + /// + /// Retrieves the details of a registered job. + /// + /// The jobName of the job. + /// Cancellation token. + /// The details comprising the job. + [Obsolete("The API is currently not stable as it is in the Alpha stage. This attribute will be removed once it is stable.")] + public abstract Task GetJobAsync(string jobName, CancellationToken cancellationToken = default); + + /// + /// Deletes the specified job. + /// + /// The jobName of the job. + /// Cancellation token. + [Obsolete("The API is currently not stable as it is in the Alpha stage. This attribute will be removed once it is stable.")] + public abstract Task DeleteJobAsync(string jobName, CancellationToken cancellationToken = default); + + internal static KeyValuePair? GetDaprApiTokenHeader(string apiToken) + { + if (string.IsNullOrWhiteSpace(apiToken)) + { + return null; + } + + return new KeyValuePair("dapr-api-token", apiToken); + } + + /// + public void Dispose() + { + if (!this.disposed) + { + Dispose(disposing: true); + this.disposed = true; + } + } + + /// + /// Disposes the resources associated with the object. + /// + /// true if called by a call to the Dispose method; otherwise false. + protected virtual void Dispose(bool disposing) + { + } +} diff --git a/src/Dapr.Jobs/DaprJobsClientBuilder.cs b/src/Dapr.Jobs/DaprJobsClientBuilder.cs new file mode 100644 index 000000000..390d52236 --- /dev/null +++ b/src/Dapr.Jobs/DaprJobsClientBuilder.cs @@ -0,0 +1,37 @@ +// ------------------------------------------------------------------------ +// Copyright 2024 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using Dapr.Common; +using Autogenerated = Dapr.Client.Autogen.Grpc.v1; + +namespace Dapr.Jobs; + +/// +/// Builds a . +/// +public sealed class DaprJobsClientBuilder : DaprGenericClientBuilder +{ + /// + /// Builds the client instance from the properties of the builder. + /// + /// The Dapr client instance. + public override DaprJobsClient Build() + { + var daprClientDependencies = this.BuildDaprClientDependencies(); + + var client = new Autogenerated.Dapr.DaprClient(daprClientDependencies.channel); + var apiTokenHeader = this.DaprApiToken is not null ? DaprJobsClient.GetDaprApiTokenHeader(this.DaprApiToken) : null; + + return new DaprJobsGrpcClient(client, daprClientDependencies.httpClient, apiTokenHeader); + } +} diff --git a/src/Dapr.Jobs/DaprJobsGrpcClient.cs b/src/Dapr.Jobs/DaprJobsGrpcClient.cs new file mode 100644 index 000000000..f23ef67fd --- /dev/null +++ b/src/Dapr.Jobs/DaprJobsGrpcClient.cs @@ -0,0 +1,248 @@ +// ------------------------------------------------------------------------ +// Copyright 2024 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using System.Net.Http.Headers; +using System.Reflection; +using Dapr.Jobs.Models; +using Dapr.Jobs.Models.Responses; +using Google.Protobuf; +using Google.Protobuf.WellKnownTypes; +using Grpc.Core; +using Autogenerated = Dapr.Client.Autogen.Grpc.v1; + +namespace Dapr.Jobs; + +/// +/// A client for interacting with the Dapr endpoints. +/// +internal sealed class DaprJobsGrpcClient : DaprJobsClient +{ + /// + /// Present only for testing purposes. + /// + internal readonly HttpClient httpClient; + + /// + /// Used to populate options headers with API token value. + /// + internal readonly KeyValuePair? apiTokenHeader; + + private readonly Autogenerated.Dapr.DaprClient client; + private readonly string userAgent = UserAgent().ToString(); + + // property exposed for testing purposes + internal Autogenerated.Dapr.DaprClient Client => client; + + internal DaprJobsGrpcClient( + Autogenerated.Dapr.DaprClient innerClient, + HttpClient httpClient, + KeyValuePair? apiTokenHeader) + { + this.client = innerClient; + this.httpClient = httpClient; + this.apiTokenHeader = apiTokenHeader; + } + + /// + /// Schedules a job with Dapr. + /// + /// The name of the job being scheduled. + /// The schedule defining when the job will be triggered. + /// The main payload of the job. + /// The optional point-in-time from which the job schedule should start. + /// The optional number of times the job should be triggered. + /// Represents when the job should expire. If both this and DueTime are set, TTL needs to represent a later point in time. + /// Cancellation token. + [Obsolete("The API is currently not stable as it is in the Alpha stage. This attribute will be removed once it is stable.")] + public override async Task ScheduleJobAsync(string jobName, DaprJobSchedule schedule, + ReadOnlyMemory? payload = null, DateTimeOffset? startingFrom = null, int? repeats = null, + DateTimeOffset? ttl = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(jobName, nameof(jobName)); + ArgumentNullException.ThrowIfNull(schedule, nameof(schedule)); + + var job = new Autogenerated.Job { Name = jobName, Schedule = schedule.ExpressionValue }; + + if (startingFrom is not null) + { + job.DueTime = ((DateTimeOffset)startingFrom).ToString("O"); + } + + if (repeats is not null) + { + if (repeats < 0) + { + throw new ArgumentOutOfRangeException(nameof(repeats)); + } + + job.Repeats = (uint)repeats; + } + + if (payload is not null) + { + job.Data = new Any { Value = ByteString.CopyFrom(payload.Value.Span), TypeUrl = "dapr.io/schedule/jobpayload" }; + } + + if (ttl is not null) + { + if (ttl <= startingFrom) + { + throw new ArgumentException( + $"When both {nameof(ttl)} and {nameof(startingFrom)} are specified, {nameof(ttl)} must represent a later point in time"); + } + + job.Ttl = ((DateTimeOffset)ttl).ToString("O"); + } + + var envelope = new Autogenerated.ScheduleJobRequest { Job = job }; + + var callOptions = CreateCallOptions(headers: null, cancellationToken); + + try + { + await client.ScheduleJobAlpha1Async(envelope, callOptions); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + //Ignore our own cancellation + } + catch (RpcException ex) when (ex.StatusCode == StatusCode.Cancelled && cancellationToken.IsCancellationRequested) + { + // Ignore a remote cancellation due to our own cancellation + } + catch (Exception ex) + { + throw new DaprException( + "Schedule job operation failed: the Dapr endpoint indicated a failure. See InnerException for details.", + ex); + } + } + + /// + /// Retrieves the details of a registered job. + /// + /// The name of the job. + /// Cancellation token. + /// The details comprising the job. + [Obsolete("The API is currently not stable as it is in the Alpha stage. This attribute will be removed once it is stable.")] + public override async Task GetJobAsync(string jobName, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(jobName)) + { + throw new ArgumentNullException(nameof(jobName)); + } + + try + { + var envelope = new Autogenerated.GetJobRequest { Name = jobName }; + var callOptions = CreateCallOptions(headers: null, cancellationToken); + var response = await client.GetJobAlpha1Async(envelope, callOptions); + return new DaprJobDetails(new DaprJobSchedule(response.Job.Schedule)) + { + DueTime = response.Job.DueTime is not null ? DateTime.Parse(response.Job.DueTime) : null, + Ttl = response.Job.Ttl is not null ? DateTime.Parse(response.Job.Ttl) : null, + RepeatCount = response.Job.Repeats == default ? null : (int?)response.Job.Repeats, + Payload = response.Job.Data.ToByteArray() + }; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + //Ignore our own cancellation + } + catch (RpcException ex) when (ex.StatusCode == StatusCode.Cancelled && cancellationToken.IsCancellationRequested) + { + // Ignore a remote cancellation due to our own cancellation + } + catch (Exception ex) + { + throw new DaprException( + "Get job operation failed: the Dapr endpoint indicated a failure. See InnerException for details.", ex); + } + + throw new DaprException("Get job operation failed: the Dapr endpoint did not return the expected value."); + } + + /// + /// Deletes the specified job. + /// + /// The name of the job. + /// Cancellation token. + /// + [Obsolete("The API is currently not stable as it is in the Alpha stage. This attribute will be removed once it is stable.")] + public override async Task DeleteJobAsync(string jobName, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(jobName)) + { + throw new ArgumentNullException(nameof(jobName)); + } + + try + { + var envelope = new Autogenerated.DeleteJobRequest { Name = jobName }; + var callOptions = CreateCallOptions(headers: null, cancellationToken); + await client.DeleteJobAlpha1Async(envelope, callOptions); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + //Ignore our own cancellation + } + catch (RpcException ex) when (ex.StatusCode == StatusCode.Cancelled && cancellationToken.IsCancellationRequested) + { + // Ignore a remote cancellation due to our own cancellation + } + catch (Exception ex) + { + throw new DaprException( + "Delete job operation failed: the Dapr endpoint indicated a failure. See InnerException for details.", + ex); + } + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + this.httpClient.Dispose(); + } + } + + private CallOptions CreateCallOptions(Metadata? headers, CancellationToken cancellationToken) + { + var callOptions = new CallOptions(headers: headers ?? new Metadata(), cancellationToken: cancellationToken); + + callOptions.Headers!.Add("User-Agent", this.userAgent); + + if (apiTokenHeader is not null) + { + callOptions.Headers.Add(apiTokenHeader.Value.Key, apiTokenHeader.Value.Value); + } + + return callOptions; + } + + /// + /// Returns the value for the User-Agent. + /// + /// A containing the value to use for the User-Agent. + private static ProductInfoHeaderValue UserAgent() + { + var assembly = typeof(DaprJobsClient).Assembly; + var assemblyVersion = assembly + .GetCustomAttributes() + .FirstOrDefault()? + .InformationalVersion; + + return new ProductInfoHeaderValue("dapr-sdk-dotnet", $"v{assemblyVersion}"); + } +} diff --git a/src/Dapr.Jobs/Extensions/DaprJobsServiceCollectionExtensions.cs b/src/Dapr.Jobs/Extensions/DaprJobsServiceCollectionExtensions.cs new file mode 100644 index 000000000..67e718985 --- /dev/null +++ b/src/Dapr.Jobs/Extensions/DaprJobsServiceCollectionExtensions.cs @@ -0,0 +1,78 @@ +// ------------------------------------------------------------------------ +// Copyright 2024 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Dapr.Jobs.Extensions; + +/// +/// Contains extension methods for using Dapr Jobs with dependency injection. +/// +public static class DaprJobsServiceCollectionExtensions +{ + /// + /// Adds Dapr Jobs client support to the service collection. + /// + /// The . + /// Optionally allows greater configuration of the . + public static IServiceCollection AddDaprJobsClient(this IServiceCollection serviceCollection, Action? configure = null) + { + ArgumentNullException.ThrowIfNull(serviceCollection, nameof(serviceCollection)); + + //Register the IHttpClientFactory implementation + serviceCollection.AddHttpClient(); + + serviceCollection.TryAddSingleton(serviceProvider => + { + var httpClientFactory = serviceProvider.GetRequiredService(); + + var builder = new DaprJobsClientBuilder(); + builder.UseHttpClientFactory(httpClientFactory); + + configure?.Invoke(builder); + + return builder.Build(); + }); + + return serviceCollection; + } + + /// + /// Adds Dapr Jobs client support to the service collection. + /// + /// The . + /// Optionally allows greater configuration of the using injected services. + /// + public static IServiceCollection AddDaprJobsClient(this IServiceCollection serviceCollection, Action? configure) + { + ArgumentNullException.ThrowIfNull(serviceCollection, nameof(serviceCollection)); + + //Register the IHttpClientFactory implementation + serviceCollection.AddHttpClient(); + + serviceCollection.TryAddSingleton(serviceProvider => + { + var httpClientFactory = serviceProvider.GetRequiredService(); + + var builder = new DaprJobsClientBuilder(); + builder.UseHttpClientFactory(httpClientFactory); + + configure?.Invoke(serviceProvider, builder); + + return builder.Build(); + }); + + return serviceCollection; + } +} diff --git a/src/Dapr.Jobs/Extensions/DaprSerializationExtensions.cs b/src/Dapr.Jobs/Extensions/DaprSerializationExtensions.cs new file mode 100644 index 000000000..1f02a32cd --- /dev/null +++ b/src/Dapr.Jobs/Extensions/DaprSerializationExtensions.cs @@ -0,0 +1,78 @@ +// ------------------------------------------------------------------------ +// Copyright 2024 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using System.Text; +using System.Text.Json; +using Dapr.Jobs.Models; + +namespace Dapr.Jobs.Extensions; + +/// +/// Provides helper extensions for performing serialization operations when scheduling one-time Cron jobs for the developer. +/// +public static class DaprJobsSerializationExtensions +{ + /// + /// Default JSON serializer options. + /// + private static readonly JsonSerializerOptions defaultOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web); + + /// + /// Schedules a job with Dapr. + /// + /// The instance. + /// The name of the job being scheduled. + /// The schedule defining when the job will be triggered. + /// The main payload of the job expressed as a JSON-serializable object. + /// The optional point-in-time from which the job schedule should start. + /// The optional number of times the job should be triggered. + /// Optional JSON serialization options. + /// Represents when the job should expire. If both this and DueTime are set, TTL needs to represent a later point in time. + /// Cancellation token. + [Obsolete("The API is currently not stable as it is in the Alpha stage. This attribute will be removed once it is stable.")] + public static async Task ScheduleJobWithPayloadAsync(this DaprJobsClient client, string jobName, DaprJobSchedule schedule, + object payload, DateTime? startingFrom = null, int? repeats = null, JsonSerializerOptions? jsonSerializerOptions = null, DateTimeOffset? ttl = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(payload, nameof(payload)); + + var serializerOptions = jsonSerializerOptions ?? defaultOptions; + var payloadBytes = + JsonSerializer.SerializeToUtf8Bytes(payload, serializerOptions); + + await client.ScheduleJobAsync(jobName, schedule, payloadBytes, startingFrom, repeats, ttl, cancellationToken); + } + + /// + /// Schedules a job with Dapr. + /// + /// The instance. + /// The name of the job being scheduled. + /// The schedule defining when the job will be triggered. + /// The main payload of the job expressed as a string. + /// The optional point-in-time from which the job schedule should start. + /// The optional number of times the job should be triggered. + /// Represents when the job should expire. If both this and DueTime are set, TTL needs to represent a later point in time. + /// Cancellation token. + [Obsolete("The API is currently not stable as it is in the Alpha stage. This attribute will be removed once it is stable.")] + public static async Task ScheduleJobWithPayloadAsync(this DaprJobsClient client, string jobName, DaprJobSchedule schedule, + string payload, DateTime? startingFrom = null, int? repeats = null, DateTimeOffset? ttl = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(payload, nameof(payload)); + + var payloadBytes = Encoding.UTF8.GetBytes(payload); + + await client.ScheduleJobAsync(jobName, schedule, payloadBytes, startingFrom, repeats, ttl, cancellationToken); + } +} diff --git a/src/Dapr.Jobs/Extensions/EndpointRouteBuilderExtensions.cs b/src/Dapr.Jobs/Extensions/EndpointRouteBuilderExtensions.cs new file mode 100644 index 000000000..26ef579cc --- /dev/null +++ b/src/Dapr.Jobs/Extensions/EndpointRouteBuilderExtensions.cs @@ -0,0 +1,95 @@ +// ------------------------------------------------------------------------ +// Copyright 2024 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using System.Text.Json; +using Dapr.Jobs.Models.Responses; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; + +namespace Dapr.Jobs.Extensions; + +/// +/// Provides extension methods to register endpoints for Dapr Job Scheduler invocations. +/// +public static class EndpointRouteBuilderExtensions +{ + /// + /// Provides for a handler to be provided that allows the user to dictate how various jobs should be handled without + /// necessarily knowing the name of the job at build time. + /// + /// The to add the route to. + /// The asynchronous action provided by the developer that handles any inbound requests. The first two + /// parameters must be a nullable for the jobName and a nullable with the + /// payload details, but otherwise can be populated with additional services to be injected into the delegate. + /// Cancellation token that will be passed in as the last parameter to the delegate action. + public static IEndpointRouteBuilder MapDaprScheduledJobHandler(this IEndpointRouteBuilder endpoints, + Delegate action, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(endpoints, nameof(endpoints)); + ArgumentNullException.ThrowIfNull(action, nameof(action)); + + endpoints.MapPost("/job/{jobName}", async context => + { + var jobName = (string?)context.Request.RouteValues["jobName"]; + DaprJobDetails? jobPayload = null; + + if (context.Request.ContentLength is > 0) + { + using var reader = new StreamReader(context.Request.Body); + var body = await reader.ReadToEndAsync(); + + try + { + var deserializedJobPayload = JsonSerializer.Deserialize(body); + jobPayload = deserializedJobPayload?.ToType() ?? null; + } + catch (JsonException) + { + jobPayload = null; + } + } + + var parameters = new Dictionary + { + { typeof(string), jobName }, + { typeof(DaprJobDetails), jobPayload }, + { typeof(CancellationToken), CancellationToken.None } + }; + + var actionParameters = action.Method.GetParameters(); + var invokeParameters = new object?[actionParameters.Length]; + + for (var a = 0; a < actionParameters.Length; a++) + { + var parameterType = actionParameters[a].ParameterType; + + if (parameters.TryGetValue(parameterType, out var value)) + { + invokeParameters[a] = value; + } + else + { + invokeParameters[a] = context.RequestServices.GetService(parameterType); + } + } + + var result = action.DynamicInvoke(invokeParameters.ToArray()); + if (result is Task task) + { + await task; + } + }); + + return endpoints; + } +} diff --git a/src/Dapr.Jobs/Extensions/StringExtensions.cs b/src/Dapr.Jobs/Extensions/StringExtensions.cs new file mode 100644 index 000000000..98e3525de --- /dev/null +++ b/src/Dapr.Jobs/Extensions/StringExtensions.cs @@ -0,0 +1,27 @@ +// ------------------------------------------------------------------------ +// Copyright 2024 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +namespace Dapr.Jobs.Extensions; + +internal static class StringExtensions +{ + /// + /// Extension method that validates a string against a list of possible matches. + /// + /// The string value to evaluate. + /// The possible values to look for a match within. + /// The type of string comparison to perform. + /// True if the value ends with any of the possible values; otherwise false. + public static bool EndsWithAny(this string value, IReadOnlyList possibleValues, + StringComparison comparisonType) => possibleValues.Any(val => value.EndsWith(val, comparisonType)); +} diff --git a/src/Dapr.Jobs/Extensions/TimeSpanExtensions.cs b/src/Dapr.Jobs/Extensions/TimeSpanExtensions.cs new file mode 100644 index 000000000..2c6b1af98 --- /dev/null +++ b/src/Dapr.Jobs/Extensions/TimeSpanExtensions.cs @@ -0,0 +1,117 @@ +// ------------------------------------------------------------------------ +// Copyright 2024 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using System.Text; +using System.Text.RegularExpressions; + +namespace Dapr.Jobs; + +/// +/// Provides extension methods used with . +/// +internal static class TimeSpanExtensions +{ + private static readonly Regex hourRegex = new Regex(@"(\d+)h", RegexOptions.Compiled); + private static readonly Regex minuteRegex = new Regex(@"(\d+)m", RegexOptions.Compiled); + private static readonly Regex secondRegex = new Regex(@"(\d+)s", RegexOptions.Compiled); + private static readonly Regex millisecondRegex = new Regex(@"(\d+)q", RegexOptions.Compiled); + + /// + /// Creates a duration string that matches the specification at https://pkg.go.dev/time#ParseDuration per the + /// Jobs API specification https://v1-14.docs.dapr.io/reference/api/jobs_api/#schedule-a-job. + /// + /// The timespan being evaluated. + /// + public static string ToDurationString(this TimeSpan timespan) + { + var sb = new StringBuilder(); + + //Hours is the largest unit of measure in the duration string + if (timespan.Hours > 0) + { + sb.Append($"{timespan.Hours}h"); + } + + if (timespan.Minutes > 0) + { + sb.Append($"{timespan.Minutes}m"); + } + + if (timespan.Seconds > 0) + { + sb.Append($"{timespan.Seconds}s"); + } + + if (timespan.Milliseconds > 0) + { + sb.Append($"{timespan.Milliseconds}ms"); + } + + return sb.ToString(); + } + + /// + /// Validates whether a given string represents a parseable Golang duration string. + /// + /// The duration string to parse. + /// True if the string represents a parseable interval duration; false if not. + public static bool IsDurationString(this string interval) + { + interval = interval.Replace("ms", "q"); + return hourRegex.Match(interval).Success || + minuteRegex.Match(interval).Success || + secondRegex.Match(interval).Success || + millisecondRegex.Match(interval).Success; + } + + /// + /// Creates a given a Golang duration string. + /// + /// The duration string to parse. + /// A timespan value. + public static TimeSpan FromDurationString(this string interval) + { + interval = interval.Replace("ms", "q"); + + int hours = 0; + int minutes = 0; + int seconds = 0; + int milliseconds = 0; + + var hourMatch = hourRegex.Match(interval); + if (hourMatch.Success) + { + hours = int.Parse(hourMatch.Groups[1].Value); + } + + var minuteMatch = minuteRegex.Match(interval); + if (minuteMatch.Success) + { + minutes = int.Parse(minuteMatch.Groups[1].Value); + } + + var secondMatch = secondRegex.Match(interval); + if (secondMatch.Success) + { + seconds = int.Parse(secondMatch.Groups[1].Value); + } + + var millisecondMatch = millisecondRegex.Match(interval); + if (millisecondMatch.Success) + { + milliseconds = int.Parse(millisecondMatch.Groups[1].Value); + } + + return new TimeSpan(0, hours, minutes, seconds, milliseconds); + } +} diff --git a/src/Dapr.Jobs/JsonConverters/DaprJobScheduleConverter.cs b/src/Dapr.Jobs/JsonConverters/DaprJobScheduleConverter.cs new file mode 100644 index 000000000..b42051469 --- /dev/null +++ b/src/Dapr.Jobs/JsonConverters/DaprJobScheduleConverter.cs @@ -0,0 +1,41 @@ +// ------------------------------------------------------------------------ +// Copyright 2024 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using System.Text.Json; +using System.Text.Json.Serialization; +using Dapr.Jobs.Models; + +namespace Dapr.Jobs.JsonConverters; + +internal sealed class DaprJobScheduleConverter : JsonConverter +{ + /// Reads and converts the JSON to type . + /// The reader. + /// The type to convert. + /// An object that specifies serialization options to use. + /// The converted value. + public override DaprJobSchedule? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var exprValue = reader.GetString(); + return exprValue is null ? null : DaprJobSchedule.FromExpression(exprValue); + } + + /// Writes a specified value as JSON. + /// The writer to write to. + /// The value to convert to JSON. + /// An object that specifies serialization options to use. + public override void Write(Utf8JsonWriter writer, DaprJobSchedule value, JsonSerializerOptions options) + { + writer.WriteStringValue(value.ExpressionValue); + } +} diff --git a/src/Dapr.Jobs/JsonConverters/Iso8601DateTimeJsonConverter.cs b/src/Dapr.Jobs/JsonConverters/Iso8601DateTimeJsonConverter.cs new file mode 100644 index 000000000..90ffd3d4d --- /dev/null +++ b/src/Dapr.Jobs/JsonConverters/Iso8601DateTimeJsonConverter.cs @@ -0,0 +1,61 @@ +// ------------------------------------------------------------------------ +// Copyright 2024 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using System.Text.Json.Serialization; +using System.Text.Json; + +namespace Dapr.Jobs.JsonConverters; + +/// +/// Converts from an ISO 8601 DateTime to a string and back. This is primarily used to serialize +/// dates for use with CosmosDB. +/// +public sealed class Iso8601DateTimeJsonConverter : JsonConverter +{ + /// Reads and converts the JSON to a . + /// The reader. + /// The type to convert. + /// An object that specifies serialization options to use. + /// The converted value. + public override DateTimeOffset? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Null) + { + return null; + } + + var dateString = reader.GetString(); + if (DateTimeOffset.TryParse(dateString, out var dateTimeOffset)) + { + return dateTimeOffset; + } + + throw new JsonException($"Unable to convert \"{dateString}\" to {nameof(DateTimeOffset)}"); + } + + /// Writes a specified value as JSON. + /// The writer to write to. + /// The value to convert to JSON. + /// An object that specifies serialization options to use. + public override void Write(Utf8JsonWriter writer, DateTimeOffset? value, JsonSerializerOptions options) + { + if (value is not null) + { + writer.WriteStringValue(value.Value.ToString("O")); + } + else + { + writer.WriteNullValue(); + } + } +} diff --git a/src/Dapr.Jobs/Models/DaprJobSchedule.cs b/src/Dapr.Jobs/Models/DaprJobSchedule.cs new file mode 100644 index 000000000..c1b592e12 --- /dev/null +++ b/src/Dapr.Jobs/Models/DaprJobSchedule.cs @@ -0,0 +1,146 @@ +// ------------------------------------------------------------------------ +// Copyright 2024 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using System.Text.Json.Serialization; +using System.Text.RegularExpressions; +using Dapr.Jobs.Extensions; +using Dapr.Jobs.JsonConverters; + +namespace Dapr.Jobs.Models; + +/// +/// Used to build a schedule for a job. +/// +[JsonConverter(typeof(DaprJobScheduleConverter))] +public sealed class DaprJobSchedule +{ + /// + /// A regular expression used to evaluate whether a given prefix period embodies an @every statement. + /// + private static readonly Regex isEveryExpression = new(@"^@every (\d+(m?s|m|h))+$", RegexOptions.Compiled); + /// + /// The various prefixed period values allowed. + /// + private static readonly string[] acceptablePeriodValues = { "yearly", "monthly", "weekly", "daily", "midnight", "hourly" }; + + /// + /// The value of the expression represented by the schedule. + /// + public string ExpressionValue { get; } + + /// + /// Initializes the value of based on the provided value from each of the factory methods. + /// + /// + /// Developers are intended to create a new using the provided static factory methods. + /// + /// The value of the scheduling expression. + internal DaprJobSchedule(string expressionValue) + { + ExpressionValue = expressionValue; + } + + /// + /// Specifies a schedule built using the fluent Cron expression builder. + /// + /// The fluent Cron expression builder. + public static DaprJobSchedule FromCronExpression(CronExpressionBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder, nameof(builder)); + return new DaprJobSchedule(builder.ToString()); + } + + /// + /// Specifies a single point in time. + /// + /// The date and time when the job should be triggered. + /// + public static DaprJobSchedule FromDateTime(DateTimeOffset scheduledTime) + { + ArgumentNullException.ThrowIfNull(scheduledTime, nameof(scheduledTime)); + return new DaprJobSchedule(scheduledTime.ToString("O")); + } + + /// + /// Specifies a schedule using a Cron-like expression or '@' prefixed period strings. + /// + /// The systemd Cron-like expression indicating when the job should be triggered. + public static DaprJobSchedule FromExpression(string expression) + { + ArgumentNullException.ThrowIfNull(expression, nameof(expression)); + return new DaprJobSchedule(expression); + } + + /// + /// Specifies a schedule using a duration interval articulated via a . + /// + /// The duration interval. + public static DaprJobSchedule FromDuration(TimeSpan duration) + { + ArgumentNullException.ThrowIfNull(duration, nameof(duration)); + return new DaprJobSchedule(duration.ToDurationString()); + } + + /// + /// Specifies a schedule in which the job is triggered to run once a year. + /// + public static DaprJobSchedule Yearly { get; } = new DaprJobSchedule("@yearly"); + + /// + /// Specifies a schedule in which the job is triggered monthly. + /// + public static DaprJobSchedule Monthly { get; } = new DaprJobSchedule("@monthly"); + + /// + /// Specifies a schedule in which the job is triggered weekly. + /// + public static DaprJobSchedule Weekly { get; } =new DaprJobSchedule("@weekly"); + + /// + /// Specifies a schedule in which the job is triggered daily. + /// + public static DaprJobSchedule Daily { get; } = new DaprJobSchedule("@daily"); + + /// + /// Specifies a schedule in which the job is triggered once a day at midnight. + /// + public static DaprJobSchedule Midnight { get; } = new DaprJobSchedule("@midnight"); + + /// + /// Specifies a schedule in which the job is triggered at the top of every hour. + /// + public static DaprJobSchedule Hourly { get; } = new DaprJobSchedule("@hourly"); + + /// + /// Reflects that the schedule represents a prefixed period expression. + /// + public bool IsPrefixedPeriodExpression => + ExpressionValue.StartsWith('@') && + (isEveryExpression.IsMatch(ExpressionValue) || + ExpressionValue.EndsWithAny(acceptablePeriodValues, StringComparison.InvariantCulture)); + + /// + /// Reflects that the schedule represents a fixed point in time. + /// + public bool IsPointInTimeExpression => DateTimeOffset.TryParse(ExpressionValue, out _); + + /// + /// Reflects that the schedule represents a Golang duration expression. + /// + public bool IsDurationExpression => ExpressionValue.IsDurationString(); + + /// + /// Reflects that the schedule represents a Cron expression. + /// + public bool IsCronExpression => CronExpressionBuilder.IsCronExpression(ExpressionValue); +} diff --git a/src/Dapr.Jobs/Models/Responses/DaprJobDetails.cs b/src/Dapr.Jobs/Models/Responses/DaprJobDetails.cs new file mode 100644 index 000000000..9b940beed --- /dev/null +++ b/src/Dapr.Jobs/Models/Responses/DaprJobDetails.cs @@ -0,0 +1,97 @@ +// ------------------------------------------------------------------------ +// Copyright 2024 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using System.Text.Json.Serialization; +using Dapr.Jobs.JsonConverters; + +namespace Dapr.Jobs.Models.Responses; + +/// +/// Represents the details of a retrieved job. +/// +/// The job schedule. +public sealed record DaprJobDetails(DaprJobSchedule Schedule) +{ + /// + /// Allows for jobs with fixed repeat counts. + /// + public int? RepeatCount { get; init; } = null; + + /// + /// Identifies a point-in-time representing when the job schedule should start from, + /// or as a "one-shot" time if other scheduling fields are not provided. + /// + public DateTimeOffset? DueTime { get; init; } = null; + + /// + /// A point-in-time value representing with the job should expire. + /// + /// + /// This must be greater than if both are set. + /// + public DateTimeOffset? Ttl { get; init; } = null; + + /// + /// Stores the main payload of the job which is passed to the trigger function. + /// + public byte[]? Payload { get; init; } = null; +} + +/// +/// A deserializable version of the . +/// +internal sealed record DeserializableDaprJobDetails +{ + /// + /// Represents the schedule that triggers the job. + /// + public string? Schedule { get; init; } + + /// + /// Allows for jobs with fixed repeat counts. + /// + public int? RepeatCount { get; init; } = null; + + /// + /// Identifies a point-in-time representing when the job schedule should start from, + /// or as a "one-shot" time if other scheduling fields are not provided. + /// + [JsonConverter(typeof(Iso8601DateTimeJsonConverter))] + public DateTimeOffset? DueTime { get; init; } = null; + + /// + /// A point-in-time value representing with the job should expire. + /// + /// + /// This must be greater than if both are set. + /// + [JsonConverter(typeof(Iso8601DateTimeJsonConverter))] + public DateTimeOffset? Ttl { get; init; } = null; + + /// + /// Stores the main payload of the job which is passed to the trigger function. + /// + public byte[]? Payload { get; init; } = null; + + public DaprJobDetails ToType() + { + var schedule = DaprJobSchedule.FromExpression(Schedule ?? string.Empty); + return new DaprJobDetails(schedule) + { + DueTime = DueTime, + Payload = Payload, + RepeatCount = RepeatCount, + Ttl = Ttl + }; + } +} diff --git a/test/Dapr.Common.Test/Extensions/EnumExtensionsTest.cs b/test/Dapr.Common.Test/Extensions/EnumExtensionsTest.cs new file mode 100644 index 000000000..84e2998d6 --- /dev/null +++ b/test/Dapr.Common.Test/Extensions/EnumExtensionsTest.cs @@ -0,0 +1,38 @@ +using System.Runtime.Serialization; +using Dapr.Common.Extensions; +using Xunit; + +namespace Dapr.Common.Test.Extensions; + +public class EnumExtensionTest +{ + [Fact] + public void GetValueFromEnumMember_RedResolvesAsExpected() + { + var value = TestEnum.Red.GetValueFromEnumMember(); + Assert.Equal("red", value); + } + + [Fact] + public void GetValueFromEnumMember_YellowResolvesAsExpected() + { + var value = TestEnum.Yellow.GetValueFromEnumMember(); + Assert.Equal("YELLOW", value); + } + + [Fact] + public void GetValueFromEnumMember_BlueResolvesAsExpected() + { + var value = TestEnum.Blue.GetValueFromEnumMember(); + Assert.Equal("Blue", value); + } +} +public enum TestEnum +{ + [EnumMember(Value = "red")] + Red, + [EnumMember(Value = "YELLOW")] + Yellow, + Blue +} + diff --git a/test/Dapr.Jobs.Test/CronExpressionBuilderTests.cs b/test/Dapr.Jobs.Test/CronExpressionBuilderTests.cs new file mode 100644 index 000000000..38031e0eb --- /dev/null +++ b/test/Dapr.Jobs.Test/CronExpressionBuilderTests.cs @@ -0,0 +1,386 @@ +// ------------------------------------------------------------------------ +// Copyright 2024 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using System; +using Xunit; +using ArgumentException = System.ArgumentException; + +namespace Dapr.Jobs.Test; + +public sealed class CronExpressionBuilderTests +{ + [Fact] + public void WildcardByDefault() + { + var builder = new CronExpressionBuilder(); + var result = builder.ToString(); + Assert.Equal("* * * * * *", result); + } + + [Fact] + public void WildcardByAssertion() + { + var builder = new CronExpressionBuilder() + .Each(CronPeriod.Second) + .Each(CronPeriod.Minute) + .Each(CronPeriod.Hour) + .Each(CronPeriod.DayOfWeek) + .Each(CronPeriod.DayOfMonth) + .Each(CronPeriod.Month); + var result = builder.ToString(); + Assert.Equal("* * * * * *", result); + } + + [Fact] + public void OnVariations() + { + var builder = new CronExpressionBuilder() + .On(OnCronPeriod.Second, 5) + .On(OnCronPeriod.Minute, 12) + .On(OnCronPeriod.Hour, 16) + .On(OnCronPeriod.DayOfMonth, 7); + var result = builder.ToString(); + Assert.Equal("5 12 16 7 * *", result); + } + + [Fact] + public void BottomOfEveryMinute() + { + var builder = new CronExpressionBuilder() + .On(OnCronPeriod.Second, 30); + var result = builder.ToString(); + Assert.Equal("30 * * * * *", result); + } + + [Fact] + public void EveryFiveSeconds() + { + var builder = new CronExpressionBuilder() + .Every(EveryCronPeriod.Second, 5); + var result = builder.ToString(); + Assert.Equal("*/5 * * * * *", result); + } + + [Fact] + public void BottomOfEveryHour() + { + var builder = new CronExpressionBuilder() + .On(OnCronPeriod.Second, 0) + .On(OnCronPeriod.Minute, 30); + var result = builder.ToString(); + Assert.Equal("0 30 * * * *", result); + } + + [Fact] + public void EveryTwelveMinutes() + { + var builder = new CronExpressionBuilder() + .Every(EveryCronPeriod.Minute, 12); + var result = builder.ToString(); + Assert.Equal("* */12 * * * *", result); + } + + [Fact] + public void EveryHour() + { + var builder = new CronExpressionBuilder() + .On(OnCronPeriod.Second, 0) + .On(OnCronPeriod.Minute, 0); + var result = builder.ToString(); + Assert.Equal("0 0 * * * *", result); + } + + [Fact] + public void EveryFourHours() + { + var builder = new CronExpressionBuilder() + .Every(EveryCronPeriod.Hour, 4); + var result = builder.ToString(); + Assert.Equal("* * */4 * * *", result); + } + + [Fact] + public void EveryOtherMonth() + { + var builder = new CronExpressionBuilder() + .Every(EveryCronPeriod.Month, 2); + var result = builder.ToString(); + Assert.Equal("* * * * */2 *", result); + } + + [Fact] + public void EachMonth() + { + var builder = new CronExpressionBuilder() + .Every(EveryCronPeriod.Month, 4) + .Each(CronPeriod.Month); + var result = builder.ToString(); + Assert.Equal("* * * * * *", result); + } + + [Fact] + public void EveryDayAtMidnight() + { + var builder = new CronExpressionBuilder() + .On(OnCronPeriod.Second, 0) + .On(OnCronPeriod.Minute, 0) + .On(OnCronPeriod.Hour, 0); + var result = builder.ToString(); + Assert.Equal("0 0 0 * * *", result); + } + + [Fact] + public void EveryFourthDayInJanAprAugAndDecIfTheDayIsWednesdayOrFriday() + { + var builder = new CronExpressionBuilder() + .On(OnCronPeriod.Second, 30) + .On(OnCronPeriod.Minute, 15) + .On(OnCronPeriod.Hour, 6) + .Every(EveryCronPeriod.DayInMonth, 4) + .On(MonthOfYear.January, MonthOfYear.April, MonthOfYear.August, MonthOfYear.December) + .On(DayOfWeek.Wednesday, DayOfWeek.Friday); + var result = builder.ToString(); + Assert.Equal("30 15 6 */4 JAN,APR,AUG,DEC WED,FRI", result); + } + + [Fact] + public void EveryValidation() + { + var builder = new CronExpressionBuilder() + .Every(EveryCronPeriod.Second, 10) + .Every(EveryCronPeriod.Minute, 8) + .Every(EveryCronPeriod.Hour, 2) + .Every(EveryCronPeriod.DayInMonth, 5) + .Every(EveryCronPeriod.DayInWeek, 2) + .Every(EveryCronPeriod.Month, 3); + var result = builder.ToString(); + Assert.Equal("*/10 */8 */2 */5 */3 */2", result); + } + + [Fact] + public void EveryDayAtNoon() + { + var builder = new CronExpressionBuilder() + .On(OnCronPeriod.Second, 0) + .On(OnCronPeriod.Minute, 0) + .On(OnCronPeriod.Hour, 12); + var result = builder.ToString(); + Assert.Equal("0 0 12 * * *", result); + } + + [Fact] + public void MidnightOnTuesdaysAndFridays() + { + var builder = new CronExpressionBuilder() + .On(OnCronPeriod.Second, 0) + .On(OnCronPeriod.Minute, 0) + .On(OnCronPeriod.Hour, 0) + .On(DayOfWeek.Tuesday, DayOfWeek.Friday); + var result = builder.ToString(); + Assert.Equal("0 0 0 * * TUE,FRI", result); + } + + [Fact] + public void FourThirtyPmOnWednesdayThroughSaturdayFromOctoberToDecember() + { + var builder = new CronExpressionBuilder() + .On(OnCronPeriod.Second, 0) + .On(OnCronPeriod.Minute, 30) + .On(OnCronPeriod.Hour, 16) + .Through(DayOfWeek.Wednesday, DayOfWeek.Saturday) + .Through(MonthOfYear.October, MonthOfYear.December); + var result = builder.ToString(); + Assert.Equal("0 30 16 * OCT-DEC WED-SAT", result); + } + + [Fact] + public void ThroughFirstAvailableUnits() + { + var builder = new CronExpressionBuilder() + .Through(ThroughCronPeriod.Second, 0, 15) + .Through(ThroughCronPeriod.Minute, 0, 15) + .Through(ThroughCronPeriod.Hour, 0, 15) + .Through(ThroughCronPeriod.DayOfMonth, 1, 10) + .Through(ThroughCronPeriod.Month, 0, 8); + var result = builder.ToString(); + Assert.Equal("0-15 0-15 0-15 1-10 0-8 *", result); + } + + [Fact] + public void ShouldThrowIfIntervalIsBelowRange() + { + Assert.Throws(() => + { + var builder = new CronExpressionBuilder() + .Every(EveryCronPeriod.Minute, -5); + }); + } + + [Fact] + public void ShouldThrowIfRangeValuesAreEqual() + { + Assert.Throws(() => + { + var builder = new CronExpressionBuilder() + .Through(ThroughCronPeriod.Hour, 8, 8); + }); + } + + [Fact] + public void ShouldThrowIfRangeValuesAreInDescendingOrder() + { + Assert.Throws(() => + { + var builder = new CronExpressionBuilder() + .Through(MonthOfYear.December, MonthOfYear.February); + }); + } + + [Fact] + public void ShouldThrowIfRangedMonthsAreEqual() + { + Assert.Throws(() => + { + var builder = new CronExpressionBuilder() + .Through(MonthOfYear.April, MonthOfYear.April); + }); + } + + [Fact] + public void ShouldThrowIfRangedMonthsAreInDescendingOrder() + { + Assert.Throws(() => + { + var builder = new CronExpressionBuilder() + .Through(ThroughCronPeriod.Minute, 10, 5); + }); + } + + [Fact] + public void ShouldThrowIfRangedDaysAreEqualInRange() + { + Assert.Throws(() => + { + var builder = new CronExpressionBuilder() + .Through(DayOfWeek.Thursday, DayOfWeek.Thursday); + }); + } + + [Fact] + public void ShouldThrowIfRangedDaysAreInDescendingOrder() + { + Assert.Throws(() => + { + var builder = new CronExpressionBuilder() + .Through(DayOfWeek.Thursday, DayOfWeek.Monday); + }); + } + + [Fact] + public void ShouldThrowIfOnValuesAreBelowRange() + { + Assert.Throws(() => + { + var builder = new CronExpressionBuilder() + .On(OnCronPeriod.Second, -2); + }); + } + + [Fact] + public void ShouldThrowIfOnValuesAreBelowRange2() + { + Assert.Throws(() => + { + var builder = new CronExpressionBuilder() + .On(OnCronPeriod.Hour, -10); + }); + } + + [Fact] + public void ShouldThrowIfOnValuesAreBelowRange3() + { + Assert.Throws(() => + { + var builder = new CronExpressionBuilder() + .On(OnCronPeriod.DayOfMonth, -5); + }); + } + + [Fact] + public void ShouldThrowIfOnValuesAreAboveRange() + { + Assert.Throws(() => + { + var builder = new CronExpressionBuilder() + .On(OnCronPeriod.Minute, 60); + }); + } + + [Fact] + public void ShouldThrowIfOnValuesAreAboveRange2() + { + Assert.Throws(() => + { + var builder = new CronExpressionBuilder() + .On(OnCronPeriod.DayOfMonth, 32); + }); + } + + [Theory] + [InlineData("* * * * *", false)] + [InlineData("* * * * * *", true)] + [InlineData("5 12 16 7 * *", true)] + [InlineData("30 * * * * *", true)] + [InlineData("*/5 * * * * *", true)] + [InlineData("0 30 * * * *", true)] + [InlineData("* */12 * * * *", true)] + [InlineData("0 0 * * * *", true)] + [InlineData("* * */4 * * *", true)] + [InlineData("* * * * */2 *", true)] + [InlineData("0 0 0 * * *", true)] + [InlineData("30 15 6 */4 JAN,APR,AUG WED,FRI", true)] + [InlineData("*/10 */8 */2 */5 */3 *", true)] + [InlineData("0 0 12 * * *", true)] + [InlineData("0 0 0 * * TUE,FRI", true)] + [InlineData("0 0 0 * * TUE", true)] + [InlineData("0 0 0 * * TUE-FRI", true)] + [InlineData("0 30 16 * OCT SAT", true)] + [InlineData("0 30 16 * OCT,DEC WED,SAT", true)] + [InlineData("0 30 16 * OCT-DEC WED-SAT", true)] + [InlineData("0-15 * * * * *", true)] + [InlineData("0-15 02-59 * * * *", true)] + [InlineData("0-15 02-59 07-23 * * *", true)] + [InlineData("0-15 0-15 0-15 1-10 8-16 *", true)] + [InlineData("5 12 16 7 FEB *", true)] + [InlineData("5 12 16 7 * MON", true)] + [InlineData("5 12 16 7 JAN SAT", true)] + [InlineData("5 * * * FEB SUN", true)] + [InlineData("* * */2 * * *", true)] + [InlineData("* * * */5 * *", true)] + [InlineData("0,01,3 0,01,2 0,01,2 00,1,02 JAN,FEB,MAR,APR SUN,MON,TUE,WED", true)] + [InlineData("* * * * JAN,FEB,MAR,APR,MAY,JUN,JUL,AUG,SEP,OCT,NOV,DEC SUN,MON,TUE,WED,THU,FRI,SAT", true)] + [InlineData("30 15 6 */4 JAN,APR,AUG WED-FRI", true)] + [InlineData("*/10 */8 */2 */5 */3 */2", true)] + [InlineData("0 0 0 * OCT SAT", true)] + [InlineData("0 0 0 * OCT,DEC WED,SAT", true)] + [InlineData("0 0 0 * OCT-DEC WED-SAT", true)] + [InlineData("1-14 2-59 20-23 * * *", true)] + [InlineData("00-59 0-59 00-23 1-31 JAN-DEC SUN-SAT", true)] + [InlineData("0-59 0-59 0-23 1-31 1-12 0-6", true)] + [InlineData("*/1 2,4,5 * 2-9 JAN,FEB,DEC MON-WED", true)] + public void ValidateCronExpression(string cronValue, bool isValid) + { + var result = CronExpressionBuilder.IsCronExpression(cronValue); + Assert.Equal(result, isValid); + } +} diff --git a/test/Dapr.Jobs.Test/Dapr.Jobs.Test.csproj b/test/Dapr.Jobs.Test/Dapr.Jobs.Test.csproj new file mode 100644 index 000000000..0e0c9017f --- /dev/null +++ b/test/Dapr.Jobs.Test/Dapr.Jobs.Test.csproj @@ -0,0 +1,28 @@ + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + diff --git a/test/Dapr.Jobs.Test/DaprJobsClientBuilderTests.cs b/test/Dapr.Jobs.Test/DaprJobsClientBuilderTests.cs new file mode 100644 index 000000000..bdfa2d8d2 --- /dev/null +++ b/test/Dapr.Jobs.Test/DaprJobsClientBuilderTests.cs @@ -0,0 +1,122 @@ +// ------------------------------------------------------------------------ +// Copyright 2024 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using System; +using System.Text.Json; +using Grpc.Net.Client; +using Xunit; + +namespace Dapr.Jobs.Test; + +public class DaprJobsClientBuilderTest +{ + [Fact] + public void DaprClientBuilder_UsesPropertyNameCaseHandlingInsensitiveByDefault() + { + DaprJobsClientBuilder builder = new DaprJobsClientBuilder(); + Assert.True(builder.JsonSerializerOptions.PropertyNameCaseInsensitive); + } + + [Fact] + public void DaprJobsClientBuilder_UsesPropertyNameCaseHandlingAsSpecified() + { + var builder = new DaprJobsClientBuilder(); + builder.UseJsonSerializationOptions(new JsonSerializerOptions + { + PropertyNameCaseInsensitive = false + }); + Assert.False(builder.JsonSerializerOptions.PropertyNameCaseInsensitive); + } + + [Fact] + public void DaprJobsClientBuilder_UsesThrowOperationCanceledOnCancellation_ByDefault() + { + var builder = new DaprJobsClientBuilder(); + var daprClient = builder.Build(); + Assert.True(builder.GrpcChannelOptions.ThrowOperationCanceledOnCancellation); + } + + [Fact] + public void DaprJobsClientBuilder_DoesNotOverrideUserGrpcChannelOptions() + { + var builder = new DaprJobsClientBuilder(); + var daprClient = builder.UseGrpcChannelOptions(new GrpcChannelOptions()).Build(); + Assert.False(builder.GrpcChannelOptions.ThrowOperationCanceledOnCancellation); + } + + [Fact] + public void DaprJobsClientBuilder_ValidatesGrpcEndpointScheme() + { + var builder = new DaprJobsClientBuilder(); + builder.UseGrpcEndpoint("ftp://example.com"); + + var ex = Assert.Throws(() => builder.Build()); + Assert.Equal("The gRPC endpoint must use http or https.", ex.Message); + } + + [Fact] + public void DaprJobsClientBuilder_ValidatesHttpEndpointScheme() + { + var builder = new DaprJobsClientBuilder(); + builder.UseHttpEndpoint("ftp://example.com"); + + var ex = Assert.Throws(() => builder.Build()); + Assert.Equal("The HTTP endpoint must use http or https.", ex.Message); + } + + [Fact] + public void DaprJobsClientBuilder_SetsApiToken() + { + var builder = new DaprJobsClientBuilder(); + builder.UseDaprApiToken("test_token"); + builder.Build(); + Assert.Equal("test_token", builder.DaprApiToken); + } + + [Fact] + public void DaprJobsClientBuilder_SetsNullApiToken() + { + var builder = new DaprJobsClientBuilder(); + builder.UseDaprApiToken(null); + builder.Build(); + Assert.Null(builder.DaprApiToken); + } + + [Fact] + public void DaprJobsClientBuilder_ApiTokenSet_SetsApiTokenHeader() + { + var builder = new DaprJobsClientBuilder(); + builder.UseDaprApiToken("test_token"); + + var entry = DaprJobsClient.GetDaprApiTokenHeader(builder.DaprApiToken); + Assert.NotNull(entry); + Assert.Equal("test_token", entry.Value.Value); + } + + [Fact] + public void DaprJobsClientBuilder_ApiTokenNotSet_EmptyApiTokenHeader() + { + var builder = new DaprJobsClientBuilder(); + var entry = DaprJobsClient.GetDaprApiTokenHeader(builder.DaprApiToken); + Assert.Equal(default, entry); + } + + [Fact] + public void DaprJobsClientBuilder_SetsTimeout() + { + var builder = new DaprJobsClientBuilder(); + builder.UseTimeout(TimeSpan.FromSeconds(2)); + builder.Build(); + Assert.Equal(2, builder.Timeout.Seconds); + } +} diff --git a/test/Dapr.Jobs.Test/DaprJobsGrpcClientTests.cs b/test/Dapr.Jobs.Test/DaprJobsGrpcClientTests.cs new file mode 100644 index 000000000..4f6168830 --- /dev/null +++ b/test/Dapr.Jobs.Test/DaprJobsGrpcClientTests.cs @@ -0,0 +1,172 @@ +// ------------------------------------------------------------------------ +// Copyright 2024 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using System; +using System.Net.Http; +using Dapr.Jobs.Models; +using Moq; +using Xunit; + +namespace Dapr.Jobs.Test; + +public sealed class DaprJobsGrpcClientTests +{ + + [Fact] + public void ScheduleJobAsync_RepeatsCannotBeLessThanZero() + { + var mockClient = Mock.Of(); + var httpClient = Mock.Of(); + + var client = new DaprJobsGrpcClient(mockClient, httpClient, null); + +#pragma warning disable CS0618 // Type or member is obsolete + var result = Assert.ThrowsAsync(async () => + { + await client.ScheduleJobAsync("MyJob", DaprJobSchedule.Daily, null, null, -5, null, default); + }); +#pragma warning restore CS0618 // Type or member is obsolete + } + + [Fact] + public void ScheduleJobAsync_JobNameCannotBeNull() + { + var mockClient = Mock.Of(); + var httpClient = Mock.Of(); + + var client = new DaprJobsGrpcClient(mockClient, httpClient, null); + +#pragma warning disable CS0618 // Type or member is obsolete + var result = Assert.ThrowsAsync(async () => + { + await client.ScheduleJobAsync(null, DaprJobSchedule.Daily, null, null, -5, null, default); + }); +#pragma warning restore CS0618 // Type or member is obsolete + } + + [Fact] + public void ScheduleJobAsync_JobNameCannotBeEmpty() + { + var mockClient = Mock.Of(); + var httpClient = Mock.Of(); + + var client = new DaprJobsGrpcClient(mockClient, httpClient, null); + +#pragma warning disable CS0618 // Type or member is obsolete + var result = Assert.ThrowsAsync(async () => + { + await client.ScheduleJobAsync(string.Empty, DaprJobSchedule.Daily, null, null, -5, null, default); + }); +#pragma warning restore CS0618 // Type or member is obsolete + } + + [Fact] + public void ScheduleJobAsync_ScheduleCannotBeEmpty() + { + var mockClient = Mock.Of(); + var httpClient = Mock.Of(); + + var client = new DaprJobsGrpcClient(mockClient, httpClient, null); + +#pragma warning disable CS0618 // Type or member is obsolete + var result = Assert.ThrowsAsync(async () => + { + await client.ScheduleJobAsync("MyJob", new DaprJobSchedule(string.Empty), null, null, -5, null, default); + }); +#pragma warning restore CS0618 // Type or member is obsolete + } + + [Fact] + public void ScheduleJobAsync_TtlCannotBeEarlierThanStartingFrom() + { + var mockClient = Mock.Of(); + var httpClient = Mock.Of(); + + var client = new DaprJobsGrpcClient(mockClient, httpClient, null); + +#pragma warning disable CS0618 // Type or member is obsolete + var result = Assert.ThrowsAsync(async () => + { + var date = DateTime.UtcNow.AddDays(10); + var earlierDate = date.AddDays(-2); + await client.ScheduleJobAsync("MyJob", DaprJobSchedule.Daily, null, date, null, earlierDate, default); + }); +#pragma warning restore CS0618 // Type or member is obsolete + } + + [Fact] + public void GetJobAsync_NameCannotBeNull() + { + var mockClient = Mock.Of(); + var httpClient = Mock.Of(); + + var client = new DaprJobsGrpcClient(mockClient, httpClient, null); + +#pragma warning disable CS0618 // Type or member is obsolete + var result = Assert.ThrowsAsync(async () => + { + await client.GetJobAsync(null, default); + }); +#pragma warning restore CS0618 // Type or member is obsolete + } + + [Fact] + public void GetJobAsync_NameCannotBeEmpty() + { + var mockClient = Mock.Of(); + var httpClient = Mock.Of(); + + var client = new DaprJobsGrpcClient(mockClient, httpClient, null); + +#pragma warning disable CS0618 // Type or member is obsolete + var result = Assert.ThrowsAsync(async () => + { + await client.GetJobAsync(string.Empty, default); + }); +#pragma warning restore CS0618 // Type or member is obsolete + } + + [Fact] + public void DeleteJobAsync_NameCannotBeNull() + { + var mockClient = Mock.Of(); + var httpClient = Mock.Of(); + + var client = new DaprJobsGrpcClient(mockClient, httpClient, null); + +#pragma warning disable CS0618 // Type or member is obsolete + var result = Assert.ThrowsAsync(async () => + { + await client.DeleteJobAsync(null, default); + }); +#pragma warning restore CS0618 // Type or member is obsolete + } + + [Fact] + public void DeleteJobAsync_NameCannotBeEmpty() + { + var mockClient = Mock.Of(); + var httpClient = Mock.Of(); + + var client = new DaprJobsGrpcClient(mockClient, httpClient, null); + +#pragma warning disable CS0618 // Type or member is obsolete + var result = Assert.ThrowsAsync(async () => + { + await client.DeleteJobAsync(string.Empty, default); + }); +#pragma warning restore CS0618 // Type or member is obsolete + } + + private sealed record TestPayload(string Name, string Color); +} diff --git a/test/Dapr.Jobs.Test/Extensions/DaprJobsServiceCollectionExtensionsTests.cs b/test/Dapr.Jobs.Test/Extensions/DaprJobsServiceCollectionExtensionsTests.cs new file mode 100644 index 000000000..34d900aeb --- /dev/null +++ b/test/Dapr.Jobs.Test/Extensions/DaprJobsServiceCollectionExtensionsTests.cs @@ -0,0 +1,85 @@ +// ------------------------------------------------------------------------ +// Copyright 2024 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using System; +using System.Net.Http; +using Dapr.Jobs.Extensions; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Dapr.Jobs.Test.Extensions; + +public class DaprJobsServiceCollectionExtensionsTest +{ + [Fact] + public void AddDaprJobsClient_RegistersDaprClientOnlyOnce() + { + var services = new ServiceCollection(); + + var clientBuilder = new Action(builder => + builder.UseDaprApiToken("abc")); + + services.AddDaprJobsClient(); //Sets a default API token value of an empty string + services.AddDaprJobsClient(clientBuilder); //Sets the API token value + + var serviceProvider = services.BuildServiceProvider(); + var daprJobClient = serviceProvider.GetService() as DaprJobsGrpcClient; + + Assert.Null(daprJobClient!.apiTokenHeader); + Assert.False(daprJobClient.httpClient.DefaultRequestHeaders.TryGetValues("dapr-api-token", out var _)); + } + + [Fact] + public void AddDaprJobsClient_RegistersIHttpClientFactory() + { + var services = new ServiceCollection(); + + services.AddDaprJobsClient(); + + var serviceProvider = services.BuildServiceProvider(); + + var httpClientFactory = serviceProvider.GetService(); + Assert.NotNull(httpClientFactory); + + var daprJobsClient = serviceProvider.GetService(); + Assert.NotNull(daprJobsClient); + } + + [Fact] + public void AddDaprJobsClient_RegistersUsingDependencyFromIServiceProvider() + { + var services = new ServiceCollection(); + services.AddSingleton(); + services.AddDaprJobsClient((provider, builder) => + { + var configProvider = provider.GetRequiredService(); + var daprApiToken = configProvider.GetApiTokenValue(); + builder.UseDaprApiToken(daprApiToken); + }); + + var serviceProvider = services.BuildServiceProvider(); + var client = serviceProvider.GetRequiredService() as DaprJobsGrpcClient; + + //Validate it's set on the GrpcClient - note that it doesn't get set on the HttpClient + Assert.NotNull(client); + Assert.NotNull(client.apiTokenHeader); + Assert.True(client.apiTokenHeader.HasValue); + Assert.Equal("dapr-api-token", client.apiTokenHeader.Value.Key); + Assert.Equal("abcdef", client.apiTokenHeader.Value.Value); + } + + private class TestSecretRetriever + { + public string GetApiTokenValue() => "abcdef"; + } +} diff --git a/test/Dapr.Jobs.Test/Extensions/EndpointRouteBuilderExtensionsTests.cs b/test/Dapr.Jobs.Test/Extensions/EndpointRouteBuilderExtensionsTests.cs new file mode 100644 index 000000000..fa4d094e1 --- /dev/null +++ b/test/Dapr.Jobs.Test/Extensions/EndpointRouteBuilderExtensionsTests.cs @@ -0,0 +1,179 @@ +// ------------------------------------------------------------------------ +// Copyright 2024 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +#nullable enable + +using System; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Dapr.Jobs.Extensions; +using Dapr.Jobs.Models; +using Dapr.Jobs.Models.Responses; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Dapr.Jobs.Test.Extensions; + +public class EndpointRouteBuilderExtensionsTest +{ + [Fact] + public async Task MapDaprScheduledJobHandler_ValidRequest_ExecutesAction() + { + var server = CreateTestServer(); + var client = server.CreateClient(); + + var serializedPayload = JsonSerializer.Serialize(new SamplePayload("Dapr", 789)); + var serializedPayloadBytes = Encoding.UTF8.GetBytes(serializedPayload); + var jobDetails = new DaprJobDetails(new DaprJobSchedule("0 0 * * *")) + { + RepeatCount = 5, + DueTime = DateTimeOffset.UtcNow, + Ttl = DateTimeOffset.UtcNow.AddHours(1), + Payload = serializedPayloadBytes + }; + var content = new StringContent(JsonSerializer.Serialize(jobDetails), Encoding.UTF8, "application/json"); + + const string jobName = "testJob"; + var response = await client.PostAsync($"/job/{jobName}", content); + + response.EnsureSuccessStatusCode(); + + //Validate the job name and payload + var validator = server.Services.GetRequiredService(); + Assert.Equal(jobName, validator.JobName); + Assert.Equal(serializedPayload, validator.SerializedPayload); + } + + [Fact] + public async Task MapDaprScheduleJobHandler_HandleMissingCancellationToken() + { + var server = CreateTestServer2(); + var client = server.CreateClient(); + + var serializedPayload = JsonSerializer.Serialize(new SamplePayload("Dapr", 789)); + var serializedPayloadBytes = Encoding.UTF8.GetBytes(serializedPayload); + var jobDetails = new DaprJobDetails(new DaprJobSchedule("0 0 * * *")) + { + RepeatCount = 5, + DueTime = DateTimeOffset.UtcNow, + Ttl = DateTimeOffset.UtcNow.AddHours(1), + Payload = serializedPayloadBytes + }; + var content = new StringContent(JsonSerializer.Serialize(jobDetails), Encoding.UTF8, "application/json"); + + const string jobName = "testJob"; + var response = await client.PostAsync($"/job/{jobName}", content); + + response.EnsureSuccessStatusCode(); + + //Validate the job name and payload + var validator = server.Services.GetRequiredService(); + Assert.Equal(jobName, validator.JobName); + Assert.Equal(serializedPayload, validator.SerializedPayload); + } + + + [Fact] + public async Task MapDaprScheduledJobHandler_InvalidPayload() + { + // Arrange + var server = CreateTestServer(); + var client = server.CreateClient(); + + var content = new StringContent("", Encoding.UTF8, "application/json"); + + // Act + const string jobName = "testJob"; + var response = await client.PostAsync($"/job/{jobName}", content); + + var validator = server.Services.GetRequiredService(); + Assert.Equal(jobName, validator.JobName); + Assert.Null(validator.SerializedPayload); + } + + private sealed record SamplePayload(string Name, int Count); + + public sealed class Validator + { + public string? JobName { get; set; } + + public string? SerializedPayload { get; set; } + } + + private static TestServer CreateTestServer() + { + var builder = new WebHostBuilder() + .ConfigureServices(services => + { + services.AddSingleton(); + services.AddRouting(); + }) + .Configure(app => + { + app.UseRouting(); + app.UseEndpoints(endpoints => + { + endpoints.MapDaprScheduledJobHandler(async (string? jobName, DaprJobDetails? jobDetails, Validator validator, CancellationToken cancellationToken) => + { + if (jobName is not null) + validator.JobName = jobName; + if (jobDetails?.Payload is not null) + { + var payloadString = Encoding.UTF8.GetString(jobDetails.Payload); + validator.SerializedPayload = payloadString; + } + await Task.CompletedTask; + }); + }); + }); + + return new TestServer(builder); + } + + private static TestServer CreateTestServer2() + { + var builder = new WebHostBuilder() + .ConfigureServices(services => + { + services.AddSingleton(); + services.AddRouting(); + }) + .Configure(app => + { + app.UseRouting(); + app.UseEndpoints(endpoints => + { + endpoints.MapDaprScheduledJobHandler(async (string? jobName, Validator validator, DaprJobDetails? jobDetails) => + { + if (jobName is not null) + validator.JobName = jobName; + if (jobDetails?.Payload is not null) + { + var payloadString = Encoding.UTF8.GetString(jobDetails.Payload); + validator.SerializedPayload = payloadString; + } + await Task.CompletedTask; + }); + }); + }); + + return new TestServer(builder); + } +} diff --git a/test/Dapr.Jobs.Test/Extensions/StringExtensionsTests.cs b/test/Dapr.Jobs.Test/Extensions/StringExtensionsTests.cs new file mode 100644 index 000000000..8c25de115 --- /dev/null +++ b/test/Dapr.Jobs.Test/Extensions/StringExtensionsTests.cs @@ -0,0 +1,46 @@ +// ------------------------------------------------------------------------ +// Copyright 2024 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using System; +using System.Collections.Generic; +using Dapr.Jobs.Extensions; +using Xunit; + +namespace Dapr.Jobs.Test.Extensions; + +public class StringExtensionsTests +{ + [Fact] + public void EndsWithAny_ContainsMatch() + { + const string testValue = "@weekly"; + var result = testValue.EndsWithAny(new List + { + "every", + "monthly", + "weekly", + "daily", + "midnight", + "hourly" + }, StringComparison.InvariantCulture); + Assert.True(result); + } + + [Fact] + public void EndsWithAny_DoesNotContainMatch() + { + const string testValue = "@weekly"; + var result = testValue.EndsWithAny(new List { "every", "monthly", "daily", "midnight", "hourly" }, StringComparison.InvariantCulture); + Assert.False(result); + } +} diff --git a/test/Dapr.Jobs.Test/Extensions/TimeSpanExtensionsTests.cs b/test/Dapr.Jobs.Test/Extensions/TimeSpanExtensionsTests.cs new file mode 100644 index 000000000..1e888a841 --- /dev/null +++ b/test/Dapr.Jobs.Test/Extensions/TimeSpanExtensionsTests.cs @@ -0,0 +1,146 @@ +// ------------------------------------------------------------------------ +// Copyright 2024 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using System; +using Xunit; + +namespace Dapr.Jobs.Test.Extensions; + +public class TimeSpanExtensionsTest +{ + [Theory] + [InlineData("5h", true)] + [InlineData("5m", true)] + [InlineData("10s", true)] + [InlineData("30q", true)] + [InlineData("5h2m", true)] + [InlineData("2m44s", true)] + [InlineData("49s28q", true)] + [InlineData("21m2s9q", true)] + [InlineData("9h17m10s55q", true)] + [InlineData("12z", false)] + [InlineData("60ms", true)] + [InlineData("", false)] + public void IsDurationString_Validate(string original, bool expectedResult) + { + var actualResult = original.IsDurationString(); + Assert.Equal(expectedResult, actualResult); + } + + [Fact] + public void ToDurationString_ValidateHours() + { + var fourHours = TimeSpan.FromHours(4); + var result = fourHours.ToDurationString(); + + Assert.Equal("4h", result); + } + + [Fact] + public void ToDurationString_ValidateMinutes() + { + var elevenMinutes = TimeSpan.FromMinutes(11); + var result = elevenMinutes.ToDurationString(); + + Assert.Equal("11m", result); + } + + [Fact] + public void ToDurationString_ValidateSeconds() + { + var fortySeconds = TimeSpan.FromSeconds(40); + var result = fortySeconds.ToDurationString(); + + Assert.Equal("40s", result); + } + + [Fact] + public void ToDurationString_ValidateMilliseconds() + { + var tenMilliseconds = TimeSpan.FromMilliseconds(10); + var result = tenMilliseconds.ToDurationString(); + + Assert.Equal("10ms", result); + } + + [Fact] + public void ToDurationString_HoursAndMinutes() + { + var ninetyMinutes = TimeSpan.FromMinutes(90); + var result = ninetyMinutes.ToDurationString(); + + Assert.Equal("1h30m", result); + } + + [Fact] + public void ToDurationString_Combined() + { + var time = TimeSpan.FromHours(2) + TimeSpan.FromMinutes(4) + TimeSpan.FromSeconds(24) + + TimeSpan.FromMilliseconds(28); + var result = time.ToDurationString(); + + Assert.Equal("2h4m24s28ms", result); + } + + [Fact] + public void FromDurationString_AllSegments() + { + const string interval = "13h57m4s10ms"; + var result = interval.FromDurationString(); + + Assert.Equal(13, result.Hours); + Assert.Equal(57, result.Minutes); + Assert.Equal(4, result.Seconds); + Assert.Equal(10, result.Milliseconds); + } + + [Fact] + public void FromDurationString_LimitedSegments1() + { + const string interval = "5h12ms"; + var result = interval.FromDurationString(); + + Assert.Equal(5, result.Hours); + Assert.Equal(12, result.Milliseconds); + } + + [Fact] + public void FromDurationString_LimitedSegments2() + { + const string interval = "5m"; + var result = interval.FromDurationString(); + + Assert.Equal(5, result.Minutes); + } + + [Fact] + public void FromDurationString_LimitedSegments3() + { + const string interval = "16s43ms"; + var result = interval.FromDurationString(); + + Assert.Equal(16, result.Seconds); + Assert.Equal(43, result.Milliseconds); + } + + [Fact] + public void FromDurationString_LimitedSegments4() + { + const string interval = "4h32m16s"; + var result = interval.FromDurationString(); + + Assert.Equal(4, result.Hours); + Assert.Equal(32, result.Minutes); + Assert.Equal(16, result.Seconds); + } +} diff --git a/test/Dapr.Jobs.Test/Models/DaprJobScheduleTests.cs b/test/Dapr.Jobs.Test/Models/DaprJobScheduleTests.cs new file mode 100644 index 000000000..17eb48362 --- /dev/null +++ b/test/Dapr.Jobs.Test/Models/DaprJobScheduleTests.cs @@ -0,0 +1,172 @@ +// ------------------------------------------------------------------------ +// Copyright 2024 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using System; +using Dapr.Jobs.Models; +using Xunit; + +namespace Dapr.Jobs.Test.Models; + +public sealed class DaprJobScheduleTests +{ + [Fact] + public void FromDuration_Validate() + { + var schedule = DaprJobSchedule.FromDuration(new TimeSpan(12, 8, 16)); + Assert.Equal("12h8m16s", schedule.ExpressionValue); + } + + [Fact] + public void FromExpression_Cron() + { + const string cronExpression = "*/5 1-5 * * JAN,FEB WED-SAT"; + + var schedule = DaprJobSchedule.FromExpression(cronExpression); + Assert.Equal(cronExpression, schedule.ExpressionValue); + } + + [Fact] + public void FromExpression_PrefixedPeriod_Yearly() + { + var schedule = DaprJobSchedule.Yearly; + Assert.Equal("@yearly", schedule.ExpressionValue); + } + + [Fact] + public void FromExpression_PrefixedPeriod_Monthly() + { + var schedule = DaprJobSchedule.Monthly; + Assert.Equal("@monthly", schedule.ExpressionValue); + } + + [Fact] + public void FromExpression_PrefixedPeriod_Weekly() + { + var schedule = DaprJobSchedule.Weekly; + Assert.Equal("@weekly", schedule.ExpressionValue); + } + + [Fact] + public void FromExpression_PrefixedPeriod_Daily() + { + var schedule = DaprJobSchedule.Daily; + Assert.Equal("@daily", schedule.ExpressionValue); + } + + [Fact] + public void FromExpression_PrefixedPeriod_Midnight() + { + var schedule = DaprJobSchedule.Midnight; + Assert.Equal("@midnight", schedule.ExpressionValue); + } + + [Fact] + public void FromExpression_PrefixedPeriod_Hourly() + { + var schedule = DaprJobSchedule.Hourly; + Assert.Equal("@hourly", schedule.ExpressionValue); + } + + [Fact] + public void FromCronExpression() + { + var schedule = DaprJobSchedule.FromCronExpression(new CronExpressionBuilder() + .On(OnCronPeriod.Second, 15) + .Every(EveryCronPeriod.Minute, 2) + .Every(EveryCronPeriod.Hour, 4) + .Through(ThroughCronPeriod.DayOfMonth, 2, 13) + .Through(DayOfWeek.Monday, DayOfWeek.Saturday) + .On(MonthOfYear.June, MonthOfYear.August, MonthOfYear.January)); + + Assert.Equal("15 */2 */4 2-13 JAN,JUN,AUG MON-SAT", schedule.ExpressionValue); + } + + [Fact] + public void IsPointInTimeExpression() + { + var schedule = DaprJobSchedule.FromDateTime(DateTimeOffset.UtcNow.AddDays(2)); + Assert.True(schedule.IsPointInTimeExpression); + Assert.False(schedule.IsDurationExpression); + Assert.False(schedule.IsCronExpression); + Assert.False(schedule.IsPrefixedPeriodExpression); + } + + [Fact] + public void IsDurationExpression() + { + var schedule = DaprJobSchedule.FromDuration(TimeSpan.FromHours(2)); + Assert.True(schedule.IsDurationExpression); + Assert.False(schedule.IsPointInTimeExpression); + Assert.False(schedule.IsCronExpression); + Assert.False(schedule.IsPrefixedPeriodExpression); + } + + [Fact] + public void IsPrefixedPeriodExpression() + { + var schedule = DaprJobSchedule.Weekly; + Assert.True(schedule.IsPrefixedPeriodExpression); + Assert.False(schedule.IsCronExpression); + Assert.False(schedule.IsPointInTimeExpression); + Assert.False(schedule.IsDurationExpression); + } + + [Theory] + [InlineData("5h")] + [InlineData("5h5m")] + [InlineData("5h2m12s")] + [InlineData("5h9m22s27ms")] + [InlineData("42m12s28ms")] + [InlineData("19s2ms")] + [InlineData("292ms")] + [InlineData("5h23s")] + [InlineData("25m192ms")] + public void ValidateEveryExpression(string testValue) + { + var schedule = DaprJobSchedule.FromExpression($"@every {testValue}"); + Assert.True(schedule.IsPrefixedPeriodExpression); + } + + [Theory] + [InlineData("* * * * * *")] + [InlineData("5 12 16 7 * *")] + [InlineData("5 12 16 7 FEB *")] + [InlineData("5 12 16 7 * MON")] + [InlineData("5 12 16 7 JAN SAT")] + [InlineData("5 * * * FEB SUN")] + [InlineData("30 * * * * *")] + [InlineData("*/5 * * * * *")] + [InlineData("* */12 * * * *")] + [InlineData("* * */2 * * *")] + [InlineData("* * * */5 * *")] + [InlineData("30 15 6 */4 JAN,APR,AUG WED-FRI")] + [InlineData("*/10 */8 */2 */5 */3 */2")] + [InlineData("0 0 0 * * TUE,FRI")] + [InlineData("0 0 0 * * TUE-FRI")] + [InlineData("0 0 0 * OCT SAT")] + [InlineData("0 0 0 * OCT,DEC WED,SAT")] + [InlineData("0 0 0 * OCT-DEC WED-SAT")] + [InlineData("0-15 * * * * *")] + [InlineData("0-15 02-59 * * * *")] + [InlineData("1-14 2-59 20-23 * * *")] + [InlineData("0-59 0-59 0-23 1-31 1-12 0-6")] + [InlineData("*/1 2,4,5 * 2-9 JAN,FEB,DEC MON-WED")] + public void IsCronExpression(string testValue) + { + var schedule = DaprJobSchedule.FromExpression(testValue); + Assert.True(schedule.IsCronExpression); + Assert.False(schedule.IsPrefixedPeriodExpression); + Assert.False(schedule.IsPointInTimeExpression); + Assert.False(schedule.IsDurationExpression); + } +} diff --git a/test/Dapr.Jobs.Test/Responses/DaprJobDetailsTests.cs b/test/Dapr.Jobs.Test/Responses/DaprJobDetailsTests.cs new file mode 100644 index 000000000..0a416b00e --- /dev/null +++ b/test/Dapr.Jobs.Test/Responses/DaprJobDetailsTests.cs @@ -0,0 +1,48 @@ +// ------------------------------------------------------------------------ +// Copyright 2024 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using System; +using System.Text.Json; +using Dapr.Jobs.Models; +using Dapr.Jobs.Models.Responses; + +namespace Dapr.Jobs.Test.Responses; + +public sealed class DaprJobDetailsTests +{ + [Fact] + public void ValidatePropertiesAreAsSet() + { + var payload = new TestPayload("Dapr", "Red"); + var payloadBytes = JsonSerializer.SerializeToUtf8Bytes(payload); + + var dueTime = DateTimeOffset.UtcNow.AddDays(2); + var ttl = DateTimeOffset.UtcNow.AddMonths(3); + const int repeatCount = 15; + + var details = new DaprJobDetails(DaprJobSchedule.Midnight) + { + RepeatCount = repeatCount, + DueTime = dueTime, + Payload = payloadBytes, + Ttl = ttl + }; + + Assert.Equal(repeatCount, details.RepeatCount); + Assert.Equal(dueTime, details.DueTime); + Assert.Equal(ttl, details.Ttl); + Assert.Equal(payloadBytes, details.Payload); + } + + private sealed record TestPayload(string Name, string Color); +} From 7356c9dea2558a47233bf0fa5a74c369fe2586ca Mon Sep 17 00:00:00 2001 From: Whit Waldo Date: Fri, 1 Nov 2024 12:23:17 -0500 Subject: [PATCH 2/2] Updated prereqs to specify .NET 6 and .NET 8 in v1.15 (#1398) Signed-off-by: Whit Waldo --- daprdocs/content/en/dotnet-sdk-docs/_index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/daprdocs/content/en/dotnet-sdk-docs/_index.md b/daprdocs/content/en/dotnet-sdk-docs/_index.md index 72e8b71d9..60a4a1a61 100644 --- a/daprdocs/content/en/dotnet-sdk-docs/_index.md +++ b/daprdocs/content/en/dotnet-sdk-docs/_index.md @@ -18,7 +18,7 @@ Dapr offers a variety of packages to help with the development of .NET applicati - [Dapr CLI]({{< ref install-dapr-cli.md >}}) installed - Initialized [Dapr environment]({{< ref install-dapr-selfhost.md >}}) -- [.NET 6+](https://dotnet.microsoft.com/download) installed +- [.NET 6](https://dotnet.microsoft.com/download) or [.NET 8+](https://dotnet.microsoft.com/download) installed ## Installation