From ef26f746099e2230d12e77106343cac5390b3b8b Mon Sep 17 00:00:00 2001 From: doomchild Date: Sun, 17 Apr 2022 16:46:41 -0500 Subject: [PATCH] Initial implementation --- .editorconfig | 6 + .github/workflows/publish-release.yml | 25 ++ .github/workflows/validate-project.yml | 27 ++ .gitignore | 136 ++++++ README.md | 121 +++++- TaskChaining.sln | 31 ++ ci/Dockerfile | 10 + ci/bin/compose.sh | 40 ++ ci/bin/publish.sh | 43 ++ ci/bin/validate.sh | 40 ++ ci/docker-compose.dependencies.yml | 23 + ci/docker-compose.project.yml | 21 + ci/libexec/ci-workflows.sh | 113 +++++ project-metadata.json | 24 ++ src/TaskChaining.csproj | 10 + src/TaskExtensions.cs | 73 ++++ src/TaskExtras.cs | 28 ++ src/TaskStatics.cs | 24 ++ tests/unit/TaskChainingTests.cs | 567 +++++++++++++++++++++++++ tests/unit/TaskChainingTests.csproj | 31 ++ tests/unit/TaskExtrasTests.cs | 113 +++++ 21 files changed, 1504 insertions(+), 2 deletions(-) create mode 100644 .editorconfig create mode 100644 .github/workflows/publish-release.yml create mode 100644 .github/workflows/validate-project.yml create mode 100644 .gitignore create mode 100644 TaskChaining.sln create mode 100644 ci/Dockerfile create mode 100755 ci/bin/compose.sh create mode 100755 ci/bin/publish.sh create mode 100755 ci/bin/validate.sh create mode 100644 ci/docker-compose.dependencies.yml create mode 100644 ci/docker-compose.project.yml create mode 100755 ci/libexec/ci-workflows.sh create mode 100644 project-metadata.json create mode 100644 src/TaskChaining.csproj create mode 100644 src/TaskExtensions.cs create mode 100644 src/TaskExtras.cs create mode 100644 src/TaskStatics.cs create mode 100644 tests/unit/TaskChainingTests.cs create mode 100644 tests/unit/TaskChainingTests.csproj create mode 100644 tests/unit/TaskExtrasTests.cs diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..0f94f15 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,6 @@ +root = true + +[*.{cs,md}] +indent_style=spaces +indent_size=2 +encoding=utf-8 diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml new file mode 100644 index 0000000..09e68a4 --- /dev/null +++ b/.github/workflows/publish-release.yml @@ -0,0 +1,25 @@ +name: Publish Release + +on: + push: + branches: + - main + - master + +jobs: + publish-release: + # ubuntu-latest provides many dependencies. + # See: https://github.com/actions/virtual-environments/blob/master/images/linux/Ubuntu1804-README.md + runs-on: ubuntu-latest + + steps: + - name: Checkout latest commit + uses: actions/checkout@v2 + - name: Install CICEE + run: dotnet tool install -g cicee || dotnet tool update -g cicee + - name: Execute publish script - Publish project artifacts + run: cicee exec -c ci/bin/publish.sh + env: + NUGET_API_KEY: ${{secrets.NUGET_API_KEY}} + NUGET_SOURCE: ${{secrets.NUGET_SOURCE}} + RELEASE_ENVIRONMENT: true diff --git a/.github/workflows/validate-project.yml b/.github/workflows/validate-project.yml new file mode 100644 index 0000000..37408a0 --- /dev/null +++ b/.github/workflows/validate-project.yml @@ -0,0 +1,27 @@ +name: Validate Project + +on: [pull_request] + +jobs: + validate-project: + # ubuntu-latest provides many dependencies. + # See: https://github.com/actions/virtual-environments/blob/main/images/linux/Ubuntu2004-README.md + runs-on: ubuntu-latest + + steps: + - name: Checkout latest commit + uses: actions/checkout@v2 + - name: Install CICEE + run: dotnet tool install -g cicee || dotnet tool update -g cicee + - name: Execute verification script - Validate source + run: cicee exec -c ci/bin/validate.sh + env: + NUGET_API_KEY: ${{secrets.NUGET_API_KEY}} + NUGET_SOURCE: ${{secrets.NUGET_SOURCE}} + RELEASE_ENVIRONMENT: false + - name: Execute compose script - Perform dry-run composition + run: cicee exec -c ci/bin/compose.sh + env: + NUGET_API_KEY: ${{secrets.NUGET_API_KEY}} + NUGET_SOURCE: ${{secrets.NUGET_SOURCE}} + RELEASE_ENVIRONMENT: false diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f7d177c --- /dev/null +++ b/.gitignore @@ -0,0 +1,136 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. + +# User-specific files +*.suo +*.user +*.sln.docstates +.vs/ + +# Build results + +[Dd]ebug/ +[Rr]elease/ +x64/ +[Bb]in/ +[Oo]bj/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +*_i.c +*_p.c +*_i.h +*.ilk +*.meta +*.obj +*.pch +*.pdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.log +*.svclog +*.scc + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opensdf +*.sdf +*.cachefile + +# Visual Studio profiler +*.psess +*.vsp +*.vspx + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# Click-Once directory +publish/ + +# Publish Web Output +*.Publish.xml +*.pubxml +*.azurePubxml + +# NuGet Packages Directory +## TODO: If you have NuGet Package Restore enabled, uncomment the next line +packages/ +## TODO: If the tool you use requires repositories.config, also uncomment the next line +!packages/repositories.config + +# Windows Azure Build Output +csx/ +*.build.csdef + +# Windows Store app package directory +AppPackages/ + +# Others +sql/ +*.Cache +ClientBin/ +[Ss]tyle[Cc]op.* +![Ss]tyle[Cc]op.targets +~$* +*~ +*.dbmdl +*.[Pp]ublish.xml + +*.publishsettings + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file to a newer +# Visual Studio version. Backup files are not needed, because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm + +# SQL Server files +App_Data/*.mdf +App_Data/*.ldf + +# ========================= +# Windows detritus +# ========================= + +# Windows image file caches +Thumbs.db +ehthumbs.db + +# Folder config file +Desktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Mac desktop service store files +.DS_Store + +_NCrunch* + +!ci/bin/ \ No newline at end of file diff --git a/README.md b/README.md index 7cc0c69..d43399f 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,119 @@ -# task-chaining -Monadic-style chaining for C# Tasks +# RLC.TaskChaining + +Monadic-style chaining for C# Tasks. + +## Rationale + +Asynchronous code (particularly in C#) typically relies on using the `async`/`await` feature introduced in C# 5.0. This has a lot of benefits, but it unfortunately tends to push code into an imperative style. This library aims to make writing asychronous functional code easier, cleaner, and less error-prone using extensions to `System.Threading.Tasks`. + +## Installation + +Install RLC.TaskChaining as a NuGet package via an IDE package manager or using the command-line instructions at [nuget.org][]. + +## API + +### Chaining + +#### Then + +Once a `Task` has been created, successive operations can be chained using the `Then` method. + +```c# +HttpClient client; // Assuming this is coming from an HttpClientFactory or injected or whatever + +Task.FromResult("https://www.google.com") // Task + .Then(client.GetAsync) // Task + .Then(response => response.StatusCode); // Task +``` + +#### Catch + +When a `Task` enters a faulted state, the `Catch` method can be used to return the `Task` to a non-faulted state. + +```c# +HttpClient client; // Assuming this is coming from an HttpClientFactory or injected or whatever + +Task.FromResult("not-a-url") // Task + .Then(client.GetAsync) // Task but FAULTED + .Catch(exception => exception.Message) // Task and NOT FAULTED + .Then(message => message.Length) // Task +``` + +Note that it's entirely possible for a `Catch` method to cause the `Task` to remain in a faulted state, e.g. if you only wanted to recover into a non-faulted state if a particular exception type occurred. + +```c# +HttpClient client; // Assuming this is coming from an HttpClientFactory or injected or whatever + +Task.FromResult("not-a-url") // Task + .Then(client.GetAsync) // Task but FAULTED + .Catch(exception => exception is NullReferenceException + ? exception.Message + : Task.FromException(exception)) // Task but STILL FAULTED if anything other than NullReferenceException occurred + .Then(message => message.Length) // Task +``` + +#### IfFulfilled/IfRejected/Tap + +The `IfFulfilled` and `IfRejected` methods can be used to perform side effects such as logging when the `Promise` is in the fulfilled or faulted state, respectively. + +```c# +HttpClient client; // Assuming this is coming from an HttpClientFactory or injected or whatever + +Promise.Resolve("https://www.google.com/") + .Then(client.GetAsync) + .IfFulfilled(response => _logger.LogDebug("Got response {Response}", response) + .Then(response => response.StatusCode); +``` + +```c# +HttpClient client; // Assuming this is coming from an HttpClientFactory or injected or whatever + +Promise.Resolve("not-a-url") + .Then(client.GetAsync) + .IfRejected(exception => _logger.LogException(exception, "Failed to get URL") + .Catch(exception => exception.Message) + .Then(message => message.Length); +``` + +The `Tap` method takes both an `onFulfilled` and `onRejected` `Action` in the event that you want to perform some side effect on both sides of the `Promise` at a single time. + +```c# +HttpClient client; // Assuming this is coming from an HttpClientFactory or injected or whatever + +Promise.Resolve(someExternalUrl) + .Then(client.GetAsync) + .Tap( + response => _logger.LogDebug("Got response {Response}", response), + exception => _logger.LogException(exception, "Failed to get URL") + ) +``` + +### Static Methods + +There are some convenience methods on `TaskExtras` that are useful when transitioning between the fulfilled and faulted states. + +#### RejectIf + +`RejectIf` can be used to transition into a faulted state based on some `Predicate`. + +```c# +Task.FromResult(1) + .Then(TaskExtras.RejectIf( + value => value % 2 == 0, + value => new Exception($"{nameof(value)} was not even") + )); +``` + +#### ResolveIf + +`ResolveIf` can be used to transition from a faulted state to a fulfilled state based on some `Predicate`. + +```c# +Task.FromException(new ArgumentException()) + .Catch(TaskExtras.ResolveIf( + exception => exception is ArgumentException, + exception => exception.Message.Length + )) +``` + +[nuget.org]: https://www.nuget.org/packages/RLC.TaskChaining/ diff --git a/TaskChaining.sln b/TaskChaining.sln new file mode 100644 index 0000000..b5897be --- /dev/null +++ b/TaskChaining.sln @@ -0,0 +1,31 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 16 +VisualStudioVersion = 16.0.30114.105 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TaskChaining", "src\TaskChaining.csproj", "{68ECCC63-041F-4224-9FF7-CDA13C547DCB}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TaskChainingTests", "tests\unit\TaskChainingTests.csproj", "{0F9F7E48-C6A5-4558-A5F5-8F9E5B4690B1}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {68ECCC63-041F-4224-9FF7-CDA13C547DCB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {68ECCC63-041F-4224-9FF7-CDA13C547DCB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {68ECCC63-041F-4224-9FF7-CDA13C547DCB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {68ECCC63-041F-4224-9FF7-CDA13C547DCB}.Release|Any CPU.Build.0 = Release|Any CPU + {0F9F7E48-C6A5-4558-A5F5-8F9E5B4690B1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0F9F7E48-C6A5-4558-A5F5-8F9E5B4690B1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0F9F7E48-C6A5-4558-A5F5-8F9E5B4690B1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0F9F7E48-C6A5-4558-A5F5-8F9E5B4690B1}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {0F9F7E48-C6A5-4558-A5F5-8F9E5B4690B1} = {26E4FD11-2DB7-4E0D-841C-5ADC5760A46C} + EndGlobalSection +EndGlobal diff --git a/ci/Dockerfile b/ci/Dockerfile new file mode 100644 index 0000000..e68948b --- /dev/null +++ b/ci/Dockerfile @@ -0,0 +1,10 @@ +# Universal, base image +# See: https://registry.hub.docker.com/_/microsoft-vscode-devcontainers?tab=description +# See: https://github.com/microsoft/vscode-dev-containers/tree/master/containers/codespaces-linux +FROM mcr.microsoft.com/vscode/devcontainers/universal:linux AS build-environment + +USER root + +# Install CICEE and make sure .NET global tools are added to path +RUN dotnet tool install -g cicee +ENV PATH="${PATH}:/root/.dotnet/tools" diff --git a/ci/bin/compose.sh b/ci/bin/compose.sh new file mode 100755 index 0000000..bdc7855 --- /dev/null +++ b/ci/bin/compose.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2155 + +### +# Build the project's artifact composition. +# +# How to use: +# Customize the "ci-compose" workflow (function) defined in ci-workflows.sh. +### + +set -o errexit # Fail or exit immediately if there is an error. +set -o nounset # Fail if an unset variable is used. +set -o pipefail # Fail pipelines if any command errors, not just the last one. + +declare SCRIPT_LOCATION="$(dirname "${BASH_SOURCE[0]}")" +declare PROJECT_ROOT="${PROJECT_ROOT:-$(cd "${SCRIPT_LOCATION}/../.." && pwd)}" + +__initialize() { + # Load the CICEE continuous integration action library (local copy, by 'cicee lib', or by the specific location CICEE mounts it to). + if [[ -d "${PROJECT_ROOT}/ci/lib/ci/bash" ]]; then + source "${PROJECT_ROOT}/ci/lib/ci/bash/ci.sh" && printf "Loaded local CI lib: ${PROJECT_ROOT}/ci/lib\n" + elif [[ -n "$(command -v cicee)" ]]; then + source "$(cicee lib)" && printf "Loaded CICEE's CI lib.\n" + else + # CICEE mounts the Bash CI action library at /opt/ci-lib/bash/ci.sh. + source "/opt/ci-lib/bash/ci.sh" && printf "Loaded CICEE's mounted CI lib.\n" + fi + # Load project CI workflow library. + # Then execute the ci-env-init, ci-env-display, and ci-env-require functions, provided by the CICEE action library. + source "${PROJECT_ROOT}/ci/libexec/ci-workflows.sh" && + ci-env-init && + ci-env-display && + ci-env-require +} + +# Execute the initialization function, defined above, and ci-compose function, defined in ci/libexec/ci-workflows.sh. +__initialize && + printf "Beginning artifact composition...\n\n" && + ci-compose && + printf "Composition complete.\n\n" diff --git a/ci/bin/publish.sh b/ci/bin/publish.sh new file mode 100755 index 0000000..2ec2810 --- /dev/null +++ b/ci/bin/publish.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2155 + +### +# Build and publish the project's artifact composition. +# +# How to use: +# Customize the "ci-compose" and "ci-publish" workflows (functions) defined in ci-workflows.sh. +### + +set -o errexit # Fail or exit immediately if there is an error. +set -o nounset # Fail if an unset variable is used. +set -o pipefail # Fail pipelines if any command errors, not just the last one. + +declare SCRIPT_LOCATION="$(dirname "${BASH_SOURCE[0]}")" +declare PROJECT_ROOT="${PROJECT_ROOT:-$(cd "${SCRIPT_LOCATION}/../.." && pwd)}" + +__initialize() { + # Load the CICEE continuous integration action library (local copy, by 'cicee lib', or by the specific location CICEE mounts it to). + if [[ -d "${PROJECT_ROOT}/ci/lib/ci/bash" ]]; then + source "${PROJECT_ROOT}/ci/lib/ci/bash/ci.sh" && printf "Loaded local CI lib: ${PROJECT_ROOT}/ci/lib\n" + elif [[ -n "$(command -v cicee)" ]]; then + source "$(cicee lib)" && printf "Loaded CICEE's CI lib.\n" + else + # CICEE mounts the Bash CI action library at /opt/ci-lib/bash/ci.sh. + source "/opt/ci-lib/bash/ci.sh" && printf "Loaded CICEE's mounted CI lib.\n" + fi + # Load project CI workflow library. + # Then execute the ci-env-init, ci-env-display, and ci-env-require functions, provided by the CICEE action library. + source "${PROJECT_ROOT}/ci/libexec/ci-workflows.sh" && + ci-env-init && + ci-env-display && + ci-env-require +} + +# Execute the initialization function, defined above, and ci-compose and ci-publish functions, defined in ci/libexec/ci-workflows.sh. +__initialize && + printf "Composing build artifacts...\n\n" && + ci-compose && + printf "Composition complete.\n" && + printf "Publishing composed artifacts...\n\n" && + ci-publish && + printf "Publishing complete.\n\n" diff --git a/ci/bin/validate.sh b/ci/bin/validate.sh new file mode 100755 index 0000000..b9f39fc --- /dev/null +++ b/ci/bin/validate.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2155 + +### +# Build and validate the project's source. +# +# How to use: +# Customize the "ci-validate" workflow (function) defined in ci-workflows.sh. +### + +set -o errexit # Fail or exit immediately if there is an error. +set -o nounset # Fail if an unset variable is used. +set -o pipefail # Fail pipelines if any command errors, not just the last one. + +declare SCRIPT_LOCATION="$(dirname "${BASH_SOURCE[0]}")" +declare PROJECT_ROOT="${PROJECT_ROOT:-$(cd "${SCRIPT_LOCATION}/../.." && pwd)}" + +__initialize() { + # Load the CICEE continuous integration action library (local copy, by 'cicee lib', or by the specific location CICEE mounts it to). + if [[ -d "${PROJECT_ROOT}/ci/lib/ci/bash" ]]; then + source "${PROJECT_ROOT}/ci/lib/ci/bash/ci.sh" && printf "Loaded local CI lib: ${PROJECT_ROOT}/ci/lib\n" + elif [[ -n "$(command -v cicee)" ]]; then + source "$(cicee lib)" && printf "Loaded CICEE's CI lib.\n" + else + # CICEE mounts the Bash CI action library at /opt/ci-lib/bash/ci.sh. + source "/opt/ci-lib/bash/ci.sh" && printf "Loaded CICEE's mounted CI lib.\n" + fi + # Load project CI workflow library. + # Then execute the ci-env-init, ci-env-display, and ci-env-require functions, provided by the CICEE action library. + source "${PROJECT_ROOT}/ci/libexec/ci-workflows.sh" && + ci-env-init && + ci-env-display && + ci-env-require +} + +# Execute the initialization function, defined above, and ci-validate function defined in ci/libexec/ci-workflows.sh. +__initialize && + printf "Beginning validation...\n\n" && + ci-validate && + printf "Validation complete!\n\n" diff --git a/ci/docker-compose.dependencies.yml b/ci/docker-compose.dependencies.yml new file mode 100644 index 0000000..6493ffa --- /dev/null +++ b/ci/docker-compose.dependencies.yml @@ -0,0 +1,23 @@ +version: "3.7" + +## +# Project-specific CI environment dependencies +## + +# services: +# +# pg: +# image: postgres:latest +# environment: +# - POSTGRES_DB=postgres +# - POSTGRES_USER=pgusr +# - POSTGRES_PASSWORD=pgpwd +# restart: always +# +# pgplv8: +# image: clkao/postgres-plv8:12-2 +# environment: +# - POSTGRES_DB=postgres +# - POSTGRES_USER=pgusr +# - POSTGRES_PASSWORD=pgpwd +# restart: always diff --git a/ci/docker-compose.project.yml b/ci/docker-compose.project.yml new file mode 100644 index 0000000..f0a5928 --- /dev/null +++ b/ci/docker-compose.project.yml @@ -0,0 +1,21 @@ +version: "3.7" + +## +# Project-specific CI environment extensions +## + +services: + + # cicee execution service. + ci-exec: + # depends_on: + # - pg + environment: + NUGET_SOURCE: + NUGET_API_KEY: + # Environment variables with only a key are resolved to their values on the machine running (Docker) Compose. + #-- + # Project + #-- + # NOTE: Root user specified below helps address permissions errors when using the default CICEE Dockerfile. + user: root diff --git a/ci/libexec/ci-workflows.sh b/ci/libexec/ci-workflows.sh new file mode 100755 index 0000000..846c256 --- /dev/null +++ b/ci/libexec/ci-workflows.sh @@ -0,0 +1,113 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2155 + +### +# Project CI Workflow Composition Library. +# Contains functions which execute the project's high-level continuous integration tasks. +# +# How to use: +# Update the "workflow compositions" in this file to perform each of the named continuous integration tasks. +# Add additional workflow functions as needed. Note: Functions must be executed to affect CI process. +### + +set -o errexit # Fail or exit immediately if there is an error. +set -o nounset # Fail if an unset variable is used. +set -o pipefail # Fail pipelines if any command errors, not just the last one. + +# Infer this script has been sourced based upon WORKFLOWS_SCRIPT_LOCATION being non-empty. +if [[ -n "${WORKFLOWS_SCRIPT_LOCATION:-}" ]]; then + # Workflows are already sourced. Exit. + # Check to see if this script was sourced. + # See: https://stackoverflow.com/a/28776166/402726 + (return 0 2>/dev/null) && sourced=1 || sourced=0 + if [[ $sourced -eq 1 ]]; then + # NOTE: return is used, rather than exit, to prevent shell exit when sourcing from an interactive shell. + return 0 + else + exit 0 + fi +fi + +# Context +WORKFLOWS_SCRIPT_LOCATION="${BASH_SOURCE[0]}" +declare WORKFLOWS_SCRIPT_DIRECTORY="$(dirname "${WORKFLOWS_SCRIPT_LOCATION}")" +PROJECT_ROOT="${PROJECT_ROOT:-$(cd "${WORKFLOWS_SCRIPT_DIRECTORY}" && cd ../.. && pwd)}" + +# Load the CICEE continuous integration action library (local copy, by 'cicee lib', or by the specific location CICEE mounts it to). +if [[ -d "${PROJECT_ROOT}/ci/lib/ci/bash" ]]; then + source "${PROJECT_ROOT}/ci/lib/ci/bash/ci.sh" && printf "Loaded local CI lib: ${PROJECT_ROOT}/ci/lib\n" +elif [[ -n "$(command -v cicee)" ]]; then + source "$(cicee lib)" && printf "Loaded CICEE's CI lib.\n" +else + # CICEE mounts the Bash CI action library at /opt/ci-lib/bash/ci.sh. + source "/opt/ci-lib/bash/ci.sh" && printf "Loaded CICEE's mounted CI lib.\n" +fi + +#### +# BEGIN Workflow Compositions +# These commands are executed by CI entrypoint scripts, e.g., publish.sh. +# By convention, each CI workflow function begins with "ci-". +#### + +#-- +# Validate the project's source, e.g. run tests, linting. +#-- +ci-validate() { + # How to use: + # Uncomment the example validation workflow line(s) below which apply to the project, or execute validation commands. + + printf "...\nTODO: Implement ci-validate in %s ...\n\n" "${WORKFLOWS_SCRIPT_LOCATION}" + # .NET _______ + # ci-dotnet-restore && + # ci-dotnet-build && + # ci-dotnet-test + + # Node.js ____ + # npm ci && + # npm run build && + # npm run test +} + +#-- +# Compose the project's artifacts, e.g., compiled binaries, Docker images. +#-- +ci-compose() { + # How to use: + # Uncomment the example composition workflow line(s) below which apply to the project, or execute composition commands. + + printf "...\nTODO: Implement ci-compose in %s ...\n\n" "${WORKFLOWS_SCRIPT_LOCATION}" + # .NET Library ________________________________ + # ci-dotnet-pack + + # .NET Application distributed as Docker image + # ci-dotnet-publish && ci-docker-build + + # AWS CDK _____________________________________ + # ci-aws-cdk-synth +} + +#-- +# Publish the project's artifact composition. +#-- +ci-publish() { + # How to use: + # Uncomment the example publishing workflow line(s) below which apply to the project, or execute publishing commands. + + printf "...\nTODO: Implement ci-publish in %s ...\n\n" "${WORKFLOWS_SCRIPT_LOCATION}" + # Push Docker image to AWS ECR ______ + # ci-aws-ecr-docker-login && ci-docker-push + + # Push .NET NuGet package ___________ + # ci-dotnet-nuget-push + + # Deploy AWS CDK Cloud Assembly _____ + # ci-aws-cdk-deploy +} + +export -f ci-compose +export -f ci-publish +export -f ci-validate + +#### +# END Workflow Compositions +#### diff --git a/project-metadata.json b/project-metadata.json new file mode 100644 index 0000000..056ca5f --- /dev/null +++ b/project-metadata.json @@ -0,0 +1,24 @@ +{ + "name": "task-chaining", + "description": "Extension methods to System.Threading.Task to allow Promise-like chaining", + "title": "TaskChaining", + "version": "0.1.0", + "ciEnvironment": { + "variables": [ + { + "name": "NUGET_API_KEY", + "description": "NuGet API Key", + "required": true, + "secret": true, + "defaultValue": null + }, + { + "name": "NUGET_SOURCE", + "description": "NuGet Source URL", + "required": true, + "secret": false, + "defaultValue": "https://api.nuget.org" + } + ] + } +} diff --git a/src/TaskChaining.csproj b/src/TaskChaining.csproj new file mode 100644 index 0000000..9a54b4b --- /dev/null +++ b/src/TaskChaining.csproj @@ -0,0 +1,10 @@ + + + + net6.0 + enable + Library + RLC.TaskChaining + + + diff --git a/src/TaskExtensions.cs b/src/TaskExtensions.cs new file mode 100644 index 0000000..3dfdacb --- /dev/null +++ b/src/TaskExtensions.cs @@ -0,0 +1,73 @@ +using System; +using System.Threading.Tasks; + +namespace RLC.TaskChaining; + +using static TaskStatics; + +public static class TaskExtensions +{ + public static Task Catch(this Task task, Func onRejected) => task.Then( + Task.FromResult, + Pipe2(onRejected, Task.FromResult) + ); + + public static Task Catch(this Task task, Func> onRejected) => task.Then( + Task.FromResult, + onRejected + ); + + public static Task Fold(this Task task, Func leftMap, Func rightMap) + { + return task.ContinueWith(async continuationTask => continuationTask.IsFaulted + ? continuationTask.Exception?.InnerException != null + ? leftMap(continuationTask.Exception.InnerException) + : leftMap(continuationTask.Exception!) + : rightMap(await continuationTask) + ).Unwrap(); + } + + public static Task IfResolved(this Task task, Action onFulfilled) => task.Then>( + Pipe2(TaskStatics.Tap(onFulfilled), Task.FromResult), + Task.FromException + ).Unwrap(); + + public static Task IfRejected(this Task task, Action onRejected) => task.Then>( + Task.FromResult, + Pipe2(TaskStatics.Tap(onRejected), Task.FromException) + ).Unwrap(); + + public static Task Tap( + this Task task, + Action onFulfilled, + Action onRejected + ) => task.IfResolved(onFulfilled).IfRejected(onRejected); + + public static Task Then(this Task task, Func onFulfilled) => task.Then( + Pipe2(onFulfilled, Task.FromResult), + Task.FromException + ); + + public static Task Then(this Task task, Func> onFulfilled) => task.Then( + Pipe2(onFulfilled, Task.FromResult), + Task.FromException + ).Unwrap(); + + public static Task Then(this Task task, Func onFulfilled, Func onRejected) => task.Then( + Pipe2(onFulfilled, Task.FromResult), + Pipe2(onRejected, Task.FromResult) + ); + + public static Task Then(this Task task, Func> onFulfilled, Func onRejected) => task.Then( + onFulfilled, + Pipe2(onRejected, Task.FromResult) + ); + + public static Task Then(this Task task, Func> onFulfilled, Func> onRejected) + { + return task.Fold( + async exception => await onRejected(exception), + async value => await onFulfilled(value) + ).Unwrap(); + } +} diff --git a/src/TaskExtras.cs b/src/TaskExtras.cs new file mode 100644 index 0000000..d9c1153 --- /dev/null +++ b/src/TaskExtras.cs @@ -0,0 +1,28 @@ +using System; +using System.Threading.Tasks; + +namespace RLC.TaskChaining; + +public static class TaskExtras +{ + public static Func> RejectIf( + Predicate predicate, + Func rejectionSupplier + ) => value => predicate(value) + ? Task.FromResult(value) + : Task.FromException(rejectionSupplier(value)); + + public static Func> ResolveIf( + Predicate predicate, + Func resolutionSupplier + ) => value => predicate(value) + ? Task.FromResult(resolutionSupplier(value)) + : Task.FromException(value); + + public static Func> ResolveIf( + Predicate predicate, + Func> resolutionSupplier + ) => value => predicate(value) + ? resolutionSupplier(value) + : Task.FromException(value); +} \ No newline at end of file diff --git a/src/TaskStatics.cs b/src/TaskStatics.cs new file mode 100644 index 0000000..9991ed7 --- /dev/null +++ b/src/TaskStatics.cs @@ -0,0 +1,24 @@ +using System; +using System.Runtime.CompilerServices; + +namespace RLC.TaskChaining; + +internal static class TaskStatics +{ + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static T Invoke(Func supplier) => supplier(); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static Func Pipe2(Func f, Func g) => x => g(f(x)); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static Func Tap(Action consumer) + { + return value => + { + consumer(value); + + return value; + }; + } +} \ No newline at end of file diff --git a/tests/unit/TaskChainingTests.cs b/tests/unit/TaskChainingTests.cs new file mode 100644 index 0000000..d002c3e --- /dev/null +++ b/tests/unit/TaskChainingTests.cs @@ -0,0 +1,567 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +using Jds.TestingUtils.MockHttp; +using RLC.TaskChaining; +using Xunit; + +namespace RLC.TaskChainingTests; + +public class TaskChainingTests +{ + public class Catch + { + public class ForExceptionTtoT + { + [Fact] + public async void ItShouldReportUnfaultedAfterCatching() + { + Func testFunc = _ => throw new ArgumentException(); + Task testTask = Task.FromResult("abcde") + .Then(testFunc) + .Catch(ex => ex.Message.Length); + + await Task.Delay(100); + + Assert.True(testTask.IsCompletedSuccessfully); + } + + [Fact] + public async void ItShouldReportUnfaultedAfterCatchingFromAsync() + { + Func> testFunc = async _ => { await Task.Delay(1); throw new ArgumentException(); }; + Task testTask = Task.FromResult("12345") + .Then(testFunc) + .Catch(ex => ex.Message.Length); + + await Task.Delay(100); + + Assert.True(testTask.IsCompletedSuccessfully); + } + + [Fact] + public async void ItShouldReturnTheFulfilledValueAfterCatching() + { + Func testFunc = _ => throw new ArgumentException(); + int expectedValue = 46; + int actualValue = await Task.FromResult("12345") + .Then(testFunc) + .Catch(ex => ex.Message.Length); + + await Task.Delay(100); + + Assert.Equal(expectedValue, actualValue); + } + } + + public class ForExceptionToTaskT + { + [Fact] + public async void ItShouldReportUnfaultedAfterCatching() + { + Func testFunc = _ => throw new ArgumentException(); + Task testTask = Task.FromResult("12345") + .Then(testFunc) + .Catch(ex => Task.FromResult(ex.Message.Length)); + + await Task.Delay(100); + + Assert.True(testTask.IsCompletedSuccessfully); + } + + [Fact] + public async void ItShouldReportUnfaultedAfterCatchingFromAsync() + { + Func> testFunc = async _ => { await Task.Delay(1); throw new ArgumentException(); }; + Task testTask = Task.FromResult("12345") + .Then(testFunc) + .Catch(ex => Task.FromResult(ex.Message.Length)); + + await Task.Delay(100); + + Assert.True(testTask.IsCompletedSuccessfully); + } + + [Fact] + public async void ItShouldReturnTheFulfilledValueAfterCatching() + { + Func testFunc = _ => throw new ArgumentException(); + int expectedValue = 46; + int actualValue = await Task.FromResult("12345") + .Then(testFunc) + .Catch(ex => Task.FromResult(ex.Message.Length)); + + await Task.Delay(100); + + Assert.Equal(expectedValue, actualValue); + } + + [Fact] + public async void ItShouldReturnTheFulfilledValueAfterCatchingFromAsync() + { + ArgumentException testException = new(); + Func testFunc = _ => throw testException; + int expectedValue = 46; + int actualValue = await Task.FromResult("12345") + .Then(testFunc) + .Catch(async ex => + { + await Task.Delay(1); + return ex.Message.Length; + }); + + await Task.Delay(100); + + Assert.Equal(expectedValue, actualValue); + } + } + } + + public class Then + { + public class ForTtoTNext + { + [Fact] + public async void ItShouldTransition() + { + int expectedValue = 5; + int actualValue = await Task.FromResult("12345") + .Then(str => str.Length); + + Assert.Equal(expectedValue, actualValue); + } + + [Fact] + public async void ItShouldCompleteWithoutAwaiting() + { + int expectedValue = 5; + int actualValue = 0; + + _ = Task.FromResult("12345") + .Then(str => + { + actualValue = str.Length; + + return actualValue; + }); + + await Task.Delay(100); + + Assert.Equal(expectedValue, actualValue); + } + + [Fact] + public async void ItShouldFaultForThrownExceptions() + { + Func testFunc = _ => throw new Exception(); + + Task testTask = Task.FromResult("abc") + .Then(testFunc); + + await Task.Delay(100); + + Assert.True(testTask.IsFaulted); + } + + [Fact] + public async void ItShouldRethrowForFaults() + { + Task testTask = Task.FromException(new ArgumentNullException("abcde")) + .Then(value => value.Length); + + await Assert.ThrowsAsync(async () => await testTask); + } + + [Fact] + public async void ItShouldNotRunForAFault() + { + Exception testException = new(Guid.NewGuid().ToString()); + + await Assert.ThrowsAsync( + async () => await Task.FromException(testException) + .Then(str => str.Length) + ); + } + + [Fact] + public async void ItShouldReportFaultedForThrownException() + { + Func testFunc = _ => throw new ArgumentException(); + Task testTask = Task.FromResult("12345").Then(testFunc); + + await Task.Delay(100); + + Assert.True(testTask.IsFaulted); + } + + [Fact] + public async void ItShouldThrowFaultedException() + { + Func testFunc = _ => throw new ArgumentException(); + + await Assert.ThrowsAsync( + async () => await Task.FromResult("12345").Then(testFunc) + ); + } + } + + public class ForTtoTaskTNext + { + [Fact] + public async void ItShouldTransition() + { + int expectedValue = 5; + int actualValue = await Task.FromResult("12345") + .Then(str => Task.FromResult(str.Length)); + + Assert.Equal(expectedValue, actualValue); + } + + [Fact] + public async void ItShouldCompleteWithoutAwaiting() + { + int expectedValue = 5; + int actualValue = 0; + + _ = Task.FromResult("12345") + .Then(str => + { + actualValue = str.Length; + + return Task.FromResult(actualValue); + }); + + await Task.Delay(100); + + Assert.Equal(expectedValue, actualValue); + } + + [Fact] + public async void ItShouldNotRunForAFault() + { + Exception testException = new(Guid.NewGuid().ToString()); + + await Assert.ThrowsAsync( + async () => await Task.FromException(testException) + .Then(str => Task.FromResult(str.Length)) + ); + } + + [Fact] + public async void ItShouldContinueAsyncTasks() + { + int expectedValue = 5; + int actualValue = 0; + + _ = Task.FromResult("12345") + .Then(async str => + { + await Task.Delay(1); + + actualValue = str.Length; + + return Task.FromResult(str.Length); + }); + + await Task.Delay(100); + + Assert.Equal(expectedValue, actualValue); + } + + [Fact] + public async void ItShouldContinueAsyncTasksWithoutAwaiting() + { + int expectedValue = 5; + int actualValue = 0; + + _ = Task.FromResult("12345") + .Then(async str => + { + await Task.Delay(1); + + actualValue = str.Length; + + return Task.FromResult(actualValue); + }); + + await Task.Delay(100); + + Assert.Equal(expectedValue, actualValue); + } + + [Fact] + public async void ItShouldReportFaultedForThrownException() + { + Func> testFunc = _ => throw new ArgumentException(); + Task testTask = Task.FromResult("12345").Then(testFunc); + + await Task.Delay(100); + + Assert.True(testTask.IsFaulted); + } + + [Fact] + public async void ItShouldThrowFaultedException() + { + Func> testFunc = _ => throw new ArgumentException(); + + await Assert.ThrowsAsync( + async () => await Task.FromResult("12345").Then(testFunc) + ); + } + + [Fact] + public async void ItShouldReportFaultedForThrownExceptionFromAsync() + { + Func> testFunc = async _ => + { + await Task.Delay(1); + throw new ArgumentException(); + }; + Task testTask = Task.FromResult("12345").Then(testFunc); + + await Task.Delay(100); + + Assert.True(testTask.IsFaulted); + } + + [Fact] + public async void ItShouldThrowFaultedExceptionFromAsync() + { + Func> testFunc = async _ => + { + await Task.Delay(1); + throw new ArgumentException(); + }; + + await Assert.ThrowsAsync( + async () => await Task.FromResult("12345").Then(testFunc) + ); + } + + [Fact] + public async void ItShouldFaultForThrownExceptions() + { + Func> testFunc = _ => throw new Exception(); + + Task testTask = Task.FromResult("abc") + .Then(testFunc); + + await Task.Delay(100); + + Assert.True(testTask.IsFaulted); + } + + [Fact] + public async void ItShouldCaptureTaskCancellation() + { + HttpClient testHttpClient = new MockHttpBuilder() + .WithHandler(messageCaseBuilder => messageCaseBuilder.AcceptAll() + .RespondWith((responseBuilder, _) => responseBuilder.WithStatusCode(HttpStatusCode.OK)) + ) + .BuildHttpClient(); + ; + CancellationTokenSource testTokenSource = new(); + testTokenSource.Cancel(); + + Task testTask = Task.FromResult("http://anything.anywhere") + .Then(async url => await testHttpClient.GetStringAsync(url, testTokenSource.Token)); + + await Assert.ThrowsAsync(async () => await testTask); + } + } + } + + public class Tap + { + [Fact] + public async void ItShouldPerformASideEffectOnAResolution() + { + int actualValue = 0; + int expectedValue = 5; + + await Task.FromResult(5) + .Tap(value => + { + actualValue = value; + }, + _ => { } + ); + + Assert.Equal(expectedValue, actualValue); + } + + [Fact] + public async void ItShouldPerformASideEffectOnAResolutionWithoutAwaiting() + { + int actualValue = 0; + int expectedValue = 5; + + _ = Task.FromResult(5) + .Tap(value => + { + actualValue = value; + }, + _ => { } + ); + + await Task.Delay(100); + + Assert.Equal(expectedValue, actualValue); + } + + [Fact] + public async void ItShouldPerformASideEffectOnAFaultWithoutAwaiting() + { + int actualValue = 0; + int expectedValue = 5; + + _ = Task.FromException(new ArgumentNullException()) + .Tap( + _ => { }, + _ => + { + actualValue = 5; + } + ); + + await Task.Delay(100); + + Assert.Equal(expectedValue, actualValue); + } + } + + public class IfResolved + { + [Fact] + public async void ItShouldPerformASideEffect() + { + int actualValue = 0; + int expectedValue = 5; + + await Task.FromResult(5) + .IfResolved(value => + { + actualValue = value; + }); + + Assert.Equal(expectedValue, actualValue); + } + + [Fact] + public async void ItShouldPerformASideEffectWithoutAwaiting() + { + int actualValue = 0; + int expectedValue = 5; + + _ = Task.FromResult(5) + .IfResolved(value => + { + actualValue = value; + }); + + await Task.Delay(100); + + Assert.Equal(expectedValue, actualValue); + } + + [Fact] + public async void ItShouldNotPerformASideEffectForAFault() + { + int actualValue = 0; + int expectedValue = 0; + + try + { + await Task.FromException(new ArgumentNullException()) + .IfResolved(value => + { + actualValue = 5; + }); + } + catch (ArgumentNullException) + { + } + + Assert.Equal(expectedValue, actualValue); + } + + [Fact] + public async void ItShouldNotPerformASideEffectForAFaultWithoutAwaiting() + { + int actualValue = 0; + int expectedValue = 0; + + _ = Task.FromException(new ArgumentNullException()) + .IfResolved(_ => + { + actualValue = 5; + }); + + await Task.Delay(100); + + Assert.Equal(expectedValue, actualValue); + } + } + + public class IfRejected + { + [Fact] + public async void ItShouldPerformASideEffectWithoutAwaiting() + { + int actualValue = 0; + int expectedValue = 5; + + _ = Task.FromException(new ArgumentNullException()) + .IfRejected(_ => + { + actualValue = 5; + }); + + await Task.Delay(100); + + Assert.Equal(expectedValue, actualValue); + } + + [Fact] + public async void ItShouldNotPerformASideEffectForAResolution() + { + int actualValue = 0; + int expectedValue = 0; + + try + { + await Task.FromResult(5) + .IfRejected(_ => + { + actualValue = 5; + }); + } + catch (ArgumentNullException) + { + } + + Assert.Equal(expectedValue, actualValue); + } + + [Fact] + public async void ItShouldNotPerformASideEffectForAResolutionWithoutAwaiting() + { + int actualValue = 0; + int expectedValue = 0; + + _ = Task.FromResult(5) + .IfRejected(_ => + { + actualValue = 5; + }); + + await Task.Delay(100); + + Assert.Equal(expectedValue, actualValue); + } + } +} \ No newline at end of file diff --git a/tests/unit/TaskChainingTests.csproj b/tests/unit/TaskChainingTests.csproj new file mode 100644 index 0000000..4bbd88d --- /dev/null +++ b/tests/unit/TaskChainingTests.csproj @@ -0,0 +1,31 @@ + + + + net6.0 + enable + + false + RLC.TaskChainingTests + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + diff --git a/tests/unit/TaskExtrasTests.cs b/tests/unit/TaskExtrasTests.cs new file mode 100644 index 0000000..01eb397 --- /dev/null +++ b/tests/unit/TaskExtrasTests.cs @@ -0,0 +1,113 @@ +using System; +using System.Threading.Tasks; + +using RLC.TaskChaining; +using Xunit; + +namespace RLC.TaskChainingTests; + +public class TaskExtrasTests +{ + public class RejectIf + { + [Fact] + public async void ItShouldRejectForASuccessfulPredicate() + { + Task testTask = TaskExtras.RejectIf( + (int value) => value % 2 == 0, + value => new ArgumentException() + )(1); + + await Task.Delay(100); + + Assert.True(testTask.IsFaulted); + } + + [Fact] + public async void ItShouldThrowTheExpectedExceptionForASuccessfulPredicate() + { + Task testTask = TaskExtras.RejectIf( + (int value) => value % 2 == 0, + value => new ArgumentException() + )(1); + + await Task.Delay(100); + + await Assert.ThrowsAsync(async () => await testTask); + } + + [Fact] + public async void ItShouldResolveForAFailedPredicate() + { + Task testTask = TaskExtras.RejectIf( + (int value) => value % 2 == 0, + value => new ArgumentException() + )(2); + + await Task.Delay(100); + + Assert.True(testTask.IsCompletedSuccessfully); + } + } + + public class ResolveIf + { + public class WithRawResolutionSupplier + { + [Fact] + public async void ItShouldResolveForASuccessfulPredicate() + { + Task testTask = TaskExtras.ResolveIf( + (Exception value) => value is ArgumentException, + value => value.Message.Length + )(new ArgumentException()); + + await Task.Delay(100); + + Assert.True(testTask.IsCompletedSuccessfully); + } + + [Fact] + public async void ItShouldRejectForAFailedPredicate() + { + Task testTask = TaskExtras.ResolveIf( + (Exception value) => value is ArgumentException, + value => value.Message.Length + )(new NullReferenceException()); + + await Task.Delay(100); + + Assert.True(testTask.IsFaulted); + } + } + + public class WithTaskResolutionSupplier + { + [Fact] + public async void ItShouldResolveForASuccessfulPredicate() + { + Task testTask = TaskExtras.ResolveIf( + (Exception value) => value is ArgumentException, + value => Task.FromResult(value.Message.Length) + )(new ArgumentException()); + + await Task.Delay(100); + + Assert.True(testTask.IsCompletedSuccessfully); + } + + [Fact] + public async void ItShouldRejectForAFailedPredicate() + { + Task testTask = TaskExtras.ResolveIf( + (Exception value) => value is ArgumentException, + value => Task.FromResult(value.Message.Length) + )(new NullReferenceException()); + + await Task.Delay(100); + + Assert.True(testTask.IsFaulted); + } + } + } +} \ No newline at end of file