diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml
index 2a3bfc68..298edb9f 100644
--- a/.github/workflows/CI.yml
+++ b/.github/workflows/CI.yml
@@ -65,3 +65,9 @@ jobs:
- name: Test Docker Build
run: docker-compose -f docker-compose.myHr.yml -f docker-compose.myHr.override.yml build --build-arg LOCAL=true
+
+ - name: Build Template
+ run: dotnet build tools/CoreEx.Template/content
+
+ - name: Test generic Template
+ run: dotnet test tools/CoreEx.Template/content --filter Category!=WithCosmos --no-build --verbosity normal /p:CollectCoverage=true /p:Exclude="[CoreEx.TestFunction]*" /p:CoverletOutputFormat=lcov /p:CoverletOutput=./coverage/lcov3.info
diff --git a/CoreEx.sln b/CoreEx.sln
index 05d54c85..8f0db93f 100644
--- a/CoreEx.sln
+++ b/CoreEx.sln
@@ -59,11 +59,9 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CoreEx.Cosmos", "src\CoreEx
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CoreEx.Cosmos.Test", "tests\CoreEx.Cosmos.Test\CoreEx.Cosmos.Test.csproj", "{C8021CF0-006F-427C-827F-B997F26E5FF6}"
EndProject
-Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "My.Hr.Infra.Tests", "samples\My.Hr\My.Hr.Infra.Tests\My.Hr.Infra.Tests.csproj", "{7DA61666-8109-4B0C-8433-EDA87370DA28}"
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tools", "tools", "{AD4128C1-A096-451B-BD61-705EC774ADEC}"
EndProject
-Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "My.Hr.Infra", "samples\My.Hr\My.Hr.Infra\My.Hr.Infra.csproj", "{3601F5D1-AFAC-417B-AC9F-1E4260148B22}"
-EndProject
-Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Infra", "Infra", "{601379B6-8FF6-4272-884C-473693BE0E90}"
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CoreEx.Template", "tools\CoreEx.Template\CoreEx.Template.csproj", "{16556C5C-E54F-48EA-A38F-CE612212EBCF}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
@@ -143,14 +141,10 @@ Global
{C8021CF0-006F-427C-827F-B997F26E5FF6}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C8021CF0-006F-427C-827F-B997F26E5FF6}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C8021CF0-006F-427C-827F-B997F26E5FF6}.Release|Any CPU.Build.0 = Release|Any CPU
- {7DA61666-8109-4B0C-8433-EDA87370DA28}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {7DA61666-8109-4B0C-8433-EDA87370DA28}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {7DA61666-8109-4B0C-8433-EDA87370DA28}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {7DA61666-8109-4B0C-8433-EDA87370DA28}.Release|Any CPU.Build.0 = Release|Any CPU
- {3601F5D1-AFAC-417B-AC9F-1E4260148B22}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {3601F5D1-AFAC-417B-AC9F-1E4260148B22}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {3601F5D1-AFAC-417B-AC9F-1E4260148B22}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {3601F5D1-AFAC-417B-AC9F-1E4260148B22}.Release|Any CPU.Build.0 = Release|Any CPU
+ {16556C5C-E54F-48EA-A38F-CE612212EBCF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {16556C5C-E54F-48EA-A38F-CE612212EBCF}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {16556C5C-E54F-48EA-A38F-CE612212EBCF}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {16556C5C-E54F-48EA-A38F-CE612212EBCF}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -175,9 +169,7 @@ Global
{F3384ADC-1DA8-4538-B991-DBD2BC591AF1} = {4B6BC31E-93B1-42B0-AE09-AD85AC4DB657}
{8D0CC3FD-65C2-4302-99F4-A90AC7680E1B} = {4B6BC31E-93B1-42B0-AE09-AD85AC4DB657}
{C8021CF0-006F-427C-827F-B997F26E5FF6} = {3145DCB9-98FB-4519-BCC0-75A22A252EDC}
- {7DA61666-8109-4B0C-8433-EDA87370DA28} = {601379B6-8FF6-4272-884C-473693BE0E90}
- {3601F5D1-AFAC-417B-AC9F-1E4260148B22} = {601379B6-8FF6-4272-884C-473693BE0E90}
- {601379B6-8FF6-4272-884C-473693BE0E90} = {F53B0E83-87F8-4679-94B8-268FE6A9C0AD}
+ {16556C5C-E54F-48EA-A38F-CE612212EBCF} = {AD4128C1-A096-451B-BD61-705EC774ADEC}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {8B4566D2-9B22-4E27-9654-402BDBA6C744}
diff --git a/Docker.md b/Docker.md
index 0909e1a1..99de8164 100644
--- a/Docker.md
+++ b/Docker.md
@@ -23,6 +23,14 @@ services:
Service Bus should have `pendingverifications` queue used by *My.Hr* sample.
+## Running SQL database only
+
+It's possible to run only DB container for local development with
+
+```bash
+docker-compose -f docker-compose.yml -f docker-compose.override.yml -f docker-compose.DB.only.yml up
+```
+
## To build
```bash
diff --git a/docker-compose.myHr.override.yml b/docker-compose.myHr.override.yml
index 3f0b1d66..2df2ac3e 100644
--- a/docker-compose.myHr.override.yml
+++ b/docker-compose.myHr.override.yml
@@ -1,11 +1,5 @@
version: '3.4'
-# The default docker-compose.override file can use the "localhost" as the external name for testing web apps within the same dev machine.
-# The ESHOP_EXTERNAL_DNS_NAME_OR_IP environment variable is taken, by default, from the ".env" file defined like:
-# ESHOP_EXTERNAL_DNS_NAME_OR_IP=localhost
-# but values present in the environment vars at runtime will always override those defined inside the .env file
-# An external IP or DNS name has to be used (instead localhost and the 10.0.75.1 IP) when testing the Web apps and the Xamarin apps from remote machines/devices using the same WiFi, for instance.
-
services:
sqldata:
diff --git a/nuget-publish.ps1 b/nuget-publish.ps1
index b901d4eb..ddd713fc 100644
--- a/nuget-publish.ps1
+++ b/nuget-publish.ps1
@@ -56,7 +56,8 @@ param(
"src\CoreEx.Cosmos",
"src\CoreEx.FluentValidation",
"src\CoreEx.Newtonsoft",
- "src\CoreEx.Validation")
+ "src\CoreEx.Validation",
+ "tools\CoreEx.Template")
)
$ShouldPublishRemote = (![string]::IsNullOrEmpty($apiKey) -and ![string]::IsNullOrEmpty($NugetServer))
diff --git a/samples/My.Hr/My.Hr.Api/Dockerfile b/samples/My.Hr/My.Hr.Api/Dockerfile
index 3a50f56f..4406f788 100644
--- a/samples/My.Hr/My.Hr.Api/Dockerfile
+++ b/samples/My.Hr/My.Hr.Api/Dockerfile
@@ -14,8 +14,6 @@ COPY "samples/My.Hr/My.Hr.Business/My.Hr.Business.csproj" "samples/My.Hr/My.Hr.B
COPY "samples/My.Hr/My.Hr.Database/My.Hr.Database.csproj" "samples/My.Hr/My.Hr.Database/My.Hr.Database.csproj"
COPY "samples/My.Hr/My.Hr.Functions/My.Hr.Functions.csproj" "samples/My.Hr/My.Hr.Functions/My.Hr.Functions.csproj"
COPY "samples/My.Hr/My.Hr.UnitTest/My.Hr.UnitTest.csproj" "samples/My.Hr/My.Hr.UnitTest/My.Hr.UnitTest.csproj"
-COPY "samples/My.Hr/My.Hr.Infra/My.Hr.Infra.csproj" "samples/My.Hr/My.Hr.Infra/My.Hr.Infra.csproj"
-COPY "samples/My.Hr/My.Hr.Infra.Tests/My.Hr.Infra.Tests.csproj" "samples/My.Hr/My.Hr.Infra.Tests/My.Hr.Infra.Tests.csproj"
COPY "src/CoreEx/CoreEx.csproj" "src/CoreEx/CoreEx.csproj"
COPY "src/CoreEx.AutoMapper/CoreEx.AutoMapper.csproj" "src/CoreEx.AutoMapper/CoreEx.AutoMapper.csproj"
diff --git a/samples/My.Hr/My.Hr.Database/Dockerfile b/samples/My.Hr/My.Hr.Database/Dockerfile
index 30af452c..18b0564a 100644
--- a/samples/My.Hr/My.Hr.Database/Dockerfile
+++ b/samples/My.Hr/My.Hr.Database/Dockerfile
@@ -18,8 +18,6 @@ COPY "samples/My.Hr/My.Hr.Business/My.Hr.Business.csproj" "samples/My.Hr/My.Hr.B
COPY "samples/My.Hr/My.Hr.Database/My.Hr.Database.csproj" "samples/My.Hr/My.Hr.Database/My.Hr.Database.csproj"
COPY "samples/My.Hr/My.Hr.Functions/My.Hr.Functions.csproj" "samples/My.Hr/My.Hr.Functions/My.Hr.Functions.csproj"
COPY "samples/My.Hr/My.Hr.UnitTest/My.Hr.UnitTest.csproj" "samples/My.Hr/My.Hr.UnitTest/My.Hr.UnitTest.csproj"
-COPY "samples/My.Hr/My.Hr.Infra/My.Hr.Infra.csproj" "samples/My.Hr/My.Hr.Infra/My.Hr.Infra.csproj"
-COPY "samples/My.Hr/My.Hr.Infra.Tests/My.Hr.Infra.Tests.csproj" "samples/My.Hr/My.Hr.Infra.Tests/My.Hr.Infra.Tests.csproj"
COPY "src/CoreEx/CoreEx.csproj" "src/CoreEx/CoreEx.csproj"
COPY "src/CoreEx.AutoMapper/CoreEx.AutoMapper.csproj" "src/CoreEx.AutoMapper/CoreEx.AutoMapper.csproj"
diff --git a/samples/My.Hr/My.Hr.Database/My.Hr.Database.csproj b/samples/My.Hr/My.Hr.Database/My.Hr.Database.csproj
index 659f4f07..e6714fff 100644
--- a/samples/My.Hr/My.Hr.Database/My.Hr.Database.csproj
+++ b/samples/My.Hr/My.Hr.Database/My.Hr.Database.csproj
@@ -23,7 +23,6 @@
-
diff --git a/samples/My.Hr/My.Hr.Functions/Dockerfile b/samples/My.Hr/My.Hr.Functions/Dockerfile
index 5721d057..3c8acc8e 100644
--- a/samples/My.Hr/My.Hr.Functions/Dockerfile
+++ b/samples/My.Hr/My.Hr.Functions/Dockerfile
@@ -15,8 +15,6 @@ COPY "samples/My.Hr/My.Hr.Business/My.Hr.Business.csproj" "samples/My.Hr/My.Hr.B
COPY "samples/My.Hr/My.Hr.Database/My.Hr.Database.csproj" "samples/My.Hr/My.Hr.Database/My.Hr.Database.csproj"
COPY "samples/My.Hr/My.Hr.Functions/My.Hr.Functions.csproj" "samples/My.Hr/My.Hr.Functions/My.Hr.Functions.csproj"
COPY "samples/My.Hr/My.Hr.UnitTest/My.Hr.UnitTest.csproj" "samples/My.Hr/My.Hr.UnitTest/My.Hr.UnitTest.csproj"
-COPY "samples/My.Hr/My.Hr.Infra/My.Hr.Infra.csproj" "samples/My.Hr/My.Hr.Infra/My.Hr.Infra.csproj"
-COPY "samples/My.Hr/My.Hr.Infra.Tests/My.Hr.Infra.Tests.csproj" "samples/My.Hr/My.Hr.Infra.Tests/My.Hr.Infra.Tests.csproj"
COPY "src/CoreEx/CoreEx.csproj" "src/CoreEx/CoreEx.csproj"
COPY "src/CoreEx.AutoMapper/CoreEx.AutoMapper.csproj" "src/CoreEx.AutoMapper/CoreEx.AutoMapper.csproj"
diff --git a/samples/My.Hr/My.Hr.Functions/Functions/EmployeeFunction.cs b/samples/My.Hr/My.Hr.Functions/Functions/EmployeeFunction.cs
index b06eb2c5..0ae7d36a 100644
--- a/samples/My.Hr/My.Hr.Functions/Functions/EmployeeFunction.cs
+++ b/samples/My.Hr/My.Hr.Functions/Functions/EmployeeFunction.cs
@@ -36,23 +36,23 @@ public EmployeeFunction(WebApi webApi, EmployeeService service, IValidator GetAsync([HttpTrigger(AuthorizationLevel.Function, "get", Route = "api/employees/{id}")] HttpRequest request, Guid id)
+ public Task GetAsync([HttpTrigger(AuthorizationLevel.Function, "get", Route = "employees/{id}")] HttpRequest request, Guid id)
=> _webApi.GetAsync(request, _ => _service.GetEmployeeAsync(id));
[FunctionName("GetAll")]
[OpenApiOperation(operationId: "GetAll", tags: new[] { "employee" })]
[OpenApiSecurity("function_key", SecuritySchemeType.ApiKey, Name = "code", In = OpenApiSecurityLocationType.Query)]
[OpenApiResponseWithBody(statusCode: HttpStatusCode.OK, contentType: MediaTypeNames.Application.Json, bodyType: typeof(List), Description = "Employee records")]
- public Task GetAllAsync([HttpTrigger(AuthorizationLevel.Function, "get", Route = "api/employees")] HttpRequest request)
+ public Task GetAllAsync([HttpTrigger(AuthorizationLevel.Function, "get", Route = "employees")] HttpRequest request)
=> _webApi.GetAsync(request, p => _service.GetAllAsync(p.RequestOptions.Paging));
[FunctionName("Create")]
[OpenApiOperation(operationId: "Create", tags: new[] { "employee" })]
[OpenApiSecurity("function_key", SecuritySchemeType.ApiKey, Name = "code", In = OpenApiSecurityLocationType.Query)]
[OpenApiResponseWithoutBody(statusCode: HttpStatusCode.Created, Description = "Created employee record")]
- public Task CreateAsync([HttpTrigger(AuthorizationLevel.Function, "post", Route = "api/employees")] HttpRequest request)
+ public Task CreateAsync([HttpTrigger(AuthorizationLevel.Function, "post", Route = "employees")] HttpRequest request)
=> _webApi.PostAsync(request, p => _service.AddEmployeeAsync(p.Value!),
- statusCode: HttpStatusCode.Created, validator: _validator, locationUri: e => new Uri($"api/employees/{e.Id}", UriKind.RelativeOrAbsolute));
+ statusCode: HttpStatusCode.Created, validator: _validator, locationUri: e => new Uri($"employees/{e.Id}", UriKind.RelativeOrAbsolute));
[FunctionName("Update")]
[OpenApiOperation(operationId: "Update", tags: new[] { "employee" })]
@@ -60,14 +60,14 @@ public Task CreateAsync([HttpTrigger(AuthorizationLevel.Function,
[OpenApiSecurity("function_key", SecuritySchemeType.ApiKey, Name = "code", In = OpenApiSecurityLocationType.Query)]
[OpenApiResponseWithBody(statusCode: HttpStatusCode.OK, contentType: MediaTypeNames.Application.Json, bodyType: typeof(Employee), Description = "Employee record")]
[OpenApiResponseWithoutBody(statusCode: HttpStatusCode.NotFound, Description = "Not found")]
- public Task UpdateAsync([HttpTrigger(AuthorizationLevel.Function, "put", Route = "api/employees/{id}")] HttpRequest request, Guid id)
+ public Task UpdateAsync([HttpTrigger(AuthorizationLevel.Function, "put", Route = "employees/{id}")] HttpRequest request, Guid id)
=> _webApi.PutAsync(request, p => _service.UpdateEmployeeAsync(p.Value!, id), validator: _validator);
[FunctionName("Patch")]
- public Task PatchAsync([HttpTrigger(AuthorizationLevel.Function, "patch", Route = "api/employees/{id}")] HttpRequest request, Guid id)
+ public Task PatchAsync([HttpTrigger(AuthorizationLevel.Function, "patch", Route = "employees/{id}")] HttpRequest request, Guid id)
=> _webApi.PatchAsync(request, get: _ => _service.GetEmployeeAsync(id), put: p => _service.UpdateEmployeeAsync(p.Value!, id), validator: _validator);
[FunctionName("Delete")]
- public Task DeleteAsync([HttpTrigger(AuthorizationLevel.Function, "delete", Route = "api/employees/{id}")] HttpRequest request, Guid id)
+ public Task DeleteAsync([HttpTrigger(AuthorizationLevel.Function, "delete", Route = "employees/{id}")] HttpRequest request, Guid id)
=> _webApi.DeleteAsync(request, _ => _service.DeleteEmployeeAsync(id));
}
diff --git a/samples/My.Hr/My.Hr.Functions/README.md b/samples/My.Hr/My.Hr.Functions/README.md
index df14d20f..eeda67a3 100644
--- a/samples/My.Hr/My.Hr.Functions/README.md
+++ b/samples/My.Hr/My.Hr.Functions/README.md
@@ -21,6 +21,7 @@ Sample configuration for `local.settings.json`
"VerificationResultsQueueName": "verificationResults",
"ServiceBusConnection__fullyQualifiedNamespace": "coreex.servicebus.windows.net",
+ "AzureWebJobs.ServiceBusExecuteVerificationFunction.Disabled": true, // disable when service bus is not available
"HttpLogContent": "true",
"AzureFunctionsJobHost__logging__logLevel__CoreEx": "Debug",
diff --git a/samples/My.Hr/My.Hr.Infra/Readme.md b/samples/My.Hr/My.Hr.Infra/Readme.md
deleted file mode 100644
index a3d78adb..00000000
--- a/samples/My.Hr/My.Hr.Infra/Readme.md
+++ /dev/null
@@ -1,57 +0,0 @@
-# About
-
-Infrastructure is built with [Pulumi](https://www.pulumi.com/).
-
-The easiest way to deploy it is by using Pulumi account (Free), but it's not mandatory.
-
-Prerequisites:
-
-1. [Pulumi CLI](https://www.pulumi.com/docs/get-started/install/)
-2. Azure CLI - logged in to Azure
-
-## Pulumi with azure storage
-
-Pulumi can be used without Pulumi Account, by using [Azure Storage as backend](https://www.techwatching.dev/posts/pulumi-azure-backend).
-
-1. set the `AZURE_STORAGE_ACCOUNT` environment variable to specify the Azure storage account to use
-1. set the `AZURE_STORAGE_KEY` or the `AZURE_STORAGE_SAS_TOKEN` environment variables to let Pulumi access the storage
-1. execute the following command `pulumi login azblob://` where container-path is the path to a blob container in the storage account
-
-## Configuring Pulumi (optional)
-
-Infrastructure project has only 2 settings:
-
-* `My.Hr.Infra:isAppsDeploymentEnabled` for controlling application deployment via zip deploy
-* `My.Hr.Infra:isDBSchemaDeploymentEnabled` for publishing Database schema and data
-
-> When `isAppsDeploymentEnabled` flag is set, pulumi code executes `dotnet publish -c RELEASE` to create app packages.
-
-Pulumi can be configured and previewed with:
-
-```bash
-pulumi preview -c azure-native:location=EastUs -c My.Hr.Infra:isAppsDeploymentEnabled=true -c My.Hr.Infra:isDBSchemaDeploymentEnabled=true
-```
-
-which creates a stack config file `Pulumi.dev.yaml`
-
-```yaml
-config:
- azure-native:location: EastUs
- My.Hr.Infra:isAppsDeploymentEnabled: true
- My.Hr.Infra:isDBSchemaDeploymentEnabled: true
-```
-
-## Deploy with Pulumi
-
-To deploy in `samples/My.Hr/My.Hr.Infra` run `pulumi up -c azure-native:location=EastUs -c My.Hr.Infra:isAppsDeploymentEnabled=true -c My.Hr.Infra:isDBSchemaDeploymentEnabled=true`
-
-To display outputs of the stack deployment run: `pulumi stack output --show-secrets` which will display function links with secret api key.
-
-## Alternative deployment methods
-
-Apps can also be deployed with Azure CLI, once published apps are zipped.
-
-```bash
-az webapp deploy --resource-group coreEx-dev4011fb65 --name app17b7c4c8 --src-path app.zip
-az functionapp deployment source config-zip -g coreEx-dev4011fb65 -n fun17b7c4c8 --src fun.zip
-```
diff --git a/samples/My.Hr/My.Hr.UnitTest/EmployeeFunctionTest.cs b/samples/My.Hr/My.Hr.UnitTest/EmployeeFunctionTest.cs
index 8dc69378..ff1cebef 100644
--- a/samples/My.Hr/My.Hr.UnitTest/EmployeeFunctionTest.cs
+++ b/samples/My.Hr/My.Hr.UnitTest/EmployeeFunctionTest.cs
@@ -175,7 +175,7 @@ public void C110_Create_Success()
.Run(c => c.CreateAsync(test.CreateJsonHttpRequest(HttpMethod.Post, "api/employees", e)))
.AssertCreated()
.Assert(e, "Id", "ETag")
- .AssertLocationHeader(v => new Uri($"api/employees/{v!.Id}", UriKind.Relative))
+ .AssertLocationHeader(v => new Uri($"employees/{v!.Id}", UriKind.Relative))
.GetValue();
// Do a GET to make sure it is in the database and all fields equal.
diff --git a/samples/My.Hr/My.Hr.sln b/samples/My.Hr/My.Hr.sln
index e7600107..1a020941 100644
--- a/samples/My.Hr/My.Hr.sln
+++ b/samples/My.Hr/My.Hr.sln
@@ -11,10 +11,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "My.Hr.Business", "My.Hr.Bus
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "My.Hr.Functions", "My.Hr.Functions\My.Hr.Functions.csproj", "{A62BAA55-0737-4671-BF31-89D4BE7C4097}"
EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "My.Hr.Infra", "My.Hr.Infra\My.Hr.Infra.csproj", "{E448EFD6-5CA6-4C71-B575-1149DD7181C6}"
-EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "My.Hr.Infra.Tests", "My.Hr.Infra.Tests\My.Hr.Infra.Tests.csproj", "{01B0FC8E-738D-47BB-AA57-F880532A501D}"
-EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "My.Hr.UnitTest", "My.Hr.UnitTest\My.Hr.UnitTest.csproj", "{EE307518-D5FD-45B3-9A61-4451DFC44835}"
EndProject
Global
@@ -42,14 +38,6 @@ Global
{A62BAA55-0737-4671-BF31-89D4BE7C4097}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A62BAA55-0737-4671-BF31-89D4BE7C4097}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A62BAA55-0737-4671-BF31-89D4BE7C4097}.Release|Any CPU.Build.0 = Release|Any CPU
- {E448EFD6-5CA6-4C71-B575-1149DD7181C6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {E448EFD6-5CA6-4C71-B575-1149DD7181C6}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {E448EFD6-5CA6-4C71-B575-1149DD7181C6}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {E448EFD6-5CA6-4C71-B575-1149DD7181C6}.Release|Any CPU.Build.0 = Release|Any CPU
- {01B0FC8E-738D-47BB-AA57-F880532A501D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {01B0FC8E-738D-47BB-AA57-F880532A501D}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {01B0FC8E-738D-47BB-AA57-F880532A501D}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {01B0FC8E-738D-47BB-AA57-F880532A501D}.Release|Any CPU.Build.0 = Release|Any CPU
{EE307518-D5FD-45B3-9A61-4451DFC44835}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{EE307518-D5FD-45B3-9A61-4451DFC44835}.Debug|Any CPU.Build.0 = Debug|Any CPU
{EE307518-D5FD-45B3-9A61-4451DFC44835}.Release|Any CPU.ActiveCfg = Release|Any CPU
diff --git a/src/CoreEx/Configuration/DeploymentInfo.cs b/src/CoreEx/Configuration/DeploymentInfo.cs
index d5389f8b..62bea73e 100644
--- a/src/CoreEx/Configuration/DeploymentInfo.cs
+++ b/src/CoreEx/Configuration/DeploymentInfo.cs
@@ -22,26 +22,26 @@ public class DeploymentInfo
///
/// Gets the username who performed the deployment.
///
- public virtual string By => _configuration?.GetValue("Deployment.By") ?? Unspecified;
+ public virtual string By => _configuration?.GetValue("Deployment_By") ?? Unspecified;
///
/// Gets the deployment build number.
///
- public virtual string Build => _configuration?.GetValue("Deployment.Build") ?? Unspecified;
+ public virtual string Build => _configuration?.GetValue("Deployment_Build") ?? Unspecified;
///
/// Gets the name of the deployment job that deployed the .
///
- public virtual string Name => _configuration?.GetValue("Deployment.Name") ?? Unspecified;
+ public virtual string Name => _configuration?.GetValue("Deployment_Name") ?? Unspecified;
///
/// Gets the deployment build version, such as the Git information (branch and commit) of the deployed .
///
- public virtual string Version => _configuration?.GetValue("Deployment.Version") ?? Unspecified;
+ public virtual string Version => _configuration?.GetValue("Deployment_Version") ?? Unspecified;
///
/// Gets the date and time (UTC) when deployment was performed.
///
- public virtual string DateUtc => _configuration?.GetValue("Deployment.Date") ?? Unspecified;
+ public virtual string DateUtc => _configuration?.GetValue("Deployment_Date") ?? Unspecified;
}
}
\ No newline at end of file
diff --git a/tests/CoreEx.Test/Framework/Json/JsonEmployeeTest.cs b/tests/CoreEx.Test/Framework/Json/JsonEmployeeTest.cs
index a3f022df..02757746 100644
--- a/tests/CoreEx.Test/Framework/Json/JsonEmployeeTest.cs
+++ b/tests/CoreEx.Test/Framework/Json/JsonEmployeeTest.cs
@@ -69,7 +69,7 @@ public class Employee
public void SystemTextJson_Serialize_Deserialize()
{
// Arrange
- var json = "{\n \"email\": \"piotr.karpala@avanade.com\",\n \"FirstName\": \"Piotr\",\n \"lastName\": \"Karpala\",\n \"genderCode\": \"male\",\n \"birthday\": \"1990-03-24T13:49:11.813Z\",\n \"startDate\": \"2022-03-24T13:49:11.813Z\",\n \"phoneNo\": \"985 657 9455\"\n}";
+ var json = "{\n \"email\": \"john.doe@avanade.com\",\n \"FirstName\": \"John\",\n \"lastName\": \"Doe\",\n \"genderCode\": \"male\",\n \"birthday\": \"1990-03-24T13:49:11.813Z\",\n \"startDate\": \"2022-03-24T13:49:11.813Z\",\n \"phoneNo\": \"985 657 9455\"\n}";
var js = new CoreEx.Text.Json.JsonSerializer() as IJsonSerializer;
// Act
@@ -77,7 +77,7 @@ public void SystemTextJson_Serialize_Deserialize()
// Assert
employee.Should().NotBeNull();
- employee!.FirstName.Should().Be("Piotr");
+ employee!.FirstName.Should().Be("John");
}
}
}
\ No newline at end of file
diff --git a/tests/CoreEx.TestFunction/CoreEx.TestFunction.csproj b/tests/CoreEx.TestFunction/CoreEx.TestFunction.csproj
index d0bca0c9..8cb3520e 100644
--- a/tests/CoreEx.TestFunction/CoreEx.TestFunction.csproj
+++ b/tests/CoreEx.TestFunction/CoreEx.TestFunction.csproj
@@ -3,6 +3,7 @@
netcoreapp3.1
v4
enable
+ <_FunctionsSkipCleanOutput>true
diff --git a/tests/CoreEx.TestFunction/local.settings.json b/tests/CoreEx.TestFunction/local.settings.json
index 4c5a3118..89d319f2 100644
--- a/tests/CoreEx.TestFunction/local.settings.json
+++ b/tests/CoreEx.TestFunction/local.settings.json
@@ -7,10 +7,10 @@
"Test/BackendBaseAddress": "https://backend/",
"BindingRedirects": "[ { \"ShortName\": \"System.Memory.Data\", \"RedirectToVersion\": \"6.0.0.0\", \"PublicKeyToken\": \"cc7b13ffcd2ddd51\" } ]",
- "Deployment.By": "me",
- "Deployment.Build": "build no",
- "Deployment.Name": "my deployment",
- "Deployment.Version": "1.0.0",
- "Deployment.Date": "today"
+ "Deployment_By": "me",
+ "Deployment_Build": "build no",
+ "Deployment_Name": "my deployment",
+ "Deployment_Version": "1.0.0",
+ "Deployment_Date": "today"
}
}
\ No newline at end of file
diff --git a/tools/CoreEx.Template/CoreEx.Template.csproj b/tools/CoreEx.Template/CoreEx.Template.csproj
new file mode 100644
index 00000000..1cdb44b9
--- /dev/null
+++ b/tools/CoreEx.Template/CoreEx.Template.csproj
@@ -0,0 +1,54 @@
+
+
+
+ netstandard2.1
+ Template
+ CoreEx.Template
+ CoreEx
+ CoreEx .NET Standard Extensions.
+ Avanade
+ CoreEx template solution for use with 'dotnet new'.
+ coreex dotnet template solution
+ false
+ true
+ false
+ content
+
+
+
+
+
+
+
+
+ true
+ contentFiles\any\any\Schema\
+ true
+
+
+ true
+ true
+
+
+ true
+ true
+
+
+ true
+ true
+
+
+ true
+ true
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tools/CoreEx.Template/content/.template.config/template.json b/tools/CoreEx.Template/content/.template.config/template.json
new file mode 100644
index 00000000..f5a89533
--- /dev/null
+++ b/tools/CoreEx.Template/content/.template.config/template.json
@@ -0,0 +1,75 @@
+{
+ "$schema": "http://json.schemastore.org/template",
+ "author": "CoreEx (https://github.com/avanade/coreex)",
+ "classifications": [ "CoreEx", "Common", "Library", "Web", "WebAPI", "Console", "Test", "NUnit", "Solution" ],
+ "identity": "CoreEx.Solution",
+ "groupIdentity": "CoreEx",
+ "name": "CoreEx (Core Building blocks for enterprise) solutions",
+ "shortName": "coreex",
+ "tags": {
+ "language": "C#",
+ "type": "project"
+ },
+ "defaultName": "CoreEx",
+ "description": "CoreEx ",
+ "sourceName": "XXCompany.AppNameXX", // Not actually used; template uses the below parameters exclusively.
+ "preferNameDirectory": true,
+ "symbols": {
+ "company": {
+ "type": "parameter",
+ "replaces": "Company",
+ "fileRename": "Company",
+ "isRequired": true,
+ "datatype": "text",
+ "description": "The company name 'Company' used to define the namespace etc; e.g. 'Company.AppName'."
+ },
+ "appname": {
+ "type": "parameter",
+ "replaces": "AppName",
+ "fileRename": "AppName",
+ "isRequired": true,
+ "datatype": "text",
+ "description": "The application (domain) name 'AppName' used to define the namespace etc; e.g. 'Company.AppName'."
+ },
+ "created_date": {
+ "type": "generated",
+ "generator": "now",
+ "parameters": {
+ "format": "yyyyMMdd"
+ },
+ "replaces": "20190101"
+ }
+ },
+ "sources": [
+ {
+ "modifiers": [
+ {
+ "rename": {
+ "_azuredevops": ".azuredevops",
+ "_gitignore": ".gitignore",
+ "_dockerignore": ".dockerignore",
+ "_devcontainer": ".devcontainer",
+ "_vscode": ".vscode"
+ }
+ }
+ ]
+ }
+ ],
+ "primaryOutputs": [
+ {
+ "path": "./Company.AppName.sln"
+ }
+ ],
+ "postActions": [{
+ "condition": "(!skipRestore)",
+ "description": "Restore NuGet packages required by this project.",
+ "manualInstructions": [{
+ "text": "Run 'dotnet restore'"
+ }],
+ "actionId": "210D431B-A78B-4D2F-B762-4ED3E3EA9025",
+ "continueOnError": true,
+ "args": {
+ "files": [ "Company.AppName.sln" ]
+ }
+ }]
+}
\ No newline at end of file
diff --git a/tools/CoreEx.Template/content/Company.AppName.Api/Api.http b/tools/CoreEx.Template/content/Company.AppName.Api/Api.http
new file mode 100644
index 00000000..0f3cc5a2
--- /dev/null
+++ b/tools/CoreEx.Template/content/Company.AppName.Api/Api.http
@@ -0,0 +1,87 @@
+@port = 7129
+@hostname = localhost
+@scheme = https
+@host = {{scheme}}://{{hostname}}:{{port}}
+
+
+### Health endpoint
+GET {{host}}/api/health
+
+# Swagger
+
+### Get openapi json
+GET {{host}}/api/openapi/1.0
+
+### Get swagger json
+GET {{host}}/api/swagger.json
+
+### Get swagger UI
+GET {{host}}/api/swagger/ui
+
+# Employee CRUD operations
+# todo: add payloads
+### Get all Employees
+GET {{host}}/api/employees
+x-correlation-id: 123-my-correlation-id-getall
+
+### Get Employee
+GET {{host}}/api/employees/3fa85f64-5717-4562-b3fc-2c963f66afa6
+x-correlation-id: 123-my-correlation-id-get
+
+### Create Employee
+POST {{host}}/api/employees
+x-correlation-id: 123-my-correlation-id-create
+Content-Type: application/json
+
+{
+ "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
+ "email": "alice@alice.com",
+ "firstName": "Alice",
+ "lastName": "Doe",
+ "gender": "F",
+ "birthday": "2000-09-28T15:36:55.730Z",
+ "startDate": "2022-09-01T15:36:55.730Z",
+ "phoneNo": "765-123-5687"
+}
+
+### Update Employee
+PUT {{host}}/api/employees/3fa85f64-5717-4562-b3fc-2c963f66afa6
+x-correlation-id: 123-my-correlation-id-update
+Content-Type: application/json
+If-Match: AAAAAAAACBg=
+
+{
+ "email": "alice@alice.com",
+ "firstName": "Alice",
+ "lastName": "Doe",
+ "gender": "F",
+ "birthday": "2000-09-28T15:36:55.730Z",
+ "startDate": "2022-09-01T15:36:55.730Z",
+ "phoneNo": "765-123-0000"
+}
+
+### Patch Employee
+PATCH {{host}}/api/employees/3fa85f64-5717-4562-b3fc-2c963f66afa6
+x-correlation-id: 123-my-correlation-id
+Content-Type: application/json
+If-Match: AAAAAAAACBg=
+
+{
+ "phoneNo": "765-123-0000"
+}
+
+### Delete Employee
+DELETE {{host}}/api/employees/3fa85f64-5717-4562-b3fc-2c963f66afa6
+x-correlation-id: 123-my-correlation-id
+
+
+### Employee Verification scenario with service bus
+POST {{host}}/api/employee/verify
+Content-Type: application/json
+x-correlation-id: 123-my-correlation-id-verify
+
+{
+ "name": "John",
+ "age": 27,
+ "gender": "male"
+}
\ No newline at end of file
diff --git a/tools/CoreEx.Template/content/Company.AppName.Api/Company.AppName.Api.csproj b/tools/CoreEx.Template/content/Company.AppName.Api/Company.AppName.Api.csproj
new file mode 100644
index 00000000..3fcc54d2
--- /dev/null
+++ b/tools/CoreEx.Template/content/Company.AppName.Api/Company.AppName.Api.csproj
@@ -0,0 +1,27 @@
+
+
+
+ net6.0
+ enable
+ enable
+
+
+
+ true
+ $(NoWarn);1591
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tools/CoreEx.Template/content/Company.AppName.Api/Controllers/EmployeeController.cs b/tools/CoreEx.Template/content/Company.AppName.Api/Controllers/EmployeeController.cs
new file mode 100644
index 00000000..3f640418
--- /dev/null
+++ b/tools/CoreEx.Template/content/Company.AppName.Api/Controllers/EmployeeController.cs
@@ -0,0 +1,89 @@
+namespace Company.AppName.Api.Controllers;
+
+[Route("api/employees")]
+[Produces(MediaTypeNames.Application.Json)]
+public class EmployeeController : ControllerBase
+{
+ private readonly WebApi _webApi;
+ private readonly IEmployeeService _service;
+
+ public EmployeeController(WebApi webApi, IEmployeeService service)
+ {
+ _webApi = webApi;
+ _service = service;
+ }
+
+ ///
+ /// Gets the specified .
+ ///
+ /// The identifier.
+ /// The selected where found.
+ [HttpGet("{id}", Name = "Get")]
+ [ProducesResponseType(typeof(Employee), (int)HttpStatusCode.OK)]
+ [ProducesResponseType((int)HttpStatusCode.NotFound)]
+ public Task GetAsync(Guid id)
+ => _webApi.GetAsync(Request, _ => _service.GetEmployeeAsync(id));
+
+ ///
+ /// Gets all .
+ ///
+ /// All .
+ [HttpGet("", Name = "GetAll")]
+ [ProducesResponseType(typeof(IEnumerable), (int)HttpStatusCode.OK)]
+ public Task GetAllAsync()
+ => _webApi.GetAsync(Request, p => _service.GetAllAsync(p.RequestOptions.Paging));
+
+ ///
+ /// Creates a new .
+ ///
+ /// The validator.
+ /// The created .
+ [HttpPost("", Name = "Create")]
+ [AcceptsBody(typeof(Employee))]
+ [ProducesResponseType(typeof(Employee), (int)HttpStatusCode.Created)]
+ public Task CreateAsync([FromServices] IValidator validator)
+ => _webApi.PostAsync(Request, p => _service.AddEmployeeAsync(p.Value!),
+ statusCode: HttpStatusCode.Created, validator: validator, locationUri: e => new Uri($"api/employees/{e.Id}", UriKind.RelativeOrAbsolute));
+
+ ///
+ /// Updates an existing .
+ ///
+ /// The identifier.
+ /// The validator.
+ /// The updated .
+ [HttpPut("{id}", Name = "Update")]
+ [AcceptsBody(typeof(Employee))]
+ [ProducesResponseType(typeof(Employee), (int)HttpStatusCode.OK)]
+ public Task UpdateAsync(Guid id, [FromServices] IValidator validator)
+ => _webApi.PutAsync(Request, p => _service.UpdateEmployeeAsync(p.Value!, id), validator: validator);
+
+ ///
+ /// Patches an existing .
+ ///
+ /// The identifier.
+ /// The validator.
+ /// The updated .
+ [HttpPatch("{id}", Name = "Patch")]
+ [AcceptsBody(typeof(Employee), HttpConsts.MergePatchMediaTypeName)]
+ [ProducesResponseType(typeof(Employee), (int)HttpStatusCode.OK)]
+ public Task PatchAsync(Guid id, [FromServices] IValidator validator)
+ => _webApi.PatchAsync(Request, get: _ => _service.GetEmployeeAsync(id), put: p => _service.UpdateEmployeeAsync(p.Value!, id), validator: validator);
+
+ ///
+ /// Deletes the specified .
+ ///
+ /// The Id.
+ [HttpDelete("{id}", Name = "Delete")]
+ [ProducesResponseType((int)HttpStatusCode.NoContent)]
+ public Task DeleteAsync(Guid id)
+ => _webApi.DeleteAsync(Request, _ => _service.DeleteEmployeeAsync(id));
+
+ ///
+ /// Performs verification in an asynchronous process.
+ ///
+ [HttpPost("{id}/verify", Name = "Verify")]
+ [ProducesResponseType((int)HttpStatusCode.Accepted)]
+ [ProducesResponseType((int)HttpStatusCode.NotFound)]
+ public Task VerifyAsync(Guid id)
+ => _webApi.PostAsync(Request, apiParam => _service.VerifyEmployeeAsync(id), HttpStatusCode.Accepted);
+}
\ No newline at end of file
diff --git a/tools/CoreEx.Template/content/Company.AppName.Api/Controllers/HealthController.cs b/tools/CoreEx.Template/content/Company.AppName.Api/Controllers/HealthController.cs
new file mode 100644
index 00000000..79039f35
--- /dev/null
+++ b/tools/CoreEx.Template/content/Company.AppName.Api/Controllers/HealthController.cs
@@ -0,0 +1,22 @@
+namespace Company.AppName.Api.Controllers;
+
+///
+/// Health Controller
+///
+[ApiController]
+[Route("api/[controller]")]
+public class HealthController : ControllerBase
+{
+ private readonly HealthService _health;
+
+ public HealthController(HealthService health)
+ {
+ _health = health;
+ }
+
+ ///
+ /// Health Endpoint
+ ///
+ [HttpGet()]
+ public async Task Index() => await _health.RunAsync().ConfigureAwait(false);
+}
diff --git a/tools/CoreEx.Template/content/Company.AppName.Api/Controllers/ReferenceDataController.cs b/tools/CoreEx.Template/content/Company.AppName.Api/Controllers/ReferenceDataController.cs
new file mode 100644
index 00000000..886eb72c
--- /dev/null
+++ b/tools/CoreEx.Template/content/Company.AppName.Api/Controllers/ReferenceDataController.cs
@@ -0,0 +1,41 @@
+namespace Company.AppName.Api.Controllers;
+
+[Route("api/ref")]
+[Produces(MediaTypeNames.Application.Json)]
+public class ReferenceDataController : ControllerBase
+{
+ private readonly ReferenceDataContentWebApi _webApi;
+ private readonly ReferenceDataOrchestrator _orchestrator;
+
+ public ReferenceDataController(ReferenceDataContentWebApi webApi, ReferenceDataOrchestrator orchestrator)
+ {
+ _webApi = webApi;
+ _orchestrator = orchestrator;
+ }
+
+ ///
+ /// Gets all of the reference data items that match the specified criteria.
+ ///
+ /// The reference data code list.
+ /// The reference data text (including wildcards).
+ /// A .
+ [HttpGet("usstates")]
+ [ProducesResponseType(typeof(IEnumerable), (int)HttpStatusCode.OK)]
+ public Task USStateGetAll([FromQuery] IEnumerable? codes = default, string? text = default) =>
+ _webApi.GetAsync(Request, x => _orchestrator.GetWithFilterAsync(codes, text, x.RequestOptions.IncludeInactive));
+
+ ///
+ /// Gets all of the reference data items that match the specified criteria.
+ ///
+ /// The reference data code list.
+ /// The reference data text (including wildcards).
+ /// A .
+ [HttpGet("genders")]
+ [ProducesResponseType(typeof(ReferenceDataMultiCollection), (int)HttpStatusCode.OK)]
+ public Task GenderGetAll([FromQuery] IEnumerable? codes = default, string? text = default) =>
+ _webApi.GetAsync(Request, x => _orchestrator.GetWithFilterAsync(codes, text, x.RequestOptions.IncludeInactive));
+
+ [HttpGet()]
+ [ProducesResponseType(typeof(ReferenceDataMultiCollection), (int)HttpStatusCode.OK)]
+ public Task GetNamed() => _webApi.GetAsync(Request, p => _orchestrator.GetNamedAsync(p.RequestOptions));
+}
\ No newline at end of file
diff --git a/tools/CoreEx.Template/content/Company.AppName.Api/Controllers/SwaggerController.cs b/tools/CoreEx.Template/content/Company.AppName.Api/Controllers/SwaggerController.cs
new file mode 100644
index 00000000..a7e1b3b8
--- /dev/null
+++ b/tools/CoreEx.Template/content/Company.AppName.Api/Controllers/SwaggerController.cs
@@ -0,0 +1,19 @@
+namespace Company.AppName.Api.Controllers;
+
+///
+/// Swagger/OpenAPI documentation for the API.
+///
+[ApiController]
+[Route("[controller]")]
+public class SwaggerController : ControllerBase
+{
+ ///
+ /// Swagger/OpenAPI documentation for the API.
+ ///
+ [HttpGet()]
+ [Route("/")]
+ public IActionResult Index()
+ {
+ return new RedirectResult("~/swagger");
+ }
+}
diff --git a/tools/CoreEx.Template/content/Company.AppName.Api/Dockerfile b/tools/CoreEx.Template/content/Company.AppName.Api/Dockerfile
new file mode 100644
index 00000000..edd03c91
--- /dev/null
+++ b/tools/CoreEx.Template/content/Company.AppName.Api/Dockerfile
@@ -0,0 +1,35 @@
+FROM mcr.microsoft.com/dotnet/aspnet:6.0 AS base
+WORKDIR /app
+EXPOSE 80
+
+FROM mcr.microsoft.com/dotnet/sdk:6.0 AS build
+WORKDIR /src
+
+# It's important to keep lines from here down to "COPY . ." identical in all Dockerfiles
+# to take advantage of Docker's build cache, to speed up local container builds
+COPY "Company.AppName.sln" "Company.AppName.sln"
+
+COPY "Company.AppName.Api/Company.AppName.Api.csproj" "Company.AppName.Api/Company.AppName.Api.csproj"
+COPY "Company.AppName.Business/Company.AppName.Business.csproj" "Company.AppName.Business/Company.AppName.Business.csproj"
+COPY "Company.AppName.Database/Company.AppName.Database.csproj" "Company.AppName.Database/Company.AppName.Database.csproj"
+COPY "Company.AppName.Functions/Company.AppName.Functions.csproj" "Company.AppName.Functions/Company.AppName.Functions.csproj"
+COPY "Company.AppName.UnitTest/Company.AppName.UnitTest.csproj" "Company.AppName.UnitTest/Company.AppName.UnitTest.csproj"
+COPY "Company.AppName.Infra/Company.AppName.Infra.csproj" "Company.AppName.Infra/Company.AppName.Infra.csproj"
+COPY "Company.AppName.Infra.Tests/Company.AppName.Infra.Tests.csproj" "Company.AppName.Infra.Tests/Company.AppName.Infra.Tests.csproj"
+
+RUN dotnet restore "Company.AppName.sln"
+
+COPY . .
+WORKDIR /src/Company.AppName.Api
+RUN dotnet publish --no-restore -c Release -o /app
+
+FROM build as unittest
+WORKDIR /src/Company.AppName.Test
+# can run tests here on buils
+
+FROM build AS publish
+
+FROM base AS final
+WORKDIR /app
+COPY --from=publish /app .
+ENTRYPOINT ["dotnet", "Company.AppName.Api.dll"]
diff --git a/tools/CoreEx.Template/content/Company.AppName.Api/ImplicitUsings.cs b/tools/CoreEx.Template/content/Company.AppName.Api/ImplicitUsings.cs
new file mode 100644
index 00000000..2fb4360f
--- /dev/null
+++ b/tools/CoreEx.Template/content/Company.AppName.Api/ImplicitUsings.cs
@@ -0,0 +1,23 @@
+global using CoreEx;
+global using CoreEx.Configuration;
+global using CoreEx.Entities;
+global using CoreEx.Events;
+global using CoreEx.HealthChecks;
+global using CoreEx.Http;
+global using CoreEx.Json;
+global using CoreEx.RefData;
+global using CoreEx.RefData.Models;
+global using CoreEx.Validation;
+global using CoreEx.WebApis;
+global using Microsoft.AspNetCore.Mvc;
+global using Microsoft.Extensions.Configuration;
+global using Microsoft.Extensions.Logging;
+global using Company.AppName.Business;
+global using Company.AppName.Business.Data;
+global using Company.AppName.Business.External;
+global using Company.AppName.Business.External.Contracts;
+global using Company.AppName.Business.Models;
+global using Company.AppName.Business.Services;
+global using System.Net;
+global using System.Net.Mime;
+global using System.Reflection;
\ No newline at end of file
diff --git a/tools/CoreEx.Template/content/Company.AppName.Api/Program.cs b/tools/CoreEx.Template/content/Company.AppName.Api/Program.cs
new file mode 100644
index 00000000..22ca10e3
--- /dev/null
+++ b/tools/CoreEx.Template/content/Company.AppName.Api/Program.cs
@@ -0,0 +1 @@
+Host.CreateDefaultBuilder().ConfigureWebHostDefaults(webBuilder => webBuilder.UseStartup()).Build().Run();
diff --git a/tools/CoreEx.Template/content/Company.AppName.Api/Properties/launchSettings.json b/tools/CoreEx.Template/content/Company.AppName.Api/Properties/launchSettings.json
new file mode 100644
index 00000000..04d88d6f
--- /dev/null
+++ b/tools/CoreEx.Template/content/Company.AppName.Api/Properties/launchSettings.json
@@ -0,0 +1,31 @@
+{
+ "$schema": "https://json.schemastore.org/launchsettings.json",
+ "iisSettings": {
+ "windowsAuthentication": false,
+ "anonymousAuthentication": true,
+ "iisExpress": {
+ "applicationUrl": "http://localhost:46085",
+ "sslPort": 44328
+ }
+ },
+ "profiles": {
+ "Company.AppName.Api": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "launchUrl": "swagger",
+ "applicationUrl": "https://localhost:7129;http://localhost:5272",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "IIS Express": {
+ "commandName": "IISExpress",
+ "launchBrowser": true,
+ "launchUrl": "swagger",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/tools/CoreEx.Template/content/Company.AppName.Api/Startup.cs b/tools/CoreEx.Template/content/Company.AppName.Api/Startup.cs
new file mode 100644
index 00000000..ea076dee
--- /dev/null
+++ b/tools/CoreEx.Template/content/Company.AppName.Api/Startup.cs
@@ -0,0 +1,83 @@
+using CoreEx.Azure.HealthChecks;
+using CoreEx.Database;
+using CoreEx.DataBase.HealthChecks;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Diagnostics.HealthChecks;
+
+namespace Company.AppName.Api;
+
+public class Startup
+{
+ // todo: add azure app configuration (conditional?)
+
+ ///
+ /// The configure services method called by the runtime; use this method to add services to the container.
+ ///
+ /// The .
+ public void ConfigureServices(IServiceCollection services)
+ {
+ // Register the core services.
+ services
+ .AddSettings()
+ .AddReferenceDataOrchestrator(sp => new ReferenceDataOrchestrator(sp).Register())
+ .AddExecutionContext()
+ .AddJsonSerializer()
+ .AddEventDataSerializer()
+ .AddEventDataFormatter()
+ .AddEventPublisher()
+ .AddAzureServiceBusSender()
+ .AddAzureServiceBusPurger()
+ .AddAzureServiceBusClient(connectionName: nameof(AppNameSettings.ServiceBusConnection))
+ .AddJsonMergePatch()
+ .AddWebApi(c => c.UnhandledExceptionAsync = (ex, _) => Task.FromResult(ex is DbUpdateConcurrencyException efex ? new ConcurrencyException().ToResult() : null))
+ .AddReferenceDataContentWebApi()
+ .AddRequestCache();
+
+ // Register the business services.
+ services
+ .AddScoped()
+ .AddScoped()
+ .AddFluentValidators();
+
+ // Register the database and EF services, including required AutoMapper.
+ services.AddDatabase(sp => new AppNameDb(sp.GetRequiredService()))
+ .AddDbContext((sp, o) => o.UseSqlServer(sp.GetRequiredService().GetConnection()))
+ .AddScoped()
+ .AddAutoMapper(typeof(AppNameEfDb).Assembly)
+ .AddAutoMapperWrapper();
+
+ // Register the health checks.
+ services
+ .AddScoped()
+ .AddHealthChecks()
+ .AddTypeActivatedCheck("Health check for service bus verification queue", HealthStatus.Unhealthy, nameof(AppNameSettings.ServiceBusConnection), nameof(AppNameSettings.VerificationQueueName))
+ .AddTypeActivatedCheck("SQL Server", HealthStatus.Unhealthy, tags: default!, timeout: TimeSpan.FromSeconds(15), nameof(AppNameSettings.ConnectionStrings__Database));
+
+
+ services.AddControllers();
+
+ // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
+ services.AddEndpointsApiExplorer();
+ services.AddSwaggerGen(options =>
+ {
+ var xmlFilename = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml";
+ options.IncludeXmlComments(Path.Combine(AppContext.BaseDirectory, xmlFilename));
+ options.OperationFilter(); // Needed to support AcceptsBodyAttribue where body parameter not explicitly defined.
+ });
+ }
+
+ ///
+ /// The configure method called by the runtime; use this method to configure the HTTP request pipeline.
+ ///
+ /// The .
+ public void Configure(IApplicationBuilder app)
+ => app
+ .UseWebApiExceptionHandler()
+ .UseSwagger()
+ .UseSwaggerUI()
+ .UseHttpsRedirection()
+ .UseRouting()
+ .UseAuthorization()
+ .UseExecutionContext()
+ .UseEndpoints(endpoints => endpoints.MapControllers());
+}
\ No newline at end of file
diff --git a/tools/CoreEx.Template/content/Company.AppName.Api/appsettings.Development.json b/tools/CoreEx.Template/content/Company.AppName.Api/appsettings.Development.json
new file mode 100644
index 00000000..de23200b
--- /dev/null
+++ b/tools/CoreEx.Template/content/Company.AppName.Api/appsettings.Development.json
@@ -0,0 +1,14 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "AllowedHosts": "*",
+ "VerificationQueueName": "pendingVerifications",
+ "ServiceBusConnection": "coreex.servicebus.windows.net",
+ "ConnectionStrings": {
+ "Database": "Data Source=.;Initial Catalog=Company.AppNameDb;Integrated Security=True;TrustServerCertificate=true"
+ }
+}
\ No newline at end of file
diff --git a/tools/CoreEx.Template/content/Company.AppName.Api/appsettings.json b/tools/CoreEx.Template/content/Company.AppName.Api/appsettings.json
new file mode 100644
index 00000000..5e9fd7fb
--- /dev/null
+++ b/tools/CoreEx.Template/content/Company.AppName.Api/appsettings.json
@@ -0,0 +1,8 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ }
+}
\ No newline at end of file
diff --git a/tools/CoreEx.Template/content/Company.AppName.Business/AppNameSettings.cs b/tools/CoreEx.Template/content/Company.AppName.Business/AppNameSettings.cs
new file mode 100644
index 00000000..f58a132a
--- /dev/null
+++ b/tools/CoreEx.Template/content/Company.AppName.Business/AppNameSettings.cs
@@ -0,0 +1,44 @@
+namespace Company.AppName.Business;
+
+public class AppNameSettings : SettingsBase
+{
+ ///
+ /// Gets the setting prefixes in order of precedence.
+ ///
+ public static string[] Prefixes { get; } = { "AppName/", "Common/" };
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The .
+ public AppNameSettings(IConfiguration configuration) : base(configuration, Prefixes) { }
+
+ public string AgifyApiEndpointUri => GetValue();
+
+ public string NationalizeApiClientApiEndpointUri => GetValue();
+
+ public string GenderizeApiClientApiEndpointUri => GetValue();
+
+ public string VerificationQueueName => GetValue();
+
+ public string VerificationResultsQueueName => GetValue();
+
+ ///
+ /// The Azure Service Bus connection string used for Publishing in .
+ ///
+ /// It defaults to managed identity connection string used by triggers 'ServiceBusConnection__fullyQualifiedNamespace'
+ public string ServiceBusConnection => GetValue(defaultValue: ServiceBusConnection__fullyQualifiedNamespace);
+
+ ///
+ /// The Azure Service Bus connection string used by Triggers using managed identity.
+ ///
+ /// Caution this key is used implicitly by function triggers when 'ServiceBusConnection' is not set.
+ /// Underscores in environment variables are replaced by semicolon ':' in configuration object, hence lookup also replaces '__' with ':'
+ public string ServiceBusConnection__fullyQualifiedNamespace => GetValue();
+
+ ///
+ /// SQL Server connection string used by the app (depending on the value it may use managed identity or username/password)
+ ///
+ /// Underscores in environment variables are replaced by semicolon ':' in configuration object, hence lookup also replaces '__' with ':'
+ public string ConnectionStrings__Database => GetValue();
+}
\ No newline at end of file
diff --git a/tools/CoreEx.Template/content/Company.AppName.Business/Company.AppName.Business.csproj b/tools/CoreEx.Template/content/Company.AppName.Business/Company.AppName.Business.csproj
new file mode 100644
index 00000000..5d982917
--- /dev/null
+++ b/tools/CoreEx.Template/content/Company.AppName.Business/Company.AppName.Business.csproj
@@ -0,0 +1,22 @@
+
+
+
+ net6.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tools/CoreEx.Template/content/Company.AppName.Business/Data/AppNameDb.cs b/tools/CoreEx.Template/content/Company.AppName.Business/Data/AppNameDb.cs
new file mode 100644
index 00000000..ddf61c5f
--- /dev/null
+++ b/tools/CoreEx.Template/content/Company.AppName.Business/Data/AppNameDb.cs
@@ -0,0 +1,11 @@
+using CoreEx.Database;
+using CoreEx.Database.SqlServer;
+using Microsoft.Data.SqlClient;
+
+namespace Company.AppName.Business.Data
+{
+ public class AppNameDb : SqlServerDatabase
+ {
+ public AppNameDb(SettingsBase settings) : base(() => new SqlConnection(settings.GetRequiredValue("ConnectionStrings:Database"))) { }
+ }
+}
\ No newline at end of file
diff --git a/tools/CoreEx.Template/content/Company.AppName.Business/Data/AppNameDbContext.cs b/tools/CoreEx.Template/content/Company.AppName.Business/Data/AppNameDbContext.cs
new file mode 100644
index 00000000..a1e5f13e
--- /dev/null
+++ b/tools/CoreEx.Template/content/Company.AppName.Business/Data/AppNameDbContext.cs
@@ -0,0 +1,28 @@
+using CoreEx.Database;
+using CoreEx.EntityFrameworkCore;
+
+namespace Company.AppName.Business.Data;
+
+public class AppNameDbContext : DbContext, IEfDbContext
+{
+ public IDatabase BaseDatabase { get; }
+
+ public DbSet USStates { get; set; }
+
+ public DbSet Genders { get; set; }
+
+ public DbSet Employees { get; set; }
+
+#pragma warning disable CS8618 // Non-nullable property - properties set by Entity Framework Core
+ public AppNameDbContext(DbContextOptions options, IDatabase database) : base(options) => BaseDatabase = database ?? throw new ArgumentNullException(nameof(database));
+#pragma warning restore CS8618 // Non-nullable property
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ modelBuilder
+ .ApplyConfiguration(new UsStateConfiguration())
+ .ApplyConfiguration(new EmployeeConfiguration());
+
+ base.OnModelCreating(modelBuilder);
+ }
+}
\ No newline at end of file
diff --git a/tools/CoreEx.Template/content/Company.AppName.Business/Data/AppNameEfDb.cs b/tools/CoreEx.Template/content/Company.AppName.Business/Data/AppNameEfDb.cs
new file mode 100644
index 00000000..79c1628c
--- /dev/null
+++ b/tools/CoreEx.Template/content/Company.AppName.Business/Data/AppNameEfDb.cs
@@ -0,0 +1,33 @@
+using CoreEx.Mapping;
+
+namespace Company.AppName.Business.Data
+{
+ ///
+ /// Enables the Company.AppName database using Entity Framework.
+ ///
+ public interface IAppNameEfDb : IEfDb
+ {
+ ///
+ /// Gets the entity.
+ ///
+ EfDbEntity Employees { get; }
+ }
+
+ ///
+ /// Represents the Company.AppName database using Entity Framework.
+ ///
+ public class AppNameEfDb : EfDb, IAppNameEfDb
+ {
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The entity framework database context.
+ /// The .
+ public AppNameEfDb(AppNameDbContext dbContext, IMapper mapper) : base(dbContext, mapper) { }
+
+ ///
+ /// Gets the encapsulated entity.
+ ///
+ public EfDbEntity Employees => new(this);
+ }
+}
\ No newline at end of file
diff --git a/tools/CoreEx.Template/content/Company.AppName.Business/Data/EmployeeConfiguration.cs b/tools/CoreEx.Template/content/Company.AppName.Business/Data/EmployeeConfiguration.cs
new file mode 100644
index 00000000..157a03a7
--- /dev/null
+++ b/tools/CoreEx.Template/content/Company.AppName.Business/Data/EmployeeConfiguration.cs
@@ -0,0 +1,21 @@
+namespace Company.AppName.Business.Data;
+
+public class EmployeeConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("Employee", "AppName");
+ builder.Property(p => p.Id).HasColumnName("EmployeeId").HasColumnType("UNIQUEIDENTIFIER");
+ builder.Property(p => p.Email).HasColumnType("NVARCHAR(250)");
+ builder.Property(p => p.FirstName).HasColumnType("NVARCHAR(100)");
+ builder.Property(p => p.LastName).HasColumnType("NVARCHAR(100)");
+ builder.Property(p => p.Gender).HasColumnName("GenderCode").HasColumnType("NVARCHAR(50)").HasConversion(v => v!.Code, v => (Gender?)v);
+ builder.Property(p => p.Birthday).HasColumnType("DATE");
+ builder.Property(p => p.StartDate).HasColumnType("DATE");
+ builder.Property(p => p.TerminationDate).HasColumnType("DATE");
+ builder.Property(p => p.TerminationReasonCode).HasColumnType("NVARCHAR(50)");
+ builder.Property(p => p.PhoneNo).HasColumnType("NVARCHAR(50)");
+ builder.Property(p => p.ETag).HasColumnName("RowVersion").IsRowVersion().HasConversion(s => s == null ? Array.Empty() : Convert.FromBase64String(s), d => Convert.ToBase64String(d));
+ builder.HasKey("Id");
+ }
+}
\ No newline at end of file
diff --git a/tools/CoreEx.Template/content/Company.AppName.Business/Data/UsStateConfiguration.cs b/tools/CoreEx.Template/content/Company.AppName.Business/Data/UsStateConfiguration.cs
new file mode 100644
index 00000000..813190d9
--- /dev/null
+++ b/tools/CoreEx.Template/content/Company.AppName.Business/Data/UsStateConfiguration.cs
@@ -0,0 +1,23 @@
+namespace Company.AppName.Business.Data;
+
+public class UsStateConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder entity)
+ {
+ entity.ToTable("USState", "AppName");
+ entity.HasKey("Id");
+ entity.Property(p => p.Id).HasColumnName("USStateId").HasColumnType("UNIQUEIDENTIFIER");
+ entity.Property(p => p.Code).HasColumnType("NVARCHAR(50)");
+ entity.Property(p => p.Text).HasColumnType("NVARCHAR(250)");
+ entity.Property(p => p.IsActive).HasColumnType("BIT");
+ entity.Property(p => p.SortOrder).HasColumnType("INT");
+ entity.Property(p => p.ETag).HasColumnName("RowVersion").IsRowVersion().HasConversion(s => s == null ? Array.Empty() : Convert.FromBase64String(s), d => Convert.ToBase64String(d));
+ entity.Ignore(p => p.EndDate);
+ entity.Ignore(p => p.StartDate);
+ entity.Ignore(p => p.Description);
+ entity.Ignore(p => p.IsReadOnly);
+ entity.Ignore(p => p.IsValid);
+ entity.Ignore(p => p.IsChanged);
+ entity.Ignore(p => p.IsInitial);
+ }
+}
\ No newline at end of file
diff --git a/tools/CoreEx.Template/content/Company.AppName.Business/External/AgifyServiceClient.cs b/tools/CoreEx.Template/content/Company.AppName.Business/External/AgifyServiceClient.cs
new file mode 100644
index 00000000..e5d488b1
--- /dev/null
+++ b/tools/CoreEx.Template/content/Company.AppName.Business/External/AgifyServiceClient.cs
@@ -0,0 +1,32 @@
+namespace Company.AppName.Business.External;
+
+///
+/// Http client for https://agify.io/
+///
+public class AgifyApiClient : TypedHttpClientCore
+{
+ public AgifyApiClient(HttpClient client, IJsonSerializer jsonSerializer, CoreEx.ExecutionContext executionContext, AppNameSettings settings, ILogger> logger)
+ : base(client, jsonSerializer, executionContext, settings, logger)
+ {
+ if (!Uri.IsWellFormedUriString(settings.AgifyApiEndpointUri, UriKind.Absolute))
+ throw new InvalidOperationException(@$"The Api endpoint URI is not valid: {settings.AgifyApiEndpointUri}. Provide valid Api endpoint URI in the configuration '{nameof(settings.AgifyApiEndpointUri)}'.
+ If Api Client is not needed - remove all references to {nameof(AgifyApiClient)}.");
+
+ client.BaseAddress = new Uri(settings.AgifyApiEndpointUri);
+ }
+
+ public override Task HealthCheckAsync(CancellationToken cancellationToken)
+ {
+ return base.HeadAsync(string.Empty, null, new HttpArg[] { new HttpArg("name", "health") }, cancellationToken);
+ }
+
+ public async Task GetAgeAsync(string name)
+ {
+ var response = await
+ WithRetry(1, 5)
+ .ThrowTransientException()
+ .GetAsync(string.Empty, null, HttpArgs.Create(new HttpArg("name", name)));
+
+ return response.Value;
+ }
+}
\ No newline at end of file
diff --git a/tools/CoreEx.Template/content/Company.AppName.Business/External/Contracts/AgifyResponse.cs b/tools/CoreEx.Template/content/Company.AppName.Business/External/Contracts/AgifyResponse.cs
new file mode 100644
index 00000000..ca4c4890
--- /dev/null
+++ b/tools/CoreEx.Template/content/Company.AppName.Business/External/Contracts/AgifyResponse.cs
@@ -0,0 +1,7 @@
+namespace Company.AppName.Business.External.Contracts;
+
+public class AgifyResponse
+{
+ public string? Name { get; set; }
+ public int Age { get; set; }
+}
\ No newline at end of file
diff --git a/tools/CoreEx.Template/content/Company.AppName.Business/External/Contracts/EmployeeVerificationRequest.cs b/tools/CoreEx.Template/content/Company.AppName.Business/External/Contracts/EmployeeVerificationRequest.cs
new file mode 100644
index 00000000..0c252939
--- /dev/null
+++ b/tools/CoreEx.Template/content/Company.AppName.Business/External/Contracts/EmployeeVerificationRequest.cs
@@ -0,0 +1,8 @@
+namespace Company.AppName.Business.External.Contracts;
+
+public class EmployeeVerificationRequest
+{
+ public string? Name { get; set; }
+ public int Age { get; set; }
+ public string? Gender { get; set; }
+}
\ No newline at end of file
diff --git a/tools/CoreEx.Template/content/Company.AppName.Business/External/Contracts/EmployeeVerificationResponse.cs b/tools/CoreEx.Template/content/Company.AppName.Business/External/Contracts/EmployeeVerificationResponse.cs
new file mode 100644
index 00000000..0599d381
--- /dev/null
+++ b/tools/CoreEx.Template/content/Company.AppName.Business/External/Contracts/EmployeeVerificationResponse.cs
@@ -0,0 +1,18 @@
+namespace Company.AppName.Business.External.Contracts;
+
+public class EmployeeVerificationResponse
+{
+ public EmployeeVerificationResponse(EmployeeVerificationRequest request) => Request = request;
+
+ public int Age { get; set; }
+
+ public string? Gender { get; set; }
+
+ public float GenderProbability { get; set; }
+
+ public List Country { get; set; } = new List();
+
+ public List VerificationMessages { get; set; } = new List();
+
+ public EmployeeVerificationRequest Request { get; }
+}
\ No newline at end of file
diff --git a/tools/CoreEx.Template/content/Company.AppName.Business/External/Contracts/GenderizeResponse.cs b/tools/CoreEx.Template/content/Company.AppName.Business/External/Contracts/GenderizeResponse.cs
new file mode 100644
index 00000000..1e18db80
--- /dev/null
+++ b/tools/CoreEx.Template/content/Company.AppName.Business/External/Contracts/GenderizeResponse.cs
@@ -0,0 +1,8 @@
+namespace Company.AppName.Business.External.Contracts;
+
+public class GenderizeResponse
+{
+ public string? Name { get; set; }
+ public string? Gender { get; set; }
+ public float Probability { get; set; }
+}
\ No newline at end of file
diff --git a/tools/CoreEx.Template/content/Company.AppName.Business/External/Contracts/NationalizeResponse.cs b/tools/CoreEx.Template/content/Company.AppName.Business/External/Contracts/NationalizeResponse.cs
new file mode 100644
index 00000000..0784e4fa
--- /dev/null
+++ b/tools/CoreEx.Template/content/Company.AppName.Business/External/Contracts/NationalizeResponse.cs
@@ -0,0 +1,14 @@
+namespace Company.AppName.Business.External.Contracts;
+
+public class NationalizeResponse
+{
+ public string? Name { get; set; }
+
+ public List? Country { get; set; }
+
+ public class CountryResponse
+ {
+ public string? Country_Id { get; set; }
+ public float Probability { get; set; }
+ }
+}
\ No newline at end of file
diff --git a/tools/CoreEx.Template/content/Company.AppName.Business/External/GenderizeApiClient.cs b/tools/CoreEx.Template/content/Company.AppName.Business/External/GenderizeApiClient.cs
new file mode 100644
index 00000000..a601ebd0
--- /dev/null
+++ b/tools/CoreEx.Template/content/Company.AppName.Business/External/GenderizeApiClient.cs
@@ -0,0 +1,32 @@
+namespace Company.AppName.Business.External;
+
+///
+/// Http client for https://genderize.io/
+///
+public class GenderizeApiClient : TypedHttpClientCore
+{
+ public GenderizeApiClient(HttpClient client, IJsonSerializer jsonSerializer, CoreEx.ExecutionContext executionContext, AppNameSettings settings, ILogger> logger)
+ : base(client, jsonSerializer, executionContext, settings, logger)
+ {
+ if (!Uri.IsWellFormedUriString(settings.GenderizeApiClientApiEndpointUri, UriKind.Absolute))
+ throw new InvalidOperationException(@$"The Api endpoint URI is not valid: {settings.GenderizeApiClientApiEndpointUri}. Provide valid Api endpoint URI in the configuration '{nameof(settings.GenderizeApiClientApiEndpointUri)}'.
+ If Api Client is not needed - remove all references to {nameof(GenderizeApiClient)}.");
+
+ client.BaseAddress = new Uri(settings.GenderizeApiClientApiEndpointUri);
+ }
+
+ public override Task HealthCheckAsync(CancellationToken cancellationToken = default)
+ {
+ return base.HeadAsync(string.Empty, null, HttpArgs.Create(new HttpArg("name", "health")), cancellationToken);
+ }
+
+ public async Task GetGenderAsync(string name)
+ {
+ var response = await
+ WithRetry(1, 5)
+ .ThrowTransientException()
+ .GetAsync(string.Empty, null, HttpArgs.Create(new HttpArg("name", name)));
+
+ return response.Value;
+ }
+}
\ No newline at end of file
diff --git a/tools/CoreEx.Template/content/Company.AppName.Business/External/NationalizeApiClient.cs b/tools/CoreEx.Template/content/Company.AppName.Business/External/NationalizeApiClient.cs
new file mode 100644
index 00000000..9ebfa3c2
--- /dev/null
+++ b/tools/CoreEx.Template/content/Company.AppName.Business/External/NationalizeApiClient.cs
@@ -0,0 +1,32 @@
+namespace Company.AppName.Business.External;
+
+///
+/// Http client for https://nationalize.io/
+///
+public class NationalizeApiClient : TypedHttpClientCore
+{
+ public NationalizeApiClient(HttpClient client, IJsonSerializer jsonSerializer, CoreEx.ExecutionContext executionContext, AppNameSettings settings, ILogger> logger)
+ : base(client, jsonSerializer, executionContext, settings, logger)
+ {
+ if (!Uri.IsWellFormedUriString(settings.NationalizeApiClientApiEndpointUri, UriKind.Absolute))
+ throw new InvalidOperationException(@$"The Api endpoint URI is not valid: {settings.NationalizeApiClientApiEndpointUri}. Provide valid Api endpoint URI in the configuration '{nameof(settings.NationalizeApiClientApiEndpointUri)}'.
+ If Api Client is not needed - remove all references to {nameof(NationalizeApiClient)}.");
+
+ client.BaseAddress = new Uri(settings.NationalizeApiClientApiEndpointUri);
+ }
+
+ public override Task HealthCheckAsync(CancellationToken cancellationToken = default)
+ {
+ return base.HeadAsync(string.Empty, null, HttpArgs.Create(new HttpArg("name", "health")), cancellationToken);
+ }
+
+ public async Task GetNationalityAsync(string name)
+ {
+ var response = await
+ WithRetry(1, 5)
+ .ThrowTransientException()
+ .GetAsync(string.Empty, null, HttpArgs.Create(new HttpArg("name", name)));
+
+ return response.Value;
+ }
+}
\ No newline at end of file
diff --git a/tools/CoreEx.Template/content/Company.AppName.Business/ImplicitUsings.cs b/tools/CoreEx.Template/content/Company.AppName.Business/ImplicitUsings.cs
new file mode 100644
index 00000000..0c4f5c95
--- /dev/null
+++ b/tools/CoreEx.Template/content/Company.AppName.Business/ImplicitUsings.cs
@@ -0,0 +1,16 @@
+global using CoreEx;
+global using CoreEx.Configuration;
+global using CoreEx.Entities;
+global using CoreEx.EntityFrameworkCore;
+global using CoreEx.Events;
+global using CoreEx.Http;
+global using CoreEx.Json;
+global using CoreEx.RefData;
+global using Microsoft.EntityFrameworkCore;
+global using Microsoft.EntityFrameworkCore.Metadata.Builders;
+global using Microsoft.Extensions.Configuration;
+global using Microsoft.Extensions.Logging;
+global using Company.AppName.Business.Data;
+global using Company.AppName.Business.External;
+global using Company.AppName.Business.External.Contracts;
+global using Company.AppName.Business.Models;
\ No newline at end of file
diff --git a/tools/CoreEx.Template/content/Company.AppName.Business/Models/Employee.cs b/tools/CoreEx.Template/content/Company.AppName.Business/Models/Employee.cs
new file mode 100644
index 00000000..064d1040
--- /dev/null
+++ b/tools/CoreEx.Template/content/Company.AppName.Business/Models/Employee.cs
@@ -0,0 +1,69 @@
+using System.Diagnostics;
+
+namespace Company.AppName.Business.Models;
+
+///
+/// Represents the Entity Framework (EF) model for database object 'AppName.Employee'.
+///
+public class Employee : IIdentifier, IETag
+{
+ ///
+ /// Gets or sets the 'EmployeeId' column value.
+ ///
+ public Guid Id { get; set; }
+
+ ///
+ /// Gets or sets the 'Email' column value.
+ ///
+ public string? Email { get; set; }
+
+ ///
+ /// Gets or sets the 'FirstName' column value.
+ ///
+ public string? FirstName { get; set; }
+
+ ///
+ /// Gets or sets the 'LastName' column value.
+ ///
+ public string? LastName { get; set; }
+
+ ///
+ /// Gets or sets the 'GenderCode' column value.
+ ///
+ [DebuggerBrowsable(DebuggerBrowsableState.Never)]
+ public Gender? Gender { get; set; }
+
+ ///
+ /// Gets or sets the 'Birthday' column value.
+ ///
+ public DateTime? Birthday { get; set; }
+
+ ///
+ /// Gets or sets the 'StartDate' column value.
+ ///
+ public DateTime? StartDate { get; set; }
+
+ ///
+ /// Gets or sets the 'TerminationDate' column value.
+ ///
+ public DateTime? TerminationDate { get; set; }
+
+ ///
+ /// Gets or sets the 'TerminationReasonCode' column value.
+ ///
+ public string? TerminationReasonCode { get; set; }
+
+ ///
+ /// Gets or sets the 'PhoneNo' column value.
+ ///
+ public string? PhoneNo { get; set; }
+
+ ///
+ /// Gets or sets the 'RowVersion' column value.
+ ///
+ public string? ETag { get; set; }
+}
+
+public class EmployeeCollection : List { }
+
+public class EmployeeCollectionResult : CollectionResult { }
\ No newline at end of file
diff --git a/tools/CoreEx.Template/content/Company.AppName.Business/Models/Gender.cs b/tools/CoreEx.Template/content/Company.AppName.Business/Models/Gender.cs
new file mode 100644
index 00000000..19a87987
--- /dev/null
+++ b/tools/CoreEx.Template/content/Company.AppName.Business/Models/Gender.cs
@@ -0,0 +1,10 @@
+using CoreEx.RefData;
+
+namespace Company.AppName.Business.Models;
+
+public class Gender : ReferenceDataBase
+{
+ public static implicit operator Gender?(string? code) => ConvertFromCode(code);
+}
+
+public class GenderCollection : ReferenceDataCollectionBase { }
\ No newline at end of file
diff --git a/tools/CoreEx.Template/content/Company.AppName.Business/Models/UsState.cs b/tools/CoreEx.Template/content/Company.AppName.Business/Models/UsState.cs
new file mode 100644
index 00000000..e1d629bf
--- /dev/null
+++ b/tools/CoreEx.Template/content/Company.AppName.Business/Models/UsState.cs
@@ -0,0 +1,8 @@
+namespace Company.AppName.Business.Models;
+
+public class USState : ReferenceDataBase
+{
+ public static implicit operator USState?(string? code) => ConvertFromCode(code);
+}
+
+public class USStateCollection : ReferenceDataCollectionBase { }
\ No newline at end of file
diff --git a/tools/CoreEx.Template/content/Company.AppName.Business/Services/EmployeeService.cs b/tools/CoreEx.Template/content/Company.AppName.Business/Services/EmployeeService.cs
new file mode 100644
index 00000000..9a0765dd
--- /dev/null
+++ b/tools/CoreEx.Template/content/Company.AppName.Business/Services/EmployeeService.cs
@@ -0,0 +1,68 @@
+namespace Company.AppName.Business.Services;
+
+public class EmployeeService : IEmployeeService
+{
+ private readonly AppNameDbContext _dbContext;
+ private readonly IEventPublisher _publisher;
+ private readonly AppNameSettings _settings;
+
+ public EmployeeService(AppNameDbContext dbContext, IEventPublisher publisher, AppNameSettings settings)
+ {
+ _dbContext = dbContext;
+ _publisher = publisher;
+ _settings = settings;
+ }
+
+ public async Task GetEmployeeAsync(Guid id)
+ => await _dbContext.Employees.FirstOrDefaultAsync(e => e.Id == id);
+
+ public Task GetAllAsync(PagingArgs? paging)
+ => _dbContext.Employees.OrderBy(x => x.LastName).ThenBy(x => x.FirstName).ToCollectionResultAsync(paging);
+
+ public async Task AddEmployeeAsync(Employee employee)
+ {
+ _dbContext.Employees.Add(employee);
+ await _dbContext.SaveChangesAsync();
+ return employee;
+ }
+
+ public async Task UpdateEmployeeAsync(Employee employee, Guid id)
+ {
+ if (!await _dbContext.Employees.AnyAsync(e => e.Id == id).ConfigureAwait(false))
+ throw new NotFoundException();
+
+ employee.Id = id;
+ _dbContext.Employees.Update(employee);
+ await _dbContext.SaveChangesAsync().ConfigureAwait(false);
+ return employee;
+ }
+
+ public async Task DeleteEmployeeAsync(Guid id)
+ {
+ var employee = await _dbContext.Employees.FirstOrDefaultAsync(e => e.Id == id);
+ if (employee != null)
+ {
+ _dbContext.Employees.Remove(employee);
+ await _dbContext.SaveChangesAsync();
+ }
+ }
+
+ public async Task VerifyEmployeeAsync(Guid id)
+ {
+ // Get the employee.
+ var employee = await GetEmployeeAsync(id);
+ if (employee == null)
+ throw new NotFoundException();
+
+ // Publish message to service bus for employee verification.
+ var verification = new EmployeeVerificationRequest
+ {
+ Name = employee.FirstName,
+ Age = DateTime.UtcNow.Subtract(employee.Birthday.GetValueOrDefault()).Days / 365,
+ Gender = employee.Gender?.Code
+ };
+
+ _publisher.Publish(_settings.VerificationQueueName, new EventData { Value = verification });
+ await _publisher.SendAsync();
+ }
+}
\ No newline at end of file
diff --git a/tools/CoreEx.Template/content/Company.AppName.Business/Services/EmployeeService2.cs b/tools/CoreEx.Template/content/Company.AppName.Business/Services/EmployeeService2.cs
new file mode 100644
index 00000000..346107d0
--- /dev/null
+++ b/tools/CoreEx.Template/content/Company.AppName.Business/Services/EmployeeService2.cs
@@ -0,0 +1,48 @@
+namespace Company.AppName.Business.Services;
+
+///
+/// Example using that largely encapsulates/simplifies the EF access.
+///
+public class EmployeeService2 : IEmployeeService
+{
+ private readonly IAppNameEfDb _efDb;
+ private readonly IEventPublisher _publisher;
+ private readonly AppNameSettings _settings;
+
+ public EmployeeService2(IAppNameEfDb efDb, IEventPublisher publisher, AppNameSettings settings)
+ {
+ _efDb = efDb;
+ _publisher = publisher;
+ _settings = settings;
+ }
+
+ public Task GetEmployeeAsync(Guid id) => _efDb.Employees.GetAsync(id);
+
+ public Task GetAllAsync(PagingArgs? paging)
+ => _efDb.Employees.Query(q => q.OrderBy(x => x.LastName).ThenBy(x => x.FirstName)).WithPaging(paging).SelectResultAsync();
+
+ public Task AddEmployeeAsync(Employee employee) => _efDb.Employees.CreateAsync(employee);
+
+ public Task UpdateEmployeeAsync(Employee employee, Guid id) => _efDb.Employees.UpdateAsync(employee.Adjust(x => x.Id = id));
+
+ public Task DeleteEmployeeAsync(Guid id) => _efDb.Employees.DeleteAsync(id);
+
+ public async Task VerifyEmployeeAsync(Guid id)
+ {
+ // Get the employee.
+ var employee = await GetEmployeeAsync(id);
+ if (employee == null)
+ throw new NotFoundException();
+
+ // Publish message to service bus for employee verification.
+ var verification = new EmployeeVerificationRequest
+ {
+ Name = employee.FirstName,
+ Age = DateTime.UtcNow.Subtract(employee.Birthday.GetValueOrDefault()).Days / 365,
+ Gender = employee.Gender?.Code
+ };
+
+ _publisher.Publish(_settings.VerificationQueueName, new EventData { Value = verification });
+ await _publisher.SendAsync();
+ }
+}
\ No newline at end of file
diff --git a/tools/CoreEx.Template/content/Company.AppName.Business/Services/IEmployeeService.cs b/tools/CoreEx.Template/content/Company.AppName.Business/Services/IEmployeeService.cs
new file mode 100644
index 00000000..8627345d
--- /dev/null
+++ b/tools/CoreEx.Template/content/Company.AppName.Business/Services/IEmployeeService.cs
@@ -0,0 +1,17 @@
+namespace Company.AppName.Business.Services
+{
+ public interface IEmployeeService
+ {
+ Task GetEmployeeAsync(Guid id);
+
+ Task GetAllAsync(PagingArgs? paging);
+
+ Task AddEmployeeAsync(Employee employee);
+
+ Task UpdateEmployeeAsync(Employee employee, Guid id);
+
+ Task DeleteEmployeeAsync(Guid id);
+
+ Task VerifyEmployeeAsync(Guid id);
+ }
+}
\ No newline at end of file
diff --git a/tools/CoreEx.Template/content/Company.AppName.Business/Services/ReferenceDataService.cs b/tools/CoreEx.Template/content/Company.AppName.Business/Services/ReferenceDataService.cs
new file mode 100644
index 00000000..5cb75c40
--- /dev/null
+++ b/tools/CoreEx.Template/content/Company.AppName.Business/Services/ReferenceDataService.cs
@@ -0,0 +1,25 @@
+using CoreEx.Database;
+using CoreEx.Database.Extended;
+
+namespace Company.AppName.Business.Services;
+
+public class ReferenceDataService : IReferenceDataProvider
+{
+ private readonly IDatabase _db;
+ private readonly AppNameDbContext _dbContext;
+
+ public ReferenceDataService(IDatabase db, AppNameDbContext dbContext)
+ {
+ _db = db;
+ _dbContext = dbContext;
+ }
+
+ public Type[] Types => new Type[] { typeof(USState), typeof(Gender) };
+
+ public async Task GetAsync(Type type, CancellationToken cancellationToken = default) => type switch
+ {
+ Type t when t == typeof(USState) => await USStateCollection.CreateAsync(_dbContext.USStates.AsNoTracking(), cancellationToken).ConfigureAwait(false),
+ Type t when t == typeof(Gender) => await _db.ReferenceData("AppName", "Gender").LoadAsync("GenderId", cancellationToken: cancellationToken).ConfigureAwait(false),
+ _ => throw new InvalidOperationException($"Type {type.FullName} is not a known {nameof(IReferenceData)}.")
+ };
+}
\ No newline at end of file
diff --git a/tools/CoreEx.Template/content/Company.AppName.Business/Services/VerificationService.cs b/tools/CoreEx.Template/content/Company.AppName.Business/Services/VerificationService.cs
new file mode 100644
index 00000000..74fd503f
--- /dev/null
+++ b/tools/CoreEx.Template/content/Company.AppName.Business/Services/VerificationService.cs
@@ -0,0 +1,69 @@
+namespace Company.AppName.Business.Services;
+
+public class VerificationService
+{
+ private readonly AgifyApiClient _agifyApiClient;
+ private readonly GenderizeApiClient _genderizeApiClient;
+ private readonly NationalizeApiClient _nationalizeApiClient;
+ private readonly AppNameSettings _settings;
+ private readonly IEventPublisher _publisher;
+
+ public VerificationService(AgifyApiClient agifyApiClient, GenderizeApiClient genderizeApiClient, NationalizeApiClient nationalizeApiClient, AppNameSettings settings, IEventPublisher publisher)
+ {
+ _agifyApiClient = agifyApiClient;
+ _genderizeApiClient = genderizeApiClient;
+ _nationalizeApiClient = nationalizeApiClient;
+ _settings = settings;
+ _publisher = publisher;
+ }
+
+ public async Task> VerifyAsync(string name)
+ {
+ var agifyTask = _agifyApiClient.GetAgeAsync(name);
+ var genderizeTask = _genderizeApiClient.GetGenderAsync(name);
+ var nationalizeTask = _nationalizeApiClient.GetNationalityAsync(name);
+
+ await Task.WhenAll(agifyTask, genderizeTask, nationalizeTask);
+
+ return new Tuple(agifyTask.Result, genderizeTask.Result, nationalizeTask.Result);
+ }
+
+ public async Task VerifyAndPublish(EmployeeVerificationRequest request)
+ {
+ var result = await VerifyAsync(request.Name!);
+
+ var response = new EmployeeVerificationResponse(request)
+ {
+ Age = result.Item1.Age,
+ Gender = result.Item2.Gender,
+ GenderProbability = result.Item2.Probability
+ };
+
+ response.Country.AddRange(result.Item3.Country!);
+
+ var nationality = response.Country.OrderByDescending(c => c.Probability).First();
+
+ response.VerificationMessages.Add(
+ @$"Performed verification for {request.Name}, {request.Gender} age {request.Age}.
+ Engine predicted age was {response.Age}.
+ Engine predicted gender was {response.Gender} with {ToPercents(response.GenderProbability)} probability.
+ Most likely nationality of {request.Name} is {nationality.Country_Id} with {ToPercents(nationality.Probability)} probability"
+ );
+
+ // first check age
+ if (Math.Abs(request.Age - response.Age) >= 10)
+ {
+ response.VerificationMessages.Add($"Employee age ({request.Age}) is not within range of 10 years of predicted age: {response.Age}");
+ }
+
+ if (response.GenderProbability > 0.5 && !response.Gender!.Equals(request.Gender, StringComparison.InvariantCultureIgnoreCase))
+ {
+ response.VerificationMessages.Add($"Employee gender ({request.Gender}) doesn't match predicted gender: {response.Gender}");
+ }
+
+ _publisher.Publish(_settings.VerificationResultsQueueName, new EventData { Value = response });
+ await _publisher.SendAsync();
+ }
+
+ private static string ToPercents(float value) => (int)(value * 100) + "%";
+}
\ No newline at end of file
diff --git a/tools/CoreEx.Template/content/Company.AppName.Business/Validators/EmployeeValidator.cs b/tools/CoreEx.Template/content/Company.AppName.Business/Validators/EmployeeValidator.cs
new file mode 100644
index 00000000..fcc8243e
--- /dev/null
+++ b/tools/CoreEx.Template/content/Company.AppName.Business/Validators/EmployeeValidator.cs
@@ -0,0 +1,17 @@
+using FluentValidation;
+
+namespace Company.AppName.Business.Validators;
+
+public class EmployeeValidator : AbstractValidator
+{
+ public EmployeeValidator()
+ {
+ RuleFor(x => x.Email).NotNull().EmailAddress();
+ RuleFor(x => x.FirstName).NotNull().MaximumLength(100);
+ RuleFor(x => x.LastName).NotNull().MaximumLength(100);
+ RuleFor(x => x.Gender).NotNull().IsValid();
+ RuleFor(x => x.Birthday).NotNull().LessThanOrEqualTo(DateTime.UtcNow.AddYears(-18)).WithMessage("Birthday is invalid as the Employee must be at least 18 years of age.");
+ RuleFor(x => x.StartDate).NotNull().GreaterThanOrEqualTo(new DateTime(1999, 01, 01, 0, 0, 0, DateTimeKind.Utc)).WithMessage("January 1, 1999");
+ RuleFor(x => x.PhoneNo).NotNull().MaximumLength(50);
+ }
+}
\ No newline at end of file
diff --git a/tools/CoreEx.Template/content/Company.AppName.Business/Validators/EmployeeVerificationValidator.cs b/tools/CoreEx.Template/content/Company.AppName.Business/Validators/EmployeeVerificationValidator.cs
new file mode 100644
index 00000000..c753c543
--- /dev/null
+++ b/tools/CoreEx.Template/content/Company.AppName.Business/Validators/EmployeeVerificationValidator.cs
@@ -0,0 +1,13 @@
+using FluentValidation;
+
+namespace Company.AppName.Business.Validators;
+
+public class EmployeeVerificationValidator : AbstractValidator
+{
+ public EmployeeVerificationValidator()
+ {
+ RuleFor(x => x.Name).NotNull().MaximumLength(100);
+ RuleFor(x => x.Gender).NotNull().MaximumLength(50); // todo: validate if reference data exists
+ RuleFor(x => x.Age).NotNull().GreaterThanOrEqualTo(18).LessThanOrEqualTo(120).WithMessage("Age has to be between 18 and 120");
+ }
+}
\ No newline at end of file
diff --git a/tools/CoreEx.Template/content/Company.AppName.Database/Company.AppName.Database.csproj b/tools/CoreEx.Template/content/Company.AppName.Database/Company.AppName.Database.csproj
new file mode 100644
index 00000000..533b883a
--- /dev/null
+++ b/tools/CoreEx.Template/content/Company.AppName.Database/Company.AppName.Database.csproj
@@ -0,0 +1,32 @@
+
+
+
+ Exe
+ net6.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tools/CoreEx.Template/content/Company.AppName.Database/Data/RefData.yaml b/tools/CoreEx.Template/content/Company.AppName.Database/Data/RefData.yaml
new file mode 100644
index 00000000..58c788d4
--- /dev/null
+++ b/tools/CoreEx.Template/content/Company.AppName.Database/Data/RefData.yaml
@@ -0,0 +1,72 @@
+AppName:
+ - $Gender:
+ - F: Female
+ - M: Male
+ - N: Not specified
+ - $TerminationReason:
+ - RE: Resigned
+ - RD: Redundant
+ - TM: Terminated
+ - $RelationshipType:
+ - SPO: Spouse
+ - PTN: Partner
+ - PAR: Parent
+ - CHI: Child
+ - SIB: Sibling
+ - EXF: Extended family
+ - FRD: Friend
+ - $USState:
+ - AL: Alabama
+ - AK: Alaska
+ - AZ: Arizona
+ - AR: Arkansas
+ - CA: California
+ - CO: Colorado
+ - CT: Connecticut
+ - DE: Delaware
+ - FL: Florida
+ - GA: Georgia
+ - HI: Hawaii
+ - ID: Idaho
+ - IL: Illinois
+ - IN: Indiana
+ - IA: Iowa
+ - KS: Kansas
+ - KY: Kentucky
+ - LA: Louisiana
+ - ME: Maine
+ - MD: Maryland
+ - MA: Massachusetts
+ - MI: Michigan
+ - MN: Minnesota
+ - MS: Mississippi
+ - MO: Missouri
+ - MT: Montana
+ - NE: Nebraska
+ - NV: Nevada
+ - NH: New Hampshire
+ - NJ: New Jersey
+ - NM: New Mexico
+ - NY: New York
+ - NC: North Carolina
+ - ND: North Dakota
+ - OH: Ohio
+ - OK: Oklahoma
+ - OR: Oregon
+ - PA: Pennsylvania
+ - RI: Rhode Island
+ - SC: South Carolina
+ - SD: South Dakota
+ - TN: Tennessee
+ - TX: Texas
+ - UT: Utah
+ - VT: Vermont
+ - VA: Virginia
+ - WA: Washington
+ - WV: West Virginia
+ - WI: Wisconsin
+ - WY: Wyoming
+ - $PerformanceOutcome:
+ - DN: Does not meet expectations
+ - ME: Meets expectations
+ - EE: Exceeds expectations
\ No newline at end of file
diff --git a/tools/CoreEx.Template/content/Company.AppName.Database/Dockerfile b/tools/CoreEx.Template/content/Company.AppName.Database/Dockerfile
new file mode 100644
index 00000000..9a6dd08d
--- /dev/null
+++ b/tools/CoreEx.Template/content/Company.AppName.Database/Dockerfile
@@ -0,0 +1,57 @@
+FROM mcr.microsoft.com/mssql/server:2022-latest AS base
+USER root
+# Install dotnet sdk
+# RUN apt-get update; \
+# apt-get install -y apt-transport-https && \
+# apt-get update && \
+# apt-get install -y dotnet-runtime-6.0
+
+RUN wget -q https://dot.net/v1/dotnet-install.sh \
+ && chmod +x ./dotnet-install.sh \
+ && ./dotnet-install.sh --runtime aspnetcore -c 6.0
+ENV PATH="${PATH}:/root/.dotnet"
+
+FROM mcr.microsoft.com/dotnet/sdk:6.0 AS build
+WORKDIR /src
+
+# It's important to keep lines from here down to "COPY . ." identical in all Dockerfiles
+# to take advantage of Docker's build cache, to speed up local container builds
+COPY "Company.AppName.sln" "Company.AppName.sln"
+
+COPY "Company.AppName.Api/Company.AppName.Api.csproj" "Company.AppName.Api/Company.AppName.Api.csproj"
+COPY "Company.AppName.Business/Company.AppName.Business.csproj" "Company.AppName.Business/Company.AppName.Business.csproj"
+COPY "Company.AppName.Database/Company.AppName.Database.csproj" "Company.AppName.Database/Company.AppName.Database.csproj"
+COPY "Company.AppName.Functions/Company.AppName.Functions.csproj" "Company.AppName.Functions/Company.AppName.Functions.csproj"
+COPY "Company.AppName.UnitTest/Company.AppName.UnitTest.csproj" "Company.AppName.UnitTest/Company.AppName.UnitTest.csproj"
+COPY "Company.AppName.Infra/Company.AppName.Infra.csproj" "Company.AppName.Infra/Company.AppName.Infra.csproj"
+COPY "Company.AppName.Infra.Tests/Company.AppName.Infra.Tests.csproj" "Company.AppName.Infra.Tests/Company.AppName.Infra.Tests.csproj"
+
+# todo: - remove this
+COPY "NuGet.config" "NuGet.config"
+COPY "nuget-publish" "nuget-publish"
+
+RUN dotnet restore "Company.AppName.sln"
+
+COPY . .
+WORKDIR /src/Company.AppName.Database
+RUN dotnet build -c Release -o /dbex/build
+
+FROM base as final
+USER root
+
+ENV ACCEPT_EULA Y
+ENV MSSQL_SA_PASSWORD sAPWD23.^0
+ENV MSSQL_TCP_PORT 1433
+ENV MSSQL_AGENT_ENABLED true
+ENV ConnectionStrings__Database="Data Source=localhost, $MSSQL_TCP_PORT;Initial Catalog=Company.AppName;User id=sa;Password=$MSSQL_SA_PASSWORD;TrustServerCertificate=true"
+
+
+# Copy setup scripts
+WORKDIR /usr/local/
+COPY --from=build /dbex/build /dbex
+COPY Company.AppName.Database/wait-for-it.sh Company.AppName.Database/entrypoint.sh ./
+
+RUN chmod +x ./*.sh
+
+ENTRYPOINT ["/usr/local/entrypoint.sh"]
+CMD ["sql"]
\ No newline at end of file
diff --git a/tools/CoreEx.Template/content/Company.AppName.Database/Migrations/20190101-000001-create-AppName-schema.sql b/tools/CoreEx.Template/content/Company.AppName.Database/Migrations/20190101-000001-create-AppName-schema.sql
new file mode 100644
index 00000000..4c44aac9
--- /dev/null
+++ b/tools/CoreEx.Template/content/Company.AppName.Database/Migrations/20190101-000001-create-AppName-schema.sql
@@ -0,0 +1,2 @@
+CREATE SCHEMA [AppName]
+ AUTHORIZATION [dbo];
\ No newline at end of file
diff --git a/tools/CoreEx.Template/content/Company.AppName.Database/Migrations/20200909-162702-create-AppName-Employee.sql b/tools/CoreEx.Template/content/Company.AppName.Database/Migrations/20200909-162702-create-AppName-Employee.sql
new file mode 100644
index 00000000..e342ff8c
--- /dev/null
+++ b/tools/CoreEx.Template/content/Company.AppName.Database/Migrations/20200909-162702-create-AppName-Employee.sql
@@ -0,0 +1,24 @@
+-- Migration Script
+
+BEGIN TRANSACTION
+
+CREATE TABLE [AppName].[Employee] (
+ [EmployeeId] UNIQUEIDENTIFIER NOT NULL DEFAULT (NEWSEQUENTIALID()) PRIMARY KEY, -- This is the primary key
+ [Email] NVARCHAR(250) NULL UNIQUE, -- This is the employee's unique email address
+ [FirstName] NVARCHAR(100) NULL,
+ [LastName] NVARCHAR(100) NULL,
+ [GenderCode] NVARCHAR(50) NULL, -- This is the related Gender code; see Ref.Gender table
+ [Birthday] DATE NULL,
+ [StartDate] DATE NULL,
+ [TerminationDate] DATE NULL,
+ [TerminationReasonCode] NVARCHAR(50) NULL, -- This is the related Termination Reason code; see Ref.TerminationReason table
+ [PhoneNo] NVARCHAR(50) NULL,
+ [AddressJson] NVARCHAR(500) NULL, -- This is the full address persisted as JSON.
+ [RowVersion] TIMESTAMP NOT NULL, -- This is used for concurrency version checking.
+ [CreatedBy] NVARCHAR(250) NULL, -- The following are standard audit columns.
+ [CreatedDate] DATETIME2 NULL,
+ [UpdatedBy] NVARCHAR(250) NULL,
+ [UpdatedDate] DATETIME2 NULL
+);
+
+COMMIT TRANSACTION
\ No newline at end of file
diff --git a/tools/CoreEx.Template/content/Company.AppName.Database/Migrations/20200909-163321-create-AppName-EmergencyContact.sql b/tools/CoreEx.Template/content/Company.AppName.Database/Migrations/20200909-163321-create-AppName-EmergencyContact.sql
new file mode 100644
index 00000000..3b688084
--- /dev/null
+++ b/tools/CoreEx.Template/content/Company.AppName.Database/Migrations/20200909-163321-create-AppName-EmergencyContact.sql
@@ -0,0 +1,14 @@
+-- Migration Script
+
+BEGIN TRANSACTION
+
+CREATE TABLE [AppName].[EmergencyContact] (
+ [EmergencyContactId] UNIQUEIDENTIFIER NOT NULL DEFAULT (NEWSEQUENTIALID()) PRIMARY KEY,
+ [EmployeeId] UNIQUEIDENTIFIER NOT NULL,
+ [FirstName] NVARCHAR(100) NULL,
+ [LastName] NVARCHAR(100) NULL,
+ [PhoneNo] NVARCHAR(50) NULL,
+ [RelationshipTypeCode] NVARCHAR(50) NULL
+);
+
+COMMIT TRANSACTION
\ No newline at end of file
diff --git a/tools/CoreEx.Template/content/Company.AppName.Database/Migrations/20200909-164735-create-AppName-gender.sql b/tools/CoreEx.Template/content/Company.AppName.Database/Migrations/20200909-164735-create-AppName-gender.sql
new file mode 100644
index 00000000..9c2ac005
--- /dev/null
+++ b/tools/CoreEx.Template/content/Company.AppName.Database/Migrations/20200909-164735-create-AppName-gender.sql
@@ -0,0 +1,18 @@
+-- Migration Script
+
+BEGIN TRANSACTION
+
+CREATE TABLE [AppName].[Gender] (
+ [GenderId] UNIQUEIDENTIFIER NOT NULL DEFAULT (NEWSEQUENTIALID()) PRIMARY KEY,
+ [Code] NVARCHAR(50) NOT NULL UNIQUE,
+ [Text] NVARCHAR(250) NULL,
+ [IsActive] BIT NULL,
+ [SortOrder] INT NULL,
+ [RowVersion] TIMESTAMP NOT NULL,
+ [CreatedBy] NVARCHAR(250) NULL,
+ [CreatedDate] DATETIME2 NULL,
+ [UpdatedBy] NVARCHAR(250) NULL,
+ [UpdatedDate] DATETIME2 NULL
+);
+
+COMMIT TRANSACTION
\ No newline at end of file
diff --git a/tools/CoreEx.Template/content/Company.AppName.Database/Migrations/20200909-164828-create-AppName-terminationreason.sql b/tools/CoreEx.Template/content/Company.AppName.Database/Migrations/20200909-164828-create-AppName-terminationreason.sql
new file mode 100644
index 00000000..4c774454
--- /dev/null
+++ b/tools/CoreEx.Template/content/Company.AppName.Database/Migrations/20200909-164828-create-AppName-terminationreason.sql
@@ -0,0 +1,18 @@
+-- Migration Script
+
+BEGIN TRANSACTION
+
+CREATE TABLE [AppName].[TerminationReason] (
+ [TerminationReasonId] UNIQUEIDENTIFIER NOT NULL DEFAULT (NEWSEQUENTIALID()) PRIMARY KEY,
+ [Code] NVARCHAR(50) NOT NULL UNIQUE,
+ [Text] NVARCHAR(250) NULL,
+ [IsActive] BIT NULL,
+ [SortOrder] INT NULL,
+ [RowVersion] TIMESTAMP NOT NULL,
+ [CreatedBy] NVARCHAR(250) NULL,
+ [CreatedDate] DATETIME2 NULL,
+ [UpdatedBy] NVARCHAR(250) NULL,
+ [UpdatedDate] DATETIME2 NULL
+);
+
+COMMIT TRANSACTION
\ No newline at end of file
diff --git a/tools/CoreEx.Template/content/Company.AppName.Database/Migrations/20200909-165308-create-AppName-relationshiptype.sql b/tools/CoreEx.Template/content/Company.AppName.Database/Migrations/20200909-165308-create-AppName-relationshiptype.sql
new file mode 100644
index 00000000..dc84b6ec
--- /dev/null
+++ b/tools/CoreEx.Template/content/Company.AppName.Database/Migrations/20200909-165308-create-AppName-relationshiptype.sql
@@ -0,0 +1,18 @@
+-- Migration Script
+
+BEGIN TRANSACTION
+
+CREATE TABLE [AppName].[RelationshipType] (
+ [RelationshipTypeId] UNIQUEIDENTIFIER NOT NULL DEFAULT (NEWSEQUENTIALID()) PRIMARY KEY,
+ [Code] NVARCHAR(50) NOT NULL UNIQUE,
+ [Text] NVARCHAR(250) NULL,
+ [IsActive] BIT NULL,
+ [SortOrder] INT NULL,
+ [RowVersion] TIMESTAMP NOT NULL,
+ [CreatedBy] NVARCHAR(250) NULL,
+ [CreatedDate] DATETIME2 NULL,
+ [UpdatedBy] NVARCHAR(250) NULL,
+ [UpdatedDate] DATETIME2 NULL
+);
+
+COMMIT TRANSACTION
\ No newline at end of file
diff --git a/tools/CoreEx.Template/content/Company.AppName.Database/Migrations/20200909-165752-create-AppName-usstate.sql b/tools/CoreEx.Template/content/Company.AppName.Database/Migrations/20200909-165752-create-AppName-usstate.sql
new file mode 100644
index 00000000..d9c725be
--- /dev/null
+++ b/tools/CoreEx.Template/content/Company.AppName.Database/Migrations/20200909-165752-create-AppName-usstate.sql
@@ -0,0 +1,18 @@
+-- Migration Script
+
+BEGIN TRANSACTION
+
+CREATE TABLE [AppName].[USState] (
+ [USStateId] UNIQUEIDENTIFIER NOT NULL DEFAULT (NEWSEQUENTIALID()) PRIMARY KEY,
+ [Code] NVARCHAR(50) NOT NULL UNIQUE,
+ [Text] NVARCHAR(250) NULL,
+ [IsActive] BIT NULL,
+ [SortOrder] INT NULL,
+ [RowVersion] TIMESTAMP NOT NULL,
+ [CreatedBy] NVARCHAR(250) NULL,
+ [CreatedDate] DATETIME2 NULL,
+ [UpdatedBy] NVARCHAR(250) NULL,
+ [UpdatedDate] DATETIME2 NULL
+);
+
+COMMIT TRANSACTION
\ No newline at end of file
diff --git a/tools/CoreEx.Template/content/Company.AppName.Database/Migrations/20200915-160812-create-AppName-PerformanceReview.sql b/tools/CoreEx.Template/content/Company.AppName.Database/Migrations/20200915-160812-create-AppName-PerformanceReview.sql
new file mode 100644
index 00000000..93765bed
--- /dev/null
+++ b/tools/CoreEx.Template/content/Company.AppName.Database/Migrations/20200915-160812-create-AppName-PerformanceReview.sql
@@ -0,0 +1,19 @@
+-- Migration Script
+
+BEGIN TRANSACTION
+
+CREATE TABLE [AppName].[PerformanceReview] (
+ [PerformanceReviewId] UNIQUEIDENTIFIER NOT NULL DEFAULT (NEWSEQUENTIALID()) PRIMARY KEY,
+ [EmployeeId] UNIQUEIDENTIFIER NOT NULL,
+ [Date] DATETIME2 NULL,
+ [PerformanceOutcomeCode] NVARCHAR(50) NULL,
+ [Reviewer] NVARCHAR(100) NULL,
+ [Notes] NVARCHAR(4000) NULL,
+ [RowVersion] TIMESTAMP NOT NULL,
+ [CreatedBy] NVARCHAR(250) NULL,
+ [CreatedDate] DATETIME2 NULL,
+ [UpdatedBy] NVARCHAR(250) NULL,
+ [UpdatedDate] DATETIME2 NULL
+);
+
+COMMIT TRANSACTION
\ No newline at end of file
diff --git a/tools/CoreEx.Template/content/Company.AppName.Database/Migrations/20200915-161927-create-AppName-performanceoutcome.sql b/tools/CoreEx.Template/content/Company.AppName.Database/Migrations/20200915-161927-create-AppName-performanceoutcome.sql
new file mode 100644
index 00000000..8bcebcb0
--- /dev/null
+++ b/tools/CoreEx.Template/content/Company.AppName.Database/Migrations/20200915-161927-create-AppName-performanceoutcome.sql
@@ -0,0 +1,18 @@
+-- Migration Script
+
+BEGIN TRANSACTION
+
+CREATE TABLE [AppName].[PerformanceOutcome] (
+ [PerformanceOutcomeId] UNIQUEIDENTIFIER NOT NULL DEFAULT (NEWSEQUENTIALID()) PRIMARY KEY,
+ [Code] NVARCHAR(50) NOT NULL UNIQUE,
+ [Text] NVARCHAR(250) NULL,
+ [IsActive] BIT NULL,
+ [SortOrder] INT NULL,
+ [RowVersion] TIMESTAMP NOT NULL,
+ [CreatedBy] NVARCHAR(250) NULL,
+ [CreatedDate] DATETIME2 NULL,
+ [UpdatedBy] NVARCHAR(250) NULL,
+ [UpdatedDate] DATETIME2 NULL
+);
+
+COMMIT TRANSACTION
\ No newline at end of file
diff --git a/tools/CoreEx.Template/content/Company.AppName.Database/Migrations/20211208-001509-create-AppName-eventoutbox-table.sql b/tools/CoreEx.Template/content/Company.AppName.Database/Migrations/20211208-001509-create-AppName-eventoutbox-table.sql
new file mode 100644
index 00000000..62773c7b
--- /dev/null
+++ b/tools/CoreEx.Template/content/Company.AppName.Database/Migrations/20211208-001509-create-AppName-eventoutbox-table.sql
@@ -0,0 +1,11 @@
+CREATE TABLE [AppName].[EventOutbox] (
+ /*
+ * This is automatically generated; any changes will be lost.
+ */
+
+ [EventOutboxId] BIGINT IDENTITY (1, 1) NOT NULL PRIMARY KEY NONCLUSTERED ([EventOutboxId] ASC),
+ [EnqueuedDate] DATETIME2 NOT NULL,
+ [PartitionKey] NVARCHAR(128) NULL,
+ [DequeuedDate] DATETIME2 NULL,
+ CONSTRAINT [IX_AppName_EventOutbox_DequeuedDate] UNIQUE CLUSTERED ([DequeuedDate], [EventOutboxId])
+);
\ No newline at end of file
diff --git a/tools/CoreEx.Template/content/Company.AppName.Database/Migrations/20211208-001509-create-AppName-eventoutboxdata-table.sql b/tools/CoreEx.Template/content/Company.AppName.Database/Migrations/20211208-001509-create-AppName-eventoutboxdata-table.sql
new file mode 100644
index 00000000..dc88305b
--- /dev/null
+++ b/tools/CoreEx.Template/content/Company.AppName.Database/Migrations/20211208-001509-create-AppName-eventoutboxdata-table.sql
@@ -0,0 +1,15 @@
+CREATE TABLE [AppName].[EventOutboxData] (
+ /*
+ * This is automatically generated; any changes will be lost.
+ */
+
+ [EventOutboxId] BIGINT NOT NULL PRIMARY KEY CLUSTERED ([EventOutboxId] ASC),
+ [EventId] UNIQUEIDENTIFIER,
+ [Subject] NVARCHAR(1024),
+ [Action] NVARCHAR(128) NULL,
+ [CorrelationId] NVARCHAR(64) NULL,
+ [TenantId] UNIQUEIDENTIFIER NULL,
+ [PartitionKey] NVARCHAR(128) NULL,
+ [ValueType] NVARCHAR(1024) NULL,
+ [EventData] VARBINARY(MAX) NULL
+);
\ No newline at end of file
diff --git a/tools/CoreEx.Template/content/Company.AppName.Database/Program.cs b/tools/CoreEx.Template/content/Company.AppName.Database/Program.cs
new file mode 100644
index 00000000..2c8fef28
--- /dev/null
+++ b/tools/CoreEx.Template/content/Company.AppName.Database/Program.cs
@@ -0,0 +1,31 @@
+using DbEx.Console;
+using System.Reflection;
+
+namespace Company.AppName.Database
+{
+ ///
+ /// Represents the database utilities program (capability).
+ ///
+ public class Program
+ {
+ ///
+ /// Main startup.
+ ///
+ /// The startup arguments.
+ /// The status code whereby zero indicates success.
+ internal static Task Main(string[] args) => RunMigrator("Data Source=.;Initial Catalog=Company.AppNameDb;Integrated Security=True;TrustServerCertificate=true", null, args);
+
+ public static Task RunMigrator(string connectionString, Assembly? assembly = null, params string[] args)
+ => SqlServerMigratorConsole
+ .Create(connectionString)
+ .ConsoleArgs(a =>
+ {
+ a.ConnectionStringEnvironmentVariableName = "ConnectionStrings__Database";
+ a.DataParserArgs.RefDataColumnDefaults.TryAdd("IsActive", _ => true);
+ a.DataParserArgs.RefDataColumnDefaults.TryAdd("SortOrder", i => i);
+ if (assembly != null)
+ a.AddAssembly(assembly);
+ })
+ .RunAsync(args);
+ }
+}
\ No newline at end of file
diff --git a/tools/CoreEx.Template/content/Company.AppName.Database/Properties/launchSettings.json b/tools/CoreEx.Template/content/Company.AppName.Database/Properties/launchSettings.json
new file mode 100644
index 00000000..d7b05887
--- /dev/null
+++ b/tools/CoreEx.Template/content/Company.AppName.Database/Properties/launchSettings.json
@@ -0,0 +1,8 @@
+{
+ "profiles": {
+ "Company.AppName.Database": {
+ "commandName": "Project",
+ "commandLineArgs": "all"
+ }
+ }
+}
\ No newline at end of file
diff --git a/tools/CoreEx.Template/content/Company.AppName.Database/entrypoint.sh b/tools/CoreEx.Template/content/Company.AppName.Database/entrypoint.sh
new file mode 100644
index 00000000..c19e6d1d
--- /dev/null
+++ b/tools/CoreEx.Template/content/Company.AppName.Database/entrypoint.sh
@@ -0,0 +1,20 @@
+#!/usr/bin/env bash
+set -e
+
+if [ "$1" = 'sql' ]; then
+ if ! [[ -f /var/opt/mssql/.initialized ]];
+ then
+ ./wait-for-it.sh localhost:1433 -t 30 -- sleep 10 && echo "db is up"
+
+ echo "Creating $DB_NAME database..."
+
+ #run the setup script to create the DB and the schema in the DB
+ dotnet /dbex/Company.AppName.Database.dll all
+
+ echo "Database scripts complete"
+ touch /var/opt/mssql/.initialized
+ fi &
+ exec /opt/mssql/bin/sqlservr
+fi
+
+exec "$@"
\ No newline at end of file
diff --git a/tools/CoreEx.Template/content/Company.AppName.Database/wait-for-it.sh b/tools/CoreEx.Template/content/Company.AppName.Database/wait-for-it.sh
new file mode 100644
index 00000000..127f18c4
--- /dev/null
+++ b/tools/CoreEx.Template/content/Company.AppName.Database/wait-for-it.sh
@@ -0,0 +1,183 @@
+#!/usr/bin/env bash
+# Use this script to test if a given TCP host/port are available
+# Source: https://github.com/vishnubob/wait-for-it
+
+WAITFORIT_cmdname=${0##*/}
+
+echoerr() { if [[ $WAITFORIT_QUIET -ne 1 ]]; then echo "$@" 1>&2; fi }
+
+usage()
+{
+ cat << USAGE >&2
+Usage:
+ $WAITFORIT_cmdname host:port [-s] [-t timeout] [-- command args]
+ -h HOST | --host=HOST Host or IP under test
+ -p PORT | --port=PORT TCP port under test
+ Alternatively, you specify the host and port as host:port
+ -s | --strict Only execute subcommand if the test succeeds
+ -q | --quiet Don't output any status messages
+ -t TIMEOUT | --timeout=TIMEOUT
+ Timeout in seconds, zero for no timeout
+ -- COMMAND ARGS Execute command with args after the test finishes
+USAGE
+ exit 1
+}
+
+wait_for()
+{
+ if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then
+ echoerr "$WAITFORIT_cmdname: waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT"
+ else
+ echoerr "$WAITFORIT_cmdname: waiting for $WAITFORIT_HOST:$WAITFORIT_PORT without a timeout"
+ fi
+ WAITFORIT_start_ts=$(date +%s)
+ while :
+ do
+ if [[ $WAITFORIT_ISBUSY -eq 1 ]]; then
+ nc -z $WAITFORIT_HOST $WAITFORIT_PORT
+ WAITFORIT_result=$?
+ else
+ (echo -n > /dev/tcp/$WAITFORIT_HOST/$WAITFORIT_PORT) >/dev/null 2>&1
+ WAITFORIT_result=$?
+ fi
+ if [[ $WAITFORIT_result -eq 0 ]]; then
+ WAITFORIT_end_ts=$(date +%s)
+ echoerr "$WAITFORIT_cmdname: $WAITFORIT_HOST:$WAITFORIT_PORT is available after $((WAITFORIT_end_ts - WAITFORIT_start_ts)) seconds"
+ break
+ fi
+ sleep 1
+ done
+ return $WAITFORIT_result
+}
+
+wait_for_wrapper()
+{
+ # In order to support SIGINT during timeout: http://unix.stackexchange.com/a/57692
+ if [[ $WAITFORIT_QUIET -eq 1 ]]; then
+ timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --quiet --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT &
+ else
+ timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT &
+ fi
+ WAITFORIT_PID=$!
+ trap "kill -INT -$WAITFORIT_PID" INT
+ wait $WAITFORIT_PID
+ WAITFORIT_RESULT=$?
+ if [[ $WAITFORIT_RESULT -ne 0 ]]; then
+ echoerr "$WAITFORIT_cmdname: timeout occurred after waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT"
+ fi
+ return $WAITFORIT_RESULT
+}
+
+# process arguments
+while [[ $# -gt 0 ]]
+do
+ case "$1" in
+ *:* )
+ WAITFORIT_hostport=(${1//:/ })
+ WAITFORIT_HOST=${WAITFORIT_hostport[0]}
+ WAITFORIT_PORT=${WAITFORIT_hostport[1]}
+ shift 1
+ ;;
+ --child)
+ WAITFORIT_CHILD=1
+ shift 1
+ ;;
+ -q | --quiet)
+ WAITFORIT_QUIET=1
+ shift 1
+ ;;
+ -s | --strict)
+ WAITFORIT_STRICT=1
+ shift 1
+ ;;
+ -h)
+ WAITFORIT_HOST="$2"
+ if [[ $WAITFORIT_HOST == "" ]]; then break; fi
+ shift 2
+ ;;
+ --host=*)
+ WAITFORIT_HOST="${1#*=}"
+ shift 1
+ ;;
+ -p)
+ WAITFORIT_PORT="$2"
+ if [[ $WAITFORIT_PORT == "" ]]; then break; fi
+ shift 2
+ ;;
+ --port=*)
+ WAITFORIT_PORT="${1#*=}"
+ shift 1
+ ;;
+ -t)
+ WAITFORIT_TIMEOUT="$2"
+ if [[ $WAITFORIT_TIMEOUT == "" ]]; then break; fi
+ shift 2
+ ;;
+ --timeout=*)
+ WAITFORIT_TIMEOUT="${1#*=}"
+ shift 1
+ ;;
+ --)
+ shift
+ WAITFORIT_CLI=("$@")
+ break
+ ;;
+ --help)
+ usage
+ ;;
+ *)
+ echoerr "Unknown argument: $1"
+ usage
+ ;;
+ esac
+done
+
+if [[ "$WAITFORIT_HOST" == "" || "$WAITFORIT_PORT" == "" ]]; then
+ echoerr "Error: you need to provide a host and port to test."
+ usage
+fi
+
+WAITFORIT_TIMEOUT=${WAITFORIT_TIMEOUT:-15}
+WAITFORIT_STRICT=${WAITFORIT_STRICT:-0}
+WAITFORIT_CHILD=${WAITFORIT_CHILD:-0}
+WAITFORIT_QUIET=${WAITFORIT_QUIET:-0}
+
+# Check to see if timeout is from busybox?
+WAITFORIT_TIMEOUT_PATH=$(type -p timeout)
+WAITFORIT_TIMEOUT_PATH=$(realpath $WAITFORIT_TIMEOUT_PATH 2>/dev/null || readlink -f $WAITFORIT_TIMEOUT_PATH)
+
+WAITFORIT_BUSYTIMEFLAG=""
+if [[ $WAITFORIT_TIMEOUT_PATH =~ "busybox" ]]; then
+ WAITFORIT_ISBUSY=1
+ # Check if busybox timeout uses -t flag
+ # (recent Alpine versions don't support -t anymore)
+ if timeout &>/dev/stdout | grep -q -e '-t '; then
+ WAITFORIT_BUSYTIMEFLAG="-t"
+ fi
+else
+ WAITFORIT_ISBUSY=0
+fi
+
+if [[ $WAITFORIT_CHILD -gt 0 ]]; then
+ wait_for
+ WAITFORIT_RESULT=$?
+ exit $WAITFORIT_RESULT
+else
+ if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then
+ wait_for_wrapper
+ WAITFORIT_RESULT=$?
+ else
+ wait_for
+ WAITFORIT_RESULT=$?
+ fi
+fi
+
+if [[ $WAITFORIT_CLI != "" ]]; then
+ if [[ $WAITFORIT_RESULT -ne 0 && $WAITFORIT_STRICT -eq 1 ]]; then
+ echoerr "$WAITFORIT_cmdname: strict mode, refusing to execute subprocess"
+ exit $WAITFORIT_RESULT
+ fi
+ exec "${WAITFORIT_CLI[@]}"
+else
+ exit $WAITFORIT_RESULT
+fi
\ No newline at end of file
diff --git a/tools/CoreEx.Template/content/Company.AppName.Functions/.vscode/extensions.json b/tools/CoreEx.Template/content/Company.AppName.Functions/.vscode/extensions.json
new file mode 100644
index 00000000..dde673dc
--- /dev/null
+++ b/tools/CoreEx.Template/content/Company.AppName.Functions/.vscode/extensions.json
@@ -0,0 +1,5 @@
+{
+ "recommendations": [
+ "ms-azuretools.vscode-azurefunctions"
+ ]
+}
\ No newline at end of file
diff --git a/tools/CoreEx.Template/content/Company.AppName.Functions/Company.AppName.Functions.csproj b/tools/CoreEx.Template/content/Company.AppName.Functions/Company.AppName.Functions.csproj
new file mode 100644
index 00000000..a7451df5
--- /dev/null
+++ b/tools/CoreEx.Template/content/Company.AppName.Functions/Company.AppName.Functions.csproj
@@ -0,0 +1,31 @@
+
+
+ net6.0
+ v4
+ <_FunctionsSkipCleanOutput>true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ PreserveNewest
+
+
+ PreserveNewest
+ Never
+
+
+
\ No newline at end of file
diff --git a/tools/CoreEx.Template/content/Company.AppName.Functions/Dockerfile b/tools/CoreEx.Template/content/Company.AppName.Functions/Dockerfile
new file mode 100644
index 00000000..d7613da2
--- /dev/null
+++ b/tools/CoreEx.Template/content/Company.AppName.Functions/Dockerfile
@@ -0,0 +1,44 @@
+FROM mcr.microsoft.com/dotnet/sdk:6.0 AS installer-env
+# set to true for local runs - switches functions auth to anoymous
+ARG LOCAL
+
+# Build requires 3.1 SDK
+COPY --from=mcr.microsoft.com/dotnet/core/sdk:3.1 /usr/share/dotnet /usr/share/dotnet
+
+WORKDIR /src
+# It's important to keep lines from here down to "COPY . ." identical in all Dockerfiles
+# to take advantage of Docker's build cache, to speed up local container builds
+COPY "Company.AppName.sln" "Company.AppName.sln"
+
+COPY "Company.AppName.Api/Company.AppName.Api.csproj" "Company.AppName.Api/Company.AppName.Api.csproj"
+COPY "Company.AppName.Business/Company.AppName.Business.csproj" "Company.AppName.Business/Company.AppName.Business.csproj"
+COPY "Company.AppName.Database/Company.AppName.Database.csproj" "Company.AppName.Database/Company.AppName.Database.csproj"
+COPY "Company.AppName.Functions/Company.AppName.Functions.csproj" "Company.AppName.Functions/Company.AppName.Functions.csproj"
+COPY "Company.AppName.UnitTest/Company.AppName.UnitTest.csproj" "Company.AppName.UnitTest/Company.AppName.UnitTest.csproj"
+COPY "Company.AppName.Infra/Company.AppName.Infra.csproj" "Company.AppName.Infra/Company.AppName.Infra.csproj"
+COPY "Company.AppName.Infra.Tests/Company.AppName.Infra.Tests.csproj" "Company.AppName.Infra.Tests/Company.AppName.Infra.Tests.csproj"
+
+RUN dotnet restore "Company.AppName.sln"
+
+COPY . .
+
+WORKDIR /src/Company.AppName.Functions
+
+RUN mkdir -p /home/site/wwwroot && \
+ dotnet publish *.csproj --no-restore -c Debug --output /home/site/wwwroot && \
+ echo LOCAL is "$LOCAL" && \
+ echo $(if [ "$LOCAL" = "true" ] ; then find / \( -type f -name .git -prune \) -o -type f -name "function.json" -print0 | xargs -0 sed -i 's/authLevel\": \"function/authLevel\": \"anonymous/g' ; fi)
+
+# To enable ssh & remote debugging on app service change the base image to the one below
+# FROM mcr.microsoft.com/azure-functions/dotnet:4-appservice
+# FROM mcr.microsoft.com/azure-functions/dotnet:4
+FROM mcr.microsoft.com/azure-functions/dotnet:4-appservice
+ARG LOCAL
+
+ENV AzureWebJobsScriptRoot=/home/site/wwwroot
+ENV AzureFunctionsJobHost__Logging__Console__IsEnabled=true
+ENV AzureFunctionsJobHost__Logging__LogLevel__CoreEx=Debug
+ENV AzureFunctionsJobHost__Logging__LogToConsole=true
+ENV AzureFunctionsJobHost__Logging__LogToConsoleColor=true
+
+COPY --from=installer-env ["/home/site/wwwroot", "/home/site/wwwroot"]
\ No newline at end of file
diff --git a/tools/CoreEx.Template/content/Company.AppName.Functions/Functions.http b/tools/CoreEx.Template/content/Company.AppName.Functions/Functions.http
new file mode 100644
index 00000000..6d758cea
--- /dev/null
+++ b/tools/CoreEx.Template/content/Company.AppName.Functions/Functions.http
@@ -0,0 +1,88 @@
+@port = 7071
+@hostname = localhost
+@scheme = http
+@host = {{scheme}}://{{hostname}}:{{port}}
+
+
+### Health endpoint
+GET {{host}}/api/health
+
+# Swagger
+
+### Get openapi json
+GET {{host}}/api/openapi/1.0
+
+### Get swagger json
+GET {{host}}/api/swagger.json
+
+### Get swagger UI
+GET {{host}}/api/swagger/ui
+
+# Employee CRUD operations
+# todo: add payloads
+### Get all Employees
+GET {{host}}/api/employees
+x-correlation-id: 123-my-correlation-id-getall
+
+### Get Employee
+GET {{host}}/api/employees/3fa85f64-5717-4562-b3fc-2c963f66afa6
+x-correlation-id: 123-my-correlation-id-get
+
+### Create Employee
+POST {{host}}/api/employees
+x-correlation-id: 123-my-correlation-id-create
+Content-Type: application/json
+
+{
+ "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
+ "email": "alice@alice.com",
+ "firstName": "Alice",
+ "lastName": "Doe",
+ "gender": "F",
+ "birthday": "2000-09-28T15:36:55.730Z",
+ "startDate": "2022-09-01T15:36:55.730Z",
+ "phoneNo": "765-123-5687"
+}
+
+### Update Employee
+# todo: this fails with The database operation was expected to affect 1 row(s), but actually affected 0 row(s); data may have been modified or deleted since entities were loaded
+PUT {{host}}/api/employees/3fa85f64-5717-4562-b3fc-2c963f66afa6
+x-correlation-id: 123-my-correlation-id-update
+Content-Type: application/json
+If-Match: AAAAAAAACBg=
+
+{
+ "email": "alice@alice.com",
+ "firstName": "Alice",
+ "lastName": "Doe",
+ "gender": "F",
+ "birthday": "2000-09-28T15:36:55.730Z",
+ "startDate": "2022-09-01T15:36:55.730Z",
+ "phoneNo": "765-123-0000"
+}
+
+### Patch Employee
+PATCH {{host}}/api/employees/3fa85f64-5717-4562-b3fc-2c963f66afa6
+x-correlation-id: 123-my-correlation-id
+Content-Type: application/json
+If-Match: AAAAAAAACBg=
+
+{
+ "phoneNo": "765-123-0000"
+}
+
+### Delete Employee
+DELETE {{host}}/api/employees/3fa85f64-5717-4562-b3fc-2c963f66afa6
+x-correlation-id: 123-my-correlation-id
+
+
+### Employee Verification scenario with service bus
+POST {{host}}/api/employee/verify
+Content-Type: application/json
+x-correlation-id: 123-my-correlation-id-verify
+
+{
+ "name": "John",
+ "age": 27,
+ "gender": "male"
+}
diff --git a/tools/CoreEx.Template/content/Company.AppName.Functions/Functions/EmployeeFunction.cs b/tools/CoreEx.Template/content/Company.AppName.Functions/Functions/EmployeeFunction.cs
new file mode 100644
index 00000000..d14c6e45
--- /dev/null
+++ b/tools/CoreEx.Template/content/Company.AppName.Functions/Functions/EmployeeFunction.cs
@@ -0,0 +1,73 @@
+using CoreEx.Validation;
+using CoreEx.WebApis;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Azure.WebJobs;
+using Microsoft.Azure.WebJobs.Extensions.Http;
+using Microsoft.Azure.WebJobs.Extensions.OpenApi.Core.Attributes;
+using Microsoft.Azure.WebJobs.Extensions.OpenApi.Core.Enums;
+using Microsoft.OpenApi.Models;
+using Company.AppName.Business.Models;
+using Company.AppName.Business.Services;
+using System;
+using System.Collections.Generic;
+using System.Net;
+using System.Net.Mime;
+using System.Threading.Tasks;
+
+namespace Company.AppName.Functions.Functions;
+
+public class EmployeeFunction
+{
+ private readonly WebApi _webApi;
+ private readonly EmployeeService _service;
+ private readonly IValidator _validator;
+
+ public EmployeeFunction(WebApi webApi, EmployeeService service, IValidator validator)
+ {
+ _webApi = webApi;
+ _service = service;
+ _validator = validator;
+ }
+
+ [FunctionName("Get")]
+ [OpenApiOperation(operationId: "Get", tags: new[] { "employee" })]
+ [OpenApiParameter(name: "id", Description = "The employee id", Required = true, In = ParameterLocation.Path, Type = typeof(Guid))]
+ [OpenApiSecurity("function_key", SecuritySchemeType.ApiKey, Name = "code", In = OpenApiSecurityLocationType.Query)]
+ [OpenApiResponseWithBody(statusCode: HttpStatusCode.OK, contentType: MediaTypeNames.Application.Json, bodyType: typeof(Employee), Description = "Employee record")]
+ [OpenApiResponseWithoutBody(statusCode: HttpStatusCode.NotFound, Description = "Not found")]
+ public Task GetAsync([HttpTrigger(AuthorizationLevel.Function, "get", Route = "employees/{id}")] HttpRequest request, Guid id)
+ => _webApi.GetAsync(request, _ => _service.GetEmployeeAsync(id));
+
+ [FunctionName("GetAll")]
+ [OpenApiOperation(operationId: "GetAll", tags: new[] { "employee" })]
+ [OpenApiSecurity("function_key", SecuritySchemeType.ApiKey, Name = "code", In = OpenApiSecurityLocationType.Query)]
+ [OpenApiResponseWithBody(statusCode: HttpStatusCode.OK, contentType: MediaTypeNames.Application.Json, bodyType: typeof(List), Description = "Employee records")]
+ public Task GetAllAsync([HttpTrigger(AuthorizationLevel.Function, "get", Route = "employees")] HttpRequest request)
+ => _webApi.GetAsync(request, p => _service.GetAllAsync(p.RequestOptions.Paging));
+
+ [FunctionName("Create")]
+ [OpenApiOperation(operationId: "Create", tags: new[] { "employee" })]
+ [OpenApiSecurity("function_key", SecuritySchemeType.ApiKey, Name = "code", In = OpenApiSecurityLocationType.Query)]
+ [OpenApiResponseWithoutBody(statusCode: HttpStatusCode.Created, Description = "Created employee record")]
+ public Task CreateAsync([HttpTrigger(AuthorizationLevel.Function, "post", Route = "employees")] HttpRequest request)
+ => _webApi.PostAsync(request, p => _service.AddEmployeeAsync(p.Value!),
+ statusCode: HttpStatusCode.Created, validator: _validator, locationUri: e => new Uri($"employees/{e.Id}", UriKind.RelativeOrAbsolute));
+
+ [FunctionName("Update")]
+ [OpenApiOperation(operationId: "Update", tags: new[] { "employee" })]
+ [OpenApiParameter(name: "id", Description = "The employee id", Required = true, In = ParameterLocation.Path, Type = typeof(Guid))]
+ [OpenApiSecurity("function_key", SecuritySchemeType.ApiKey, Name = "code", In = OpenApiSecurityLocationType.Query)]
+ [OpenApiResponseWithBody(statusCode: HttpStatusCode.OK, contentType: MediaTypeNames.Application.Json, bodyType: typeof(Employee), Description = "Employee record")]
+ [OpenApiResponseWithoutBody(statusCode: HttpStatusCode.NotFound, Description = "Not found")]
+ public Task UpdateAsync([HttpTrigger(AuthorizationLevel.Function, "put", Route = "employees/{id}")] HttpRequest request, Guid id)
+ => _webApi.PutAsync(request, p => _service.UpdateEmployeeAsync(p.Value!, id), validator: _validator);
+
+ [FunctionName("Patch")]
+ public Task PatchAsync([HttpTrigger(AuthorizationLevel.Function, "patch", Route = "employees/{id}")] HttpRequest request, Guid id)
+ => _webApi.PatchAsync(request, get: _ => _service.GetEmployeeAsync(id), put: p => _service.UpdateEmployeeAsync(p.Value!, id), validator: _validator);
+
+ [FunctionName("Delete")]
+ public Task DeleteAsync([HttpTrigger(AuthorizationLevel.Function, "delete", Route = "employees/{id}")] HttpRequest request, Guid id)
+ => _webApi.DeleteAsync(request, _ => _service.DeleteEmployeeAsync(id));
+}
diff --git a/tools/CoreEx.Template/content/Company.AppName.Functions/Functions/HttpHealthFunction.cs b/tools/CoreEx.Template/content/Company.AppName.Functions/Functions/HttpHealthFunction.cs
new file mode 100644
index 00000000..386c54e7
--- /dev/null
+++ b/tools/CoreEx.Template/content/Company.AppName.Functions/Functions/HttpHealthFunction.cs
@@ -0,0 +1,31 @@
+using CoreEx.HealthChecks;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Azure.WebJobs;
+using Microsoft.Azure.WebJobs.Extensions.Http;
+using Microsoft.Azure.WebJobs.Extensions.OpenApi.Core.Attributes;
+using Microsoft.Azure.WebJobs.Extensions.OpenApi.Core.Enums;
+using Microsoft.Extensions.Diagnostics.HealthChecks;
+using Microsoft.OpenApi.Models;
+using System.Net;
+using System.Net.Mime;
+using System.Threading.Tasks;
+
+namespace Company.AppName.Functions;
+
+public class HttpHealthFunction
+{
+ private readonly HealthService _health;
+
+ public HttpHealthFunction(HealthService health)
+ {
+ _health = health;
+ }
+
+ [FunctionName("HealthInfo")]
+ [OpenApiOperation(operationId: "Run", tags: new[] { "health" })]
+ [OpenApiSecurity("function_key", SecuritySchemeType.ApiKey, Name = "code", In = OpenApiSecurityLocationType.Query)]
+ [OpenApiResponseWithBody(statusCode: HttpStatusCode.OK, contentType: MediaTypeNames.Application.Json, bodyType: typeof(HealthReportEntry), Description = "The OK response")]
+ public async Task RunAsync([HttpTrigger(AuthorizationLevel.Function, "get", Route = "health")] HttpRequest req)
+ => await _health.RunAsync().ConfigureAwait(false);
+}
\ No newline at end of file
diff --git a/tools/CoreEx.Template/content/Company.AppName.Functions/Functions/HttpTriggerQueueVerificationFunction.cs b/tools/CoreEx.Template/content/Company.AppName.Functions/Functions/HttpTriggerQueueVerificationFunction.cs
new file mode 100644
index 00000000..e73d62ee
--- /dev/null
+++ b/tools/CoreEx.Template/content/Company.AppName.Functions/Functions/HttpTriggerQueueVerificationFunction.cs
@@ -0,0 +1,37 @@
+using System.Net;
+using System.Net.Mime;
+using System.Threading.Tasks;
+using CoreEx.FluentValidation;
+using CoreEx.WebApis;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Azure.WebJobs;
+using Microsoft.Azure.WebJobs.Extensions.Http;
+using Microsoft.Azure.WebJobs.Extensions.OpenApi.Core.Attributes;
+using Microsoft.Azure.WebJobs.Extensions.OpenApi.Core.Enums;
+using Microsoft.OpenApi.Models;
+using Company.AppName.Business;
+using Company.AppName.Business.External.Contracts;
+using Company.AppName.Business.Validators;
+
+namespace Company.AppName.Functions;
+
+public class HttpTriggerQueueVerificationFunction
+{
+ private readonly WebApiPublisher _webApiPublisher;
+ private readonly AppNameSettings _settings;
+
+ public HttpTriggerQueueVerificationFunction(WebApiPublisher webApiPublisher, AppNameSettings settings)
+ {
+ _webApiPublisher = webApiPublisher;
+ _settings = settings;
+ }
+
+ [FunctionName(nameof(HttpTriggerQueueVerificationFunction))]
+ [OpenApiOperation(operationId: "Run", tags: new[] { "employee" })]
+ [OpenApiSecurity("function_key", SecuritySchemeType.ApiKey, Name = "code", In = OpenApiSecurityLocationType.Query)]
+ [OpenApiRequestBody(MediaTypeNames.Application.Json, typeof(EmployeeVerificationRequest), Description = "The **EmployeeVerification** payload")]
+ [OpenApiResponseWithBody(statusCode: HttpStatusCode.Accepted, contentType: MediaTypeNames.Text.Plain, bodyType: typeof(string), Description = "The OK response")]
+ public Task RunAsync([HttpTrigger(AuthorizationLevel.Function, "post", Route = "employee/verify")] HttpRequest request)
+ => _webApiPublisher.PublishAsync(request, _settings.VerificationQueueName, validator: new EmployeeVerificationValidator().Wrap());
+}
\ No newline at end of file
diff --git a/tools/CoreEx.Template/content/Company.AppName.Functions/Functions/ServiceBusExecuteVerificationFunction.cs b/tools/CoreEx.Template/content/Company.AppName.Functions/Functions/ServiceBusExecuteVerificationFunction.cs
new file mode 100644
index 00000000..fd5bed76
--- /dev/null
+++ b/tools/CoreEx.Template/content/Company.AppName.Functions/Functions/ServiceBusExecuteVerificationFunction.cs
@@ -0,0 +1,28 @@
+using System.Threading.Tasks;
+using Azure.Messaging.ServiceBus;
+using FluentValidation;
+using CoreEx.Azure.ServiceBus;
+using Microsoft.Azure.WebJobs;
+using Microsoft.Azure.WebJobs.ServiceBus;
+using Company.AppName.Business;
+using Company.AppName.Business.Services;
+using Company.AppName.Business.Validators;
+
+namespace Company.AppName.Functions;
+
+public class ServiceBusExecuteVerificationFunction
+{
+ private readonly ServiceBusSubscriber _subscriber;
+ private readonly VerificationService _service;
+
+ public ServiceBusExecuteVerificationFunction(ServiceBusSubscriber subscriber, VerificationService service)
+ {
+ _subscriber = subscriber;
+ _service = service;
+ }
+
+ [FunctionName(nameof(ServiceBusExecuteVerificationFunction))]
+ [ExponentialBackoffRetry(3, "00:02:00", "00:30:00")]
+ public Task RunAsync([ServiceBusTrigger("%" + nameof(AppNameSettings.VerificationQueueName) + "%", Connection = nameof(AppNameSettings.ServiceBusConnection))] ServiceBusReceivedMessage message, ServiceBusMessageActions messageActions)
+ => _subscriber.ReceiveAsync(message, messageActions, ed => _service.VerifyAndPublish(ed.Value), validator: new EmployeeVerificationValidator().Wrap());
+}
\ No newline at end of file
diff --git a/tools/CoreEx.Template/content/Company.AppName.Functions/MyHrApiConfigurationOptions.cs b/tools/CoreEx.Template/content/Company.AppName.Functions/MyHrApiConfigurationOptions.cs
new file mode 100644
index 00000000..a5260d66
--- /dev/null
+++ b/tools/CoreEx.Template/content/Company.AppName.Functions/MyHrApiConfigurationOptions.cs
@@ -0,0 +1,27 @@
+using System;
+using Microsoft.Azure.WebJobs.Extensions.OpenApi.Core.Configurations;
+using Microsoft.Azure.WebJobs.Extensions.OpenApi.Core.Enums;
+using Microsoft.OpenApi.Models;
+
+namespace Company.AppName.Functions;
+
+/// Configuration options for .
+public class MyOpenApiConfigurationOptions : DefaultOpenApiConfigurationOptions
+{
+ public override OpenApiInfo Info { get; set; } = new OpenApiInfo()
+ {
+ Version = "1.0.1",
+ Title = "Company AppName API",
+ Description = "A serverless Azure Function which demonstrates the use of CoreEx for Company AppName - to be updated",
+ TermsOfService = new Uri("https://github.com/Avanade/CoreEx"),
+
+ License = new OpenApiLicense()
+ {
+ Name = "MIT",
+ Url = new Uri("http://opensource.org/licenses/MIT"),
+ }
+ };
+
+ public override OpenApiVersionType OpenApiVersion { get; set; } = OpenApiVersionType.V3;
+
+}
\ No newline at end of file
diff --git a/tools/CoreEx.Template/content/Company.AppName.Functions/README.md b/tools/CoreEx.Template/content/Company.AppName.Functions/README.md
new file mode 100644
index 00000000..81bc1ee7
--- /dev/null
+++ b/tools/CoreEx.Template/content/Company.AppName.Functions/README.md
@@ -0,0 +1,7 @@
+# About
+
+Functions project
+
+## Configuration
+
+Update configuration in `local.settings.json` to update service bus namespace and enable SB triggered functions
diff --git a/tools/CoreEx.Template/content/Company.AppName.Functions/Startup.cs b/tools/CoreEx.Template/content/Company.AppName.Functions/Startup.cs
new file mode 100644
index 00000000..359c3d09
--- /dev/null
+++ b/tools/CoreEx.Template/content/Company.AppName.Functions/Startup.cs
@@ -0,0 +1,82 @@
+using System.Threading.Tasks;
+using CoreEx;
+using CoreEx.Azure.HealthChecks;
+using CoreEx.Database;
+using CoreEx.DataBase.HealthChecks;
+using CoreEx.HealthChecks;
+using CoreEx.RefData;
+using Microsoft.Azure.Functions.Extensions.DependencyInjection;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Caching.Memory;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Diagnostics.HealthChecks;
+using Company.AppName.Business;
+using Company.AppName.Business.Data;
+using Company.AppName.Business.External;
+using Company.AppName.Business.Services;
+
+[assembly: FunctionsStartup(typeof(Company.AppName.Functions.Startup))]
+
+namespace Company.AppName.Functions;
+
+public class Startup : FunctionsStartup
+{
+ public override void ConfigureAppConfiguration(IFunctionsConfigurationBuilder builder)
+ {
+ }
+
+ public override void Configure(IFunctionsHostBuilder builder)
+ {
+ try
+ {
+ // Register the core services.
+ builder.Services
+ .AddSettings()
+ .AddReferenceDataOrchestrator(sp => new ReferenceDataOrchestrator(sp, new MemoryCache(new MemoryCacheOptions())).Register())
+ .AddExecutionContext()
+ .AddJsonSerializer()
+ .AddEventDataSerializer()
+ .AddEventDataFormatter()
+ .AddEventPublisher()
+ .AddAzureServiceBusSender()
+ .AddWebApi(c => c.UnhandledExceptionAsync = (ex, _) => Task.FromResult(ex is DbUpdateConcurrencyException efex ? new ConcurrencyException().ToResult() : null))
+ .AddJsonMergePatch()
+ .AddWebApiPublisher()
+ .AddAzureServiceBusSubscriber()
+ .AddAzureServiceBusClient(connectionName: nameof(AppNameSettings.ServiceBusConnection));
+
+ // Register the health checks.
+ builder.Services
+ .AddScoped()
+ .AddHealthChecks()
+ .AddTypeActivatedCheck>("Genderize API")
+ .AddTypeActivatedCheck>("Agify API")
+ .AddTypeActivatedCheck>("Nationalize API")
+ .AddTypeActivatedCheck("Health check for service bus verification queue", HealthStatus.Unhealthy, nameof(AppNameSettings.ServiceBusConnection), nameof(AppNameSettings.VerificationQueueName))
+ .AddTypeActivatedCheck("SQL Server", HealthStatus.Unhealthy, tags: default, timeout: System.TimeSpan.FromSeconds(15), nameof(AppNameSettings.ConnectionStrings__Database));
+
+ // Register the business services.
+ builder.Services
+ .AddScoped()
+ .AddScoped()
+ .AddScoped()
+ .AddFluentValidators();
+
+ // Register the typed backend http clients.
+ builder.Services.AddTypedHttpClient("Agify");
+ builder.Services.AddTypedHttpClient("Genderize");
+ builder.Services.AddTypedHttpClient("Nationalize");
+
+ // Database
+ builder.Services.AddScoped();
+ builder.Services.AddDatabase(sp => new AppNameDb(sp.GetRequiredService()));
+ builder.Services.AddDbContext((sp, o) => o.UseSqlServer(sp.GetRequiredService().GetConnection()));
+ }
+ catch (System.Exception ex)
+ {
+ // try catch block for running the function in docker container, without it, it may fail silently.
+ System.Console.Error.WriteLine(ex);
+ throw;
+ }
+ }
+}
\ No newline at end of file
diff --git a/tools/CoreEx.Template/content/Company.AppName.Functions/host.json b/tools/CoreEx.Template/content/Company.AppName.Functions/host.json
new file mode 100644
index 00000000..3a74677c
--- /dev/null
+++ b/tools/CoreEx.Template/content/Company.AppName.Functions/host.json
@@ -0,0 +1,14 @@
+{
+ "version": "2.0",
+ "logging": {
+ "applicationInsights": {
+ "samplingSettings": {
+ "isEnabled": true,
+ "excludedTypes": "Request"
+ }
+ },
+ "logLevel": {
+ "default": "Warning"
+ }
+ }
+}
\ No newline at end of file
diff --git a/tools/CoreEx.Template/content/Company.AppName.Functions/local.settings.json b/tools/CoreEx.Template/content/Company.AppName.Functions/local.settings.json
new file mode 100644
index 00000000..080ef750
--- /dev/null
+++ b/tools/CoreEx.Template/content/Company.AppName.Functions/local.settings.json
@@ -0,0 +1,25 @@
+{
+ "IsEncrypted": false,
+ "Values": {
+ "AzureWebJobsStorage": "UseDevelopmentStorage=true",
+ "FUNCTIONS_WORKER_RUNTIME": "dotnet",
+
+ "AgifyApiEndpointUri": "https://api.agify.io",
+ "NationalizeApiClientApiEndpointUri": "https://api.nationalize.io",
+ "GenderizeApiClientApiEndpointUri": "https://api.genderize.io",
+
+ "VerificationQueueName": "pendingVerifications",
+ "VerificationResultsQueueName": "verificationResults",
+
+ "ServiceBusConnection__fullyQualifiedNamespace": "coreex.servicebus.windows.net",
+ "AzureWebJobs.ServiceBusExecuteVerificationFunction.Disabled": true, // disable when service bus is not available
+
+ "HttpLogContent": "true",
+ "AzureFunctionsJobHost__logging__logLevel__CoreEx": "Debug",
+ "AzureFunctionsJobHost__logging__logToConsole": "true",
+ "AzureFunctionsJobHost__logging__logToConsoleColor": "true",
+ "AzureFunctionsJobHost__logging__console__isEnabled": "true",
+
+ "MassPublishQueueName": "mass-publish"
+ }
+}
\ No newline at end of file
diff --git a/samples/My.Hr/My.Hr.Infra.Tests/My.Hr.Infra.Tests.csproj b/tools/CoreEx.Template/content/Company.AppName.Infra.Tests/Company.AppName.Infra.Tests.csproj
similarity index 57%
rename from samples/My.Hr/My.Hr.Infra.Tests/My.Hr.Infra.Tests.csproj
rename to tools/CoreEx.Template/content/Company.AppName.Infra.Tests/Company.AppName.Infra.Tests.csproj
index 04c6ce46..e8f1ea7a 100644
--- a/samples/My.Hr/My.Hr.Infra.Tests/My.Hr.Infra.Tests.csproj
+++ b/tools/CoreEx.Template/content/Company.AppName.Infra.Tests/Company.AppName.Infra.Tests.csproj
@@ -15,12 +15,19 @@
-
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
-
+
diff --git a/samples/My.Hr/My.Hr.Infra.Tests/CoreExStackTests.cs b/tools/CoreEx.Template/content/Company.AppName.Infra.Tests/CompanyAppNameStackTests.cs
similarity index 94%
rename from samples/My.Hr/My.Hr.Infra.Tests/CoreExStackTests.cs
rename to tools/CoreEx.Template/content/Company.AppName.Infra.Tests/CompanyAppNameStackTests.cs
index c6a4ac2e..e021755e 100644
--- a/samples/My.Hr/My.Hr.Infra.Tests/CoreExStackTests.cs
+++ b/tools/CoreEx.Template/content/Company.AppName.Infra.Tests/CompanyAppNameStackTests.cs
@@ -2,9 +2,9 @@
using Pulumi.AzureNative.Resources;
-namespace My.Hr.Infra.Tests;
+namespace Company.AppName.Infra.Tests;
-public class CoreExStackTests
+public class CompanyAppNameStackTests
{
[Test]
public async Task ResourceGroupHasNameTag()
diff --git a/samples/My.Hr/My.Hr.Infra.Tests/Services/AzureApiClientTests.cs b/tools/CoreEx.Template/content/Company.AppName.Infra.Tests/Services/AzureApiClientTests.cs
similarity index 87%
rename from samples/My.Hr/My.Hr.Infra.Tests/Services/AzureApiClientTests.cs
rename to tools/CoreEx.Template/content/Company.AppName.Infra.Tests/Services/AzureApiClientTests.cs
index 296b9d60..f1d2e507 100644
--- a/samples/My.Hr/My.Hr.Infra.Tests/Services/AzureApiClientTests.cs
+++ b/tools/CoreEx.Template/content/Company.AppName.Infra.Tests/Services/AzureApiClientTests.cs
@@ -1,9 +1,9 @@
using System.Net;
-using My.Hr.Infra.Services;
+using Company.AppName.Infra.Services;
using UnitTestEx.NUnit;
-namespace My.Hr.Infra.Tests.Services;
+namespace Company.AppName.Infra.Tests.Services;
public class AzureApiClientTests
{
diff --git a/samples/My.Hr/My.Hr.Infra.Tests/Services/AzureApiServiceTests.cs b/tools/CoreEx.Template/content/Company.AppName.Infra.Tests/Services/AzureApiServiceTests.cs
similarity index 98%
rename from samples/My.Hr/My.Hr.Infra.Tests/Services/AzureApiServiceTests.cs
rename to tools/CoreEx.Template/content/Company.AppName.Infra.Tests/Services/AzureApiServiceTests.cs
index fe734bb7..c669517d 100644
--- a/samples/My.Hr/My.Hr.Infra.Tests/Services/AzureApiServiceTests.cs
+++ b/tools/CoreEx.Template/content/Company.AppName.Infra.Tests/Services/AzureApiServiceTests.cs
@@ -1,9 +1,9 @@
using System.Net;
-using My.Hr.Infra.Services;
+using Company.AppName.Infra.Services;
using UnitTestEx.NUnit;
-namespace My.Hr.Infra.Tests.Services;
+namespace Company.AppName.Infra.Tests.Services;
public class AzureApiServiceTests
{
diff --git a/samples/My.Hr/My.Hr.Infra.Tests/Testing.cs b/tools/CoreEx.Template/content/Company.AppName.Infra.Tests/Testing.cs
similarity index 83%
rename from samples/My.Hr/My.Hr.Infra.Tests/Testing.cs
rename to tools/CoreEx.Template/content/Company.AppName.Infra.Tests/Testing.cs
index af033ad3..bd3fe58d 100644
--- a/samples/My.Hr/My.Hr.Infra.Tests/Testing.cs
+++ b/tools/CoreEx.Template/content/Company.AppName.Infra.Tests/Testing.cs
@@ -1,9 +1,9 @@
using System.Collections.Immutable;
using System.Text.Json;
-using My.Hr.Infra.Services;
+using Company.AppName.Infra.Services;
using UnitTestEx.NUnit;
-namespace My.Hr.Infra.Tests;
+namespace Company.AppName.Infra.Tests;
public static class Testing
{
@@ -17,16 +17,16 @@ public static class Testing
var mcf = MockHttpClientFactory.Create();
var mc = mcf.CreateClient("azure");
- mc.Request(HttpMethod.Post, $"https://management.azure.com/subscriptions/{SubscriptionId}/resourceGroups/coreEx-{StackName}/providers/Microsoft.Web/sites/funApp/host/default/listkeys?api-version=2022-03-01")
+ mc.Request(HttpMethod.Post, $"https://management.azure.com/subscriptions/{SubscriptionId}/resourceGroups/Company-AppName-{StackName}/providers/Microsoft.Web/sites/funApp/host/default/listkeys?api-version=2022-03-01")
.Respond.WithJson(new AzureApiService.HostKeys { FunctionKeys = new AzureApiService.FunctionKeysValue { Key = "mocked-key" } });
mc.Request(HttpMethod.Get, "https://api.ipify.org")
.Respond.With(new StringContent("215.45.1.567"));
- mc.Request(HttpMethod.Post, $"https://management.azure.com/subscriptions/{SubscriptionId}/resourceGroups/coreEx-{StackName}/providers/Microsoft.Web/sites/funApp/syncfunctiontriggers?api-version=2016-08-01")
+ mc.Request(HttpMethod.Post, $"https://management.azure.com/subscriptions/{SubscriptionId}/resourceGroups/Company-AppName-{StackName}/providers/Microsoft.Web/sites/funApp/syncfunctiontriggers?api-version=2016-08-01")
.Respond.With(statusCode: System.Net.HttpStatusCode.NoContent);
- var (resources, outputs) = await RunAsync(() => CoreExStack.ExecuteStackAsync(dbOperationsMock.Object, mcf.GetHttpClient("azure")!));
+ var (resources, outputs) = await RunAsync(() => CompanyAppNameStack.ExecuteStackAsync(dbOperationsMock.Object, mcf.GetHttpClient("azure")!));
return (resources, outputs, dbOperationsMock);
}
@@ -37,7 +37,9 @@ public static class Testing
{"unittest:sqlAdAdmin", "sqlAdAdmin"},
{"unittest:sqlAdPassword", "sqlAdPassword"},
{"unittest:isAppsDeploymentEnabled", "true"},
- {"unittest:isDBSchemaDeploymentEnabled", "true"}
+ {"unittest:isDBSchemaDeploymentEnabled", "true"},
+ {"unittest:developerEmails", "dev1@somedomain.com,dev2@somedomain.com"}
+
};
Environment.SetEnvironmentVariable("PULUMI_CONFIG", JsonSerializer.Serialize(config));
@@ -77,7 +79,6 @@ public Task
-
+
diff --git a/samples/My.Hr/My.Hr.Infra/CoreExStack.cs b/tools/CoreEx.Template/content/Company.AppName.Infra/CompanyAppNameStack.cs
similarity index 58%
rename from samples/My.Hr/My.Hr.Infra/CoreExStack.cs
rename to tools/CoreEx.Template/content/Company.AppName.Infra/CompanyAppNameStack.cs
index 63a133f4..063765d7 100644
--- a/samples/My.Hr/My.Hr.Infra/CoreExStack.cs
+++ b/tools/CoreEx.Template/content/Company.AppName.Infra/CompanyAppNameStack.cs
@@ -1,33 +1,33 @@
using System.Collections.Generic;
using System.Net.Http;
using System.Threading.Tasks;
-using My.Hr.Infra.Services;
+using Company.AppName.Infra.Services;
using Pulumi;
using Pulumi.AzureNative.Resources;
using AD = Pulumi.AzureAD;
-namespace My.Hr.Infra;
+namespace Company.AppName.Infra;
-public static class CoreExStack
+public static class CompanyAppNameStack
{
public static async Task> ExecuteStackAsync(IDbOperations dbOperations, HttpClient client)
{
var config = await StackConfiguration.CreateConfiguration();
Log.Info("Configuration completed");
- var tags = new InputMap { { "App", "CoreEx" } };
+ var tags = new InputMap { { "App", "Company-AppName" } };
// Create Azure API client for direct HTTP calls
var azureApiClient = new AzureApiClient(client);
var azureApiService = new AzureApiService(azureApiClient);
// Create an Azure Resource Group
- var resourceGroup = new ResourceGroup($"coreEx-{Pulumi.Deployment.Instance.StackName}", new ResourceGroupArgs
+ var resourceGroup = new ResourceGroup($"Company-AppName-{Pulumi.Deployment.Instance.StackName}", new ResourceGroupArgs
{
Tags = tags
});
- var serviceBus = new Components.Messaging("coreExBus", new Components.Messaging.MessagingArgs
+ var serviceBus = new Components.Messaging("CompanyAppNameBus", new Components.Messaging.MessagingArgs
{
ResourceGroupName = resourceGroup.Name,
Tags = tags
@@ -72,6 +72,9 @@ public static class CoreExStack
Tags = tags
}, azureApiService);
+ // Developer group
+ var devSetup = new Components.DevSetup("devs", config.DeveloperEmails);
+
// Permissions for function app
storage.AddAccess(apps.FunctionPrincipalId, "functionApp");
serviceBus.AddAccess(apps.FunctionPrincipalId, "functionApp");
@@ -80,10 +83,17 @@ public static class CoreExStack
storage.AddAccess(apps.AppPrincipalId, "appService");
serviceBus.AddAccess(apps.AppPrincipalId, "appService");
+ // Permissions for dev group
+ storage.AddAccess(devSetup.DevelopersGroupId, "devGroup", principalType: "Group");
+ serviceBus.AddAccess(devSetup.DevelopersGroupId, "devGroup", principalType: "Group");
+
// allow app and function to query/use DB
sql.AddToSqlDatabaseAuthorizedGroup("functionGroupMember", apps.FunctionPrincipalId);
sql.AddToSqlDatabaseAuthorizedGroup("appGroupMember", apps.AppPrincipalId);
+ // allow dev team to query/use DB
+ sql.AddToSqlDatabaseAuthorizedGroup("devGroupMember", devSetup.DevelopersGroupId);
+
// allow app and function through SQL firewall
sql.AddFirewallRule(apps.FunctionOutboundIps, "appService");
sql.AddFirewallRule(apps.AppOutboundIps, "appService");
@@ -92,57 +102,12 @@ public static class CoreExStack
{
["SqlDatabaseConnectionString"] = sql.SqlDatabaseConnectionString,
["FunctionHealthUrl"] = apps.FunctionHealthUrl,
+ ["AppHealthUrl"] = apps.AppHealthUrl,
["FunctionSwaggerUrl"] = apps.FunctionSwaggerUrl,
["AppSwaggerUrl"] = apps.AppSwaggerUrl,
+ ["ResourceGroupName"] = resourceGroup.Name,
+ ["AppServiceName"] = apps.AppServiceName,
+ ["FunctionName"] = apps.FunctionName
};
}
-
- public class StackConfiguration
- {
- public Input? SqlAdAdminLogin { get; private set; }
- public Input? SqlAdAdminPassword { get; private set; }
- public bool IsAppsDeploymentEnabled { get; private set; }
- public bool IsDBSchemaDeploymentEnabled { get; private set; }
- public string PendingVerificationsQueue { get; private set; } = default!;
- public string VerificationResultsQueue { get; private set; } = default!;
- public string MassPublishQueue { get; private set; } = default!;
-
- private StackConfiguration() { }
-
- public static async Task CreateConfiguration()
- {
- // read stack config
- var config = new Config();
-
- // get some info from Azure AD
- var domainResult = await AD.GetDomains.InvokeAsync(new AD.GetDomainsArgs { OnlyDefault = true });
- var defaultUsername = $"sqlGlobalAdAdmin{Pulumi.Deployment.Instance.StackName}@{domainResult.Domains[0].DomainName}";
- var defaultPassword = new Pulumi.Random.RandomPassword("sqlAdPassword", new()
- {
- Length = 32,
- Upper = true,
- Number = true,
- Special = true,
- OverrideSpecial = "@",
- MinLower = 2,
- MinUpper = 2,
- MinSpecial = 2,
- MinNumeric = 2
- }).Result;
-
- Log.Info($"Default username is: {defaultUsername}");
-
- return new StackConfiguration
- {
- SqlAdAdminLogin = Extensions.GetConfigValue("sqlAdAdmin", defaultUsername),
- SqlAdAdminPassword = Extensions.GetConfigValue("sqlAdPassword", defaultPassword),
- IsAppsDeploymentEnabled = config.GetBoolean("isAppsDeploymentEnabled") ?? false,
- IsDBSchemaDeploymentEnabled = config.GetBoolean("isDBSchemaDeploymentEnabled") ?? false,
-
- PendingVerificationsQueue = config.Get("pendingVerificationsQueue") ?? "pendingVerifications",
- VerificationResultsQueue = config.Get("verificationResultsQueue") ?? "verificationResults",
- MassPublishQueue = config.Get("massPublishQueue") ?? "massPublish"
- };
- }
- }
}
\ No newline at end of file
diff --git a/samples/My.Hr/My.Hr.Infra/Components/Apps.cs b/tools/CoreEx.Template/content/Company.AppName.Infra/Components/Apps.cs
similarity index 93%
rename from samples/My.Hr/My.Hr.Infra/Components/Apps.cs
rename to tools/CoreEx.Template/content/Company.AppName.Infra/Components/Apps.cs
index 098e201d..e549d2e6 100644
--- a/samples/My.Hr/My.Hr.Infra/Components/Apps.cs
+++ b/tools/CoreEx.Template/content/Company.AppName.Infra/Components/Apps.cs
@@ -1,30 +1,33 @@
using System;
using System.Diagnostics;
-using System.Net.Http;
+using System.IO;
using System.Threading.Tasks;
-using My.Hr.Infra.Services;
+using Company.AppName.Infra.Services;
using Pulumi;
using Pulumi.AzureNative.Storage;
using Pulumi.AzureNative.Web;
using Pulumi.AzureNative.Web.Inputs;
using AzureNative = Pulumi.AzureNative;
-namespace My.Hr.Infra.Components;
+namespace Company.AppName.Infra.Components;
public class Apps : ComponentResource
{
private readonly FunctionArgs args;
public Output FunctionHealthUrl { get; } = default!;
+ public Output AppHealthUrl { get; } = default!;
public Output FunctionSwaggerUrl { get; } = default!;
public Output AppSwaggerUrl { get; } = default!;
public Output FunctionPrincipalId { get; } = default!;
public Output AppPrincipalId { get; } = default!;
public Output FunctionOutboundIps { get; } = default!;
public Output AppOutboundIps { get; } = default!;
+ public Output AppServiceName { get; } = default!;
+ public Output FunctionName { get; } = default!;
public Apps(string name, FunctionArgs args, AzureApiService azureApiService, ComponentResourceOptions? options = null)
- : base("coreexinfra:web:apps", name, options)
+ : base("Company:AppName:web:apps", name, options)
{
this.args = args;
@@ -35,8 +38,8 @@ public Apps(string name, FunctionArgs args, AzureApiService azureApiService, Com
{
await PublishApp();
- var appZipUrl = PrepareAppForDeployment("app", "../My.Hr.Api/bin/Release/net6.0/publish");
- var funZipUrl = PrepareAppForDeployment("function", "../My.Hr.Functions/bin/Release/net6.0/publish");
+ var appZipUrl = PrepareAppForDeployment("app", "../Company.AppName.Api/bin/Release/net6.0/publish");
+ var funZipUrl = PrepareAppForDeployment("function", "../Company.AppName.Functions/bin/Release/net6.0/publish");
return Output.Tuple(appZipUrl, funZipUrl);
}
@@ -228,8 +231,11 @@ public Apps(string name, FunctionArgs args, AzureApiService azureApiService, Com
azureApiService.SyncFunctionAppTriggers(args.ResourceGroupName, functionApp.Name);
FunctionHealthUrl = Output.Format($"https://{functionApp.DefaultHostName}/api/health?code={functionKey}");
+ AppHealthUrl = Output.Format($"https://{app.DefaultHostName}/api/health");
FunctionSwaggerUrl = Output.Format($"https://{functionApp.DefaultHostName}/api/swagger/ui?code={functionKey}");
AppSwaggerUrl = Output.Format($"https://{app.DefaultHostName}/swagger/index.html");
+ AppServiceName = app.Name;
+ FunctionName = functionApp.Name;
RegisterOutputs();
}
@@ -237,7 +243,11 @@ public Apps(string name, FunctionArgs args, AzureApiService azureApiService, Com
private static async Task PublishApp()
{
if (Deployment.Instance.IsDryRun)
+ {
+ Directory.CreateDirectory("../Company.AppName.Api/bin/Release/net6.0/publish");
+ Directory.CreateDirectory("../Company.AppName.Functions/bin/Release/net6.0/publish");
return;
+ }
Log.Info("Setting up deployments from zip for the app and function and executing [dotnet publish]");
diff --git a/tools/CoreEx.Template/content/Company.AppName.Infra/Components/DevSetup.cs b/tools/CoreEx.Template/content/Company.AppName.Infra/Components/DevSetup.cs
new file mode 100644
index 00000000..3853eb9e
--- /dev/null
+++ b/tools/CoreEx.Template/content/Company.AppName.Infra/Components/DevSetup.cs
@@ -0,0 +1,48 @@
+using System;
+using Pulumi;
+using AD = Pulumi.AzureAD;
+
+namespace Company.AppName.Infra.Components;
+
+public class DevSetup : ComponentResource
+{
+ public string DevelopersGroupName { get; } = $"Developers-Company-AppName-{Deployment.Instance.StackName}";
+
+ public Output DevelopersGroupId { get; private set; } = default!;
+
+ public DevSetup(string name, string emailsCommaSeparated, ComponentResourceOptions? options = null)
+ : base("Company:AppName:developer:setup", name, options)
+ {
+ // get current user
+ var current = Output.Create(AD.GetClientConfig.InvokeAsync());
+
+ var developersAuthorizedGroup = new AD.Group(DevelopersGroupName, new AD.GroupArgs
+ {
+ DisplayName = DevelopersGroupName,
+ SecurityEnabled = true,
+ Owners = new InputList { current.Apply(current => current.ObjectId) },
+ Members = new InputList { current.Apply(current => current.ObjectId) }
+ }, new CustomResourceOptions { Parent = this });
+
+ DevelopersGroupId = developersAuthorizedGroup.Id;
+
+ Log.Info("Provisioning access for developers: " + emailsCommaSeparated);
+ var emails = emailsCommaSeparated.Split(",", StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+
+ foreach (var email in emails)
+ {
+ var user = AD.GetUser.Invoke(new()
+ {
+ UserPrincipalName = email,
+ }, new InvokeOptions { Parent = this });
+
+ var groupMember = new AD.GroupMember($"developerGroupMember{Deployment.Instance.StackName}-{email}", new()
+ {
+ GroupObjectId = DevelopersGroupId,
+ MemberObjectId = user.Apply(usr => usr.Id),
+ }, new CustomResourceOptions { Parent = this });
+ }
+
+ RegisterOutputs();
+ }
+}
\ No newline at end of file
diff --git a/samples/My.Hr/My.Hr.Infra/Components/Diagnostics.cs b/tools/CoreEx.Template/content/Company.AppName.Infra/Components/Diagnostics.cs
similarity index 92%
rename from samples/My.Hr/My.Hr.Infra/Components/Diagnostics.cs
rename to tools/CoreEx.Template/content/Company.AppName.Infra/Components/Diagnostics.cs
index ec112c40..d9dfb828 100644
--- a/samples/My.Hr/My.Hr.Infra/Components/Diagnostics.cs
+++ b/tools/CoreEx.Template/content/Company.AppName.Infra/Components/Diagnostics.cs
@@ -2,14 +2,14 @@
using Pulumi.AzureNative.Insights.V20200202;
using Pulumi.AzureNative.OperationalInsights;
-namespace My.Hr.Infra.Components;
+namespace Company.AppName.Infra.Components;
public class Diagnostics : ComponentResource
{
public Output InstrumentationKey { get; } = default!;
public Diagnostics(string name, DiagnosticsArgs args, ComponentResourceOptions? options = null)
- : base("coreexinfra:web:diagnostics", name, options)
+ : base("Company:AppName:web:diagnostics", name, options)
{
// Log Analytics Workspace
var workspace = new Workspace("workspace", new()
diff --git a/samples/My.Hr/My.Hr.Infra/Components/Messaging.cs b/tools/CoreEx.Template/content/Company.AppName.Infra/Components/Messaging.cs
similarity index 91%
rename from samples/My.Hr/My.Hr.Infra/Components/Messaging.cs
rename to tools/CoreEx.Template/content/Company.AppName.Infra/Components/Messaging.cs
index 5ee62f91..149c3098 100644
--- a/samples/My.Hr/My.Hr.Infra/Components/Messaging.cs
+++ b/tools/CoreEx.Template/content/Company.AppName.Infra/Components/Messaging.cs
@@ -4,7 +4,7 @@
using Pulumi.AzureNative.ServiceBus;
using AzureNative = Pulumi.AzureNative;
-namespace My.Hr.Infra.Components;
+namespace Company.AppName.Infra.Components;
public class Messaging : ComponentResource
{
@@ -15,7 +15,7 @@ public class Messaging : ComponentResource
public Output NamespaceId { get; } = default!;
public Messaging(string name, MessagingArgs args, ComponentResourceOptions? options = null)
- : base("coreexinfra:web:messaging", name, options)
+ : base("Company:AppName:web:messaging", name, options)
{
this.args = args;
this.name = name;
@@ -50,7 +50,7 @@ public Queue AddQueue(string queueName, bool batchOperationsEnabled = false)
}, new CustomResourceOptions { Parent = this });
}
- public IEnumerable AddAccess(Input principalId, string name)
+ public IEnumerable AddAccess(Input principalId, string name, string principalType = "ServicePrincipal")
{
var receive_permission = new RoleAssignment(
$"receive-for-{name}",
@@ -58,7 +58,7 @@ public IEnumerable AddAccess(Input principalId, string n
{
Description = $"{name} receiving data from service bus",
PrincipalId = principalId,
- PrincipalType = "ServicePrincipal",
+ PrincipalType = principalType,
RoleDefinitionId = Roles.BuiltInRolesIds.ServiceBusDataReceiver,
Scope = NamespaceId
},
@@ -71,7 +71,7 @@ public IEnumerable AddAccess(Input principalId, string n
{
Description = $"{name} sending data to service bus",
PrincipalId = principalId,
- PrincipalType = "ServicePrincipal",
+ PrincipalType = principalType,
RoleDefinitionId = Roles.BuiltInRolesIds.ServiceBusDataSender,
Scope = NamespaceId
diff --git a/samples/My.Hr/My.Hr.Infra/Components/Sql.cs b/tools/CoreEx.Template/content/Company.AppName.Infra/Components/Sql.cs
similarity index 93%
rename from samples/My.Hr/My.Hr.Infra/Components/Sql.cs
rename to tools/CoreEx.Template/content/Company.AppName.Infra/Components/Sql.cs
index 6695e296..0b899a9d 100644
--- a/samples/My.Hr/My.Hr.Infra/Components/Sql.cs
+++ b/tools/CoreEx.Template/content/Company.AppName.Infra/Components/Sql.cs
@@ -1,24 +1,24 @@
-using System.Collections.Generic;
-using My.Hr.Infra.Services;
+using System.Collections.Concurrent;
+using Company.AppName.Infra.Services;
using Pulumi;
using Pulumi.AzureNative.Sql;
using Pulumi.AzureNative.Sql.Inputs;
using AD = Pulumi.AzureAD;
using Deployment = Pulumi.Deployment;
-namespace My.Hr.Infra.Components;
+namespace Company.AppName.Infra.Components;
public class Sql : ComponentResource
{
private readonly SqlArgs args;
- private readonly HashSet firewallAllowedIps = new();
+ private readonly ConcurrentDictionary firewallAllowedIps = new();
public Output SqlDatabaseConnectionString { get; }
public Output SqlServerName { get; }
public Output SqlDatabaseAuthorizedGroupId { get; }
public Sql(string name, SqlArgs args, IDbOperations dbOperations, AzureApiClient apiClient, ComponentResourceOptions? options = null)
- : base("coreexinfra:web:sql", name, options)
+ : base("Company:AppName:web:sql", name, options)
{
this.args = args;
var sqlAdAdmin = new AD.User("sqlAdmin", new AD.UserArgs
@@ -57,7 +57,7 @@ public Sql(string name, SqlArgs args, IDbOperations dbOperations, AzureApiClient
{
ResourceGroupName = args.ResourceGroupName,
ServerName = sqlServer.Name,
- DatabaseName = "CoreExDB",
+ DatabaseName = "CompanyAppNameDB",
Sku = new SkuArgs
{
Name = "Basic"
@@ -102,10 +102,8 @@ public void AddFirewallRule(Output ips, string name)
{
foreach (var address in ips.Split(","))
{
- if (!firewallAllowedIps.Contains(address))
+ if (firewallAllowedIps.TryAdd(address, default))
{
- firewallAllowedIps.Add(address);
-
var enableIp = new FirewallRule("Enable_" + name + "_" + address, new FirewallRuleArgs
{
ResourceGroupName = args.ResourceGroupName,
diff --git a/samples/My.Hr/My.Hr.Infra/Components/Storage.cs b/tools/CoreEx.Template/content/Company.AppName.Infra/Components/Storage.cs
similarity index 94%
rename from samples/My.Hr/My.Hr.Infra/Components/Storage.cs
rename to tools/CoreEx.Template/content/Company.AppName.Infra/Components/Storage.cs
index 7f9b9139..822ba202 100644
--- a/samples/My.Hr/My.Hr.Infra/Components/Storage.cs
+++ b/tools/CoreEx.Template/content/Company.AppName.Infra/Components/Storage.cs
@@ -7,7 +7,7 @@
using AzureNative = Pulumi.AzureNative;
-namespace My.Hr.Infra.Components;
+namespace Company.AppName.Infra.Components;
public class Storage : ComponentResource
{
@@ -17,7 +17,7 @@ public class Storage : ComponentResource
public Output ConnectionString { get; private set; } = default!;
public Storage(string name, StorageArgs args, ComponentResourceOptions? options = null)
- : base("coreexinfra:web:storage", name, options)
+ : base("Company:AppName:web:storage", name, options)
{
// Create an Azure resource (Storage Account)
var storageAccount = new StorageAccount(name, new StorageAccountArgs
@@ -69,7 +69,7 @@ private static Output GetConnectionString(Input resourceGroupNam
});
}
- public RoleAssignment AddAccess(Input principalId, string name)
+ public RoleAssignment AddAccess(Input principalId, string name, string principalType = "ServicePrincipal")
{
return new RoleAssignment(
$"useblob-for-{name}",
@@ -77,7 +77,7 @@ public RoleAssignment AddAccess(Input principalId, string name)
{
Description = $"{name} accessing storage account",
PrincipalId = principalId,
- PrincipalType = "ServicePrincipal",
+ PrincipalType = principalType,
RoleDefinitionId = Roles.BuiltInRolesIds.StorageBlobDataOwner,
Scope = id
},
diff --git a/samples/My.Hr/My.Hr.Infra/Extensions.cs b/tools/CoreEx.Template/content/Company.AppName.Infra/Extensions.cs
similarity index 97%
rename from samples/My.Hr/My.Hr.Infra/Extensions.cs
rename to tools/CoreEx.Template/content/Company.AppName.Infra/Extensions.cs
index 35e844d0..c6cc88e6 100644
--- a/samples/My.Hr/My.Hr.Infra/Extensions.cs
+++ b/tools/CoreEx.Template/content/Company.AppName.Infra/Extensions.cs
@@ -2,7 +2,7 @@
using System.Threading.Tasks;
using Pulumi;
-namespace My.Hr.Infra;
+namespace Company.AppName.Infra;
public static class Extensions
{
diff --git a/samples/My.Hr/My.Hr.Infra/Program.cs b/tools/CoreEx.Template/content/Company.AppName.Infra/Program.cs
similarity index 77%
rename from samples/My.Hr/My.Hr.Infra/Program.cs
rename to tools/CoreEx.Template/content/Company.AppName.Infra/Program.cs
index 4bc17535..6686fa1b 100644
--- a/samples/My.Hr/My.Hr.Infra/Program.cs
+++ b/tools/CoreEx.Template/content/Company.AppName.Infra/Program.cs
@@ -1,12 +1,13 @@
// build CoreEx stack
-using My.Hr.Infra.Services;
+using Company.AppName.Infra.Services;
using Pulumi;
return await Deployment.RunAsync(() =>
{
+ // running with using statement (to Dispose) doesn't work with Pulumi
var client = new System.Net.Http.HttpClient();
// create and use actual instance of DB Operations service
- return My.Hr.Infra.CoreExStack.ExecuteStackAsync(new DbOperations(), client);
+ return Company.AppName.Infra.CompanyAppNameStack.ExecuteStackAsync(new DbOperations(), client);
}, new StackOptions
{
// apply auto-tagging transformation
@@ -21,7 +22,7 @@
tags.Add("user:Stack", Deployment.Instance.StackName);
tags.Add("user:Project", Deployment.Instance.ProjectName);
- tags.Add("App:Name", "CoreEx");
+ tags.Add("App:Name", "Company:AppName");
}
return new ResourceTransformationResult(args.Args, args.Options);
diff --git a/samples/My.Hr/My.Hr.Infra/Pulumi.yaml b/tools/CoreEx.Template/content/Company.AppName.Infra/Pulumi.yaml
similarity index 54%
rename from samples/My.Hr/My.Hr.Infra/Pulumi.yaml
rename to tools/CoreEx.Template/content/Company.AppName.Infra/Pulumi.yaml
index 4b527ea0..66b5ab9b 100644
--- a/samples/My.Hr/My.Hr.Infra/Pulumi.yaml
+++ b/tools/CoreEx.Template/content/Company.AppName.Infra/Pulumi.yaml
@@ -1,4 +1,4 @@
-name: My.Hr.Infra
+name: Company.AppName.Infra
runtime: dotnet
description: Infrastructure for CoreEx sample application
template:
@@ -6,9 +6,12 @@ template:
azure-native:location:
description: The Azure region to deploy into
default: EastUs
- My.Hr.Infra:isAppsDeploymentEnabled:
+ Company.AppName.Infra:isAppsDeploymentEnabled:
description: Whether Application code should be deployed
default: true
- My.Hr.Infra:isDBSchemaDeploymentEnabled:
+ Company.AppName.Infra:isDBSchemaDeploymentEnabled:
description: Whether Database schema should be deployed
default: true
+ Company.AppName.Infra:developerEmails:
+ description: Comma separated list of developer team emails that will get access to created resources
+ default:
\ No newline at end of file
diff --git a/tools/CoreEx.Template/content/Company.AppName.Infra/Readme.md b/tools/CoreEx.Template/content/Company.AppName.Infra/Readme.md
new file mode 100644
index 00000000..82b808ac
--- /dev/null
+++ b/tools/CoreEx.Template/content/Company.AppName.Infra/Readme.md
@@ -0,0 +1,92 @@
+# About
+
+Infrastructure is built with [Pulumi](https://www.pulumi.com/).
+
+The easiest way to deploy it is by using Pulumi account (Free), but it's not mandatory.
+
+Prerequisites:
+
+1. [Pulumi CLI](https://www.pulumi.com/docs/get-started/install/)
+2. Azure CLI - logged in to Azure subscription with permissions to create service principals
+
+> Note: Some corporate AAD restrict what can be done in AAD. Since this sample creates AAD User and Group, infrastructure needs to be created in AAD tenant that allows it.
+
+## Pulumi with azure storage
+
+Pulumi can be used without Pulumi Account, by using [Azure Storage as backend](https://www.techwatching.dev/posts/pulumi-azure-backend).
+
+1. set the `AZURE_STORAGE_ACCOUNT` environment variable to specify the Azure storage account to use
+1. set the `AZURE_STORAGE_KEY` or the `AZURE_STORAGE_SAS_TOKEN` environment variables to let Pulumi access the storage
+1. create a container in the storage account
+1. execute the following command `pulumi login azblob://` where container-path is the path to a blob container in the storage account
+
+## Configuring Pulumi (optional)
+
+Infrastructure project has only few settings:
+
+* `Company.AppName.Infra:isAppsDeploymentEnabled` for controlling application deployment via zip deploy
+* `Company.AppName.Infra:isDBSchemaDeploymentEnabled` for publishing Database schema and data
+* `Company.AppName.Infra:developerEmails` comma separated list of developer team emails that will get access to created resources
+
+> When `isAppsDeploymentEnabled` flag is set, pulumi code executes `dotnet publish -c RELEASE` to create app packages.
+
+Pulumi can be configured and previewed with:
+
+```bash
+pulumi preview -c azure-native:location=EastUs -c Company.AppName.Infra:isAppsDeploymentEnabled=true -c Company.AppName.Infra:isDBSchemaDeploymentEnabled=true
+```
+
+which creates a stack config file `Pulumi.dev.yaml`
+
+```yaml
+config:
+ azure-native:location: EastUs
+ Company.AppName.Infra:isAppsDeploymentEnabled: true
+ Company.AppName.Infra:isDBSchemaDeploymentEnabled: true
+ Company.AppName.Infra:developerEmails: "bob@mycustomad.onmicrosoft.com, alice@mycustomad.onmicrosoft.com"
+```
+
+### Note on best practices
+
+Infrastructure project has built-in ability to deploy application code and database schema, in real-life scenarios those operations should be separated out. Code will most likely be deployed more often than infrastructure piece, with additional options for tagging, versioning etc.
+
+It's also important to keep infrastructure project up to date and deploy it often. Pulumi state change analysis is quick and should not add a lot to deployment time.
+
+## Infrastructure deployed
+
+Pulumi creates full stack infrastructure designed for production. Resources deployed include:
+
+* Storage account with RBAC enabled for app service and function app managed identities
+* App Service Plan
+* Log analytics workspace and Application Insights
+* SQL Server with SQL Database enabled for Azure AD access with permissions setup for app service and function app managed identities
+* Service Bus with queues and topics and permissions setup for app service and function app managed identities
+* Function App and App Service
+* Azure AD group for developer access
+
+> **Considerations for enhancing security pasture**
+>
+> Networking stack can be enhanced with private networking capabilities - private endpoints, service endpoints. Tunneling traffic via API Management with WAF, cross region replication and more.
+
+## Deploy with Pulumi
+
+To deploy in `samples/Company.AppName/Company.AppName.Infra` run `pulumi up -c azure-native:location=EastUs -c Company.AppName.Infra:isAppsDeploymentEnabled=true -c Company.AppName.Infra:isDBSchemaDeploymentEnabled=true`
+
+To display outputs of the stack deployment run: `pulumi stack output --show-secrets` which will display function links with secret api key.
+
+## Alternative deployment methods
+
+Apps can also be deployed with Azure CLI, once published apps are zipped.
+
+```bash
+az webapp deploy --resource-group coreEx-dev4011fb65 --name app17b7c4c8 --src-path app.zip
+az functionapp deployment source config-zip -g coreEx-dev4011fb65 -n fun17b7c4c8 --src fun.zip
+```
+
+## Deploying from CI/CD with service principal
+
+When deploying using service principal, SP needs to be given appropriate permissions to be allowed to create resources:
+
+* Owner role on the subscription to be able to assign permissions to manage identities created (this can be achieved by creating/assigning custom role too).
+* Azure AD create user role in order to create SQL Admin user and SQL access group.
+* Microsoft Graph permissions according to [Terraform Docs](https://registry.terraform.io/providers/hashicorp/azuread/latest/docs/guides/service_principal_configuration) to be able to query domain, create/read users and create/read groups.
diff --git a/samples/My.Hr/My.Hr.Infra/Roles/BuiltInRolesIds.cs b/tools/CoreEx.Template/content/Company.AppName.Infra/Roles/BuiltInRolesIds.cs
similarity index 94%
rename from samples/My.Hr/My.Hr.Infra/Roles/BuiltInRolesIds.cs
rename to tools/CoreEx.Template/content/Company.AppName.Infra/Roles/BuiltInRolesIds.cs
index cad5d8d2..f77a8ce6 100644
--- a/samples/My.Hr/My.Hr.Infra/Roles/BuiltInRolesIds.cs
+++ b/tools/CoreEx.Template/content/Company.AppName.Infra/Roles/BuiltInRolesIds.cs
@@ -1,4 +1,4 @@
-namespace My.Hr.Infra.Roles;
+namespace Company.AppName.Infra.Roles;
public static class BuiltInRolesIds
{
diff --git a/samples/My.Hr/My.Hr.Infra/Services/AzureApiClient.cs b/tools/CoreEx.Template/content/Company.AppName.Infra/Services/AzureApiClient.cs
similarity index 96%
rename from samples/My.Hr/My.Hr.Infra/Services/AzureApiClient.cs
rename to tools/CoreEx.Template/content/Company.AppName.Infra/Services/AzureApiClient.cs
index e9d49d08..e1587299 100644
--- a/samples/My.Hr/My.Hr.Infra/Services/AzureApiClient.cs
+++ b/tools/CoreEx.Template/content/Company.AppName.Infra/Services/AzureApiClient.cs
@@ -5,7 +5,7 @@
using CoreEx.Http;
using Microsoft.Extensions.Configuration;
-namespace My.Hr.Infra.Services;
+namespace Company.AppName.Infra.Services;
///
/// Http client for Azure APIs
diff --git a/samples/My.Hr/My.Hr.Infra/Services/AzureApiService.cs b/tools/CoreEx.Template/content/Company.AppName.Infra/Services/AzureApiService.cs
similarity index 93%
rename from samples/My.Hr/My.Hr.Infra/Services/AzureApiService.cs
rename to tools/CoreEx.Template/content/Company.AppName.Infra/Services/AzureApiService.cs
index c7989421..19e4eb44 100644
--- a/samples/My.Hr/My.Hr.Infra/Services/AzureApiService.cs
+++ b/tools/CoreEx.Template/content/Company.AppName.Infra/Services/AzureApiService.cs
@@ -6,7 +6,7 @@
using Pulumi;
using Pulumi.AzureNative.Authorization;
-namespace My.Hr.Infra.Services;
+namespace Company.AppName.Infra.Services;
public class AzureApiService
{
@@ -42,6 +42,10 @@ public Output GetHostKeys(Output rgName, Output function
{
return Output.Tuple(rgName, functionName).Apply(async t =>
{
+ // do not call in preview
+ if (Deployment.Instance.IsDryRun)
+ return "tbd";
+
var (resourceGroupName, siteName) = t;
Log.Info("Getting host keys for: " + siteName);
@@ -62,6 +66,10 @@ public Output SyncFunctionAppTriggers(Output rgName, Output
{
+ // do not call in preview
+ if (Deployment.Instance.IsDryRun)
+ return true;
+
var (resourceGroupName, siteName) = t;
Log.Info("Syncing Function App triggers");
diff --git a/samples/My.Hr/My.Hr.Infra/Services/DbOperations.cs b/tools/CoreEx.Template/content/Company.AppName.Infra/Services/DbOperations.cs
similarity index 72%
rename from samples/My.Hr/My.Hr.Infra/Services/DbOperations.cs
rename to tools/CoreEx.Template/content/Company.AppName.Infra/Services/DbOperations.cs
index 13f8b08a..f6e97ece 100644
--- a/samples/My.Hr/My.Hr.Infra/Services/DbOperations.cs
+++ b/tools/CoreEx.Template/content/Company.AppName.Infra/Services/DbOperations.cs
@@ -3,7 +3,7 @@
using Microsoft.Data.SqlClient;
using Pulumi;
-namespace My.Hr.Infra.Services;
+namespace Company.AppName.Infra.Services;
public class DbOperations : IDbOperations
{
@@ -13,17 +13,17 @@ public void ProvisionUsers(Input connectionString, string groupName)
// skip in dry run
return;
- Log.Info($"Provisioning user {groupName} in SQL DB");
+ Log.Info($"Provisioning user group {groupName} in SQL DB");
string commandText = @$"
IF NOT EXISTS (SELECT [name]
FROM [sys].[database_principals]
WHERE [type] = N'X' AND [name] = N'{groupName}')
BEGIN
- CREATE USER {groupName} FROM EXTERNAL PROVIDER;
+ CREATE USER [{groupName}] FROM EXTERNAL PROVIDER;
END
- ALTER ROLE db_datareader ADD MEMBER {groupName};
- ALTER ROLE db_datawriter ADD MEMBER {groupName};
+ ALTER ROLE db_datareader ADD MEMBER [{groupName}];
+ ALTER ROLE db_datawriter ADD MEMBER [{groupName}];
";
connectionString.Apply(async cs =>
@@ -42,7 +42,7 @@ public Task DeployDbSchemaAsync(string connectionString)
// skip in dry run
return Task.FromResult(0);
- Log.Info($"Deploying DB schema using {connectionString}");
- return Database.Program.RunMigrator(connectionString, assembly: typeof(My.Hr.Database.Program).Assembly, "DeployWithData");
+ Log.Info($"Deploying DB schema");
+ return Database.Program.RunMigrator(connectionString, assembly: typeof(Company.AppName.Database.Program).Assembly, "DeployWithData");
}
}
\ No newline at end of file
diff --git a/samples/My.Hr/My.Hr.Infra/Services/IDbOperations.cs b/tools/CoreEx.Template/content/Company.AppName.Infra/Services/IDbOperations.cs
similarity index 83%
rename from samples/My.Hr/My.Hr.Infra/Services/IDbOperations.cs
rename to tools/CoreEx.Template/content/Company.AppName.Infra/Services/IDbOperations.cs
index f681adfc..9137891d 100644
--- a/samples/My.Hr/My.Hr.Infra/Services/IDbOperations.cs
+++ b/tools/CoreEx.Template/content/Company.AppName.Infra/Services/IDbOperations.cs
@@ -1,7 +1,7 @@
using System.Threading.Tasks;
using Pulumi;
-namespace My.Hr.Infra.Services;
+namespace Company.AppName.Infra.Services;
public interface IDbOperations
{
diff --git a/samples/My.Hr/My.Hr.Infra/Services/PulumiLogger.cs b/tools/CoreEx.Template/content/Company.AppName.Infra/Services/PulumiLogger.cs
similarity index 96%
rename from samples/My.Hr/My.Hr.Infra/Services/PulumiLogger.cs
rename to tools/CoreEx.Template/content/Company.AppName.Infra/Services/PulumiLogger.cs
index 9a14fd0f..c00957a4 100644
--- a/samples/My.Hr/My.Hr.Infra/Services/PulumiLogger.cs
+++ b/tools/CoreEx.Template/content/Company.AppName.Infra/Services/PulumiLogger.cs
@@ -1,7 +1,7 @@
using System;
using Microsoft.Extensions.Logging;
-namespace My.Hr.Infra.Services;
+namespace Company.AppName.Infra.Services;
public class PulumiLogger : ILogger
{
diff --git a/tools/CoreEx.Template/content/Company.AppName.Infra/StackConfiguration.cs b/tools/CoreEx.Template/content/Company.AppName.Infra/StackConfiguration.cs
new file mode 100644
index 00000000..a3d0c870
--- /dev/null
+++ b/tools/CoreEx.Template/content/Company.AppName.Infra/StackConfiguration.cs
@@ -0,0 +1,61 @@
+using System.Threading.Tasks;
+using Pulumi;
+using AD = Pulumi.AzureAD;
+
+namespace Company.AppName.Infra;
+
+public class StackConfiguration
+{
+ public Input? SqlAdAdminLogin { get; private set; }
+ public Input? SqlAdAdminPassword { get; private set; }
+ public bool IsAppsDeploymentEnabled { get; private set; }
+ public bool IsDBSchemaDeploymentEnabled { get; private set; }
+ public string PendingVerificationsQueue { get; private set; } = default!;
+ public string VerificationResultsQueue { get; private set; } = default!;
+ public string MassPublishQueue { get; private set; } = default!;
+
+ ///
+ /// Emails for developer team, that will be added to Developers AD group.
+ ///
+ public string DeveloperEmails { get; private set; } = default!;
+
+ private StackConfiguration() { }
+
+ public static async Task CreateConfiguration()
+ {
+ // read stack config
+ var config = new Config();
+
+ // get some info from Azure AD
+ var domainResult = await AD.GetDomains.InvokeAsync(new AD.GetDomainsArgs { OnlyDefault = true });
+ var defaultUsername = $"sqlGlobalAdAdminCompanyAppName{Pulumi.Deployment.Instance.StackName}@{domainResult.Domains[0].DomainName}";
+ var defaultPassword = new Pulumi.Random.RandomPassword("sqlAdPassword", new()
+ {
+ Length = 32,
+ Upper = true,
+ Number = true,
+ Special = true,
+ OverrideSpecial = "@",
+ MinLower = 2,
+ MinUpper = 2,
+ MinSpecial = 2,
+ MinNumeric = 2
+ }).Result;
+
+ Log.Info($"Default username is: {defaultUsername}");
+
+ return new StackConfiguration
+ {
+ SqlAdAdminLogin = Extensions.GetConfigValue("sqlAdAdmin", defaultUsername),
+ SqlAdAdminPassword = Extensions.GetConfigValue("sqlAdPassword", defaultPassword),
+ IsAppsDeploymentEnabled = config.GetBoolean("isAppsDeploymentEnabled") ?? false,
+ IsDBSchemaDeploymentEnabled = config.GetBoolean("isDBSchemaDeploymentEnabled") ?? false,
+
+ PendingVerificationsQueue = config.Get("pendingVerificationsQueue") ?? "pendingVerifications",
+ VerificationResultsQueue = config.Get("verificationResultsQueue") ?? "verificationResults",
+ MassPublishQueue = config.Get("massPublishQueue") ?? "massPublish",
+
+ DeveloperEmails = config.Get("developerEmails") ?? string.Empty
+ };
+ }
+}
\ No newline at end of file
diff --git a/tools/CoreEx.Template/content/Company.AppName.UnitTest/Company.AppName.UnitTest.csproj b/tools/CoreEx.Template/content/Company.AppName.UnitTest/Company.AppName.UnitTest.csproj
new file mode 100644
index 00000000..f290bc13
--- /dev/null
+++ b/tools/CoreEx.Template/content/Company.AppName.UnitTest/Company.AppName.UnitTest.csproj
@@ -0,0 +1,51 @@
+
+
+
+ net6.0
+ enable
+
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ PreserveNewest
+
+
+
+
diff --git a/tools/CoreEx.Template/content/Company.AppName.UnitTest/Data/Data.yaml b/tools/CoreEx.Template/content/Company.AppName.UnitTest/Data/Data.yaml
new file mode 100644
index 00000000..20e76226
--- /dev/null
+++ b/tools/CoreEx.Template/content/Company.AppName.UnitTest/Data/Data.yaml
@@ -0,0 +1,10 @@
+AppName:
+ - Employee:
+ - { EmployeeId: 1, Email: w.jones@org.com, FirstName: Wendy, LastName: Jones, GenderCode: F, Birthday: 1985-03-18, StartDate: 2000-12-11, PhoneNo: (425) 612 8113 }
+ - { EmployeeId: 2, Email: b.smith@org.com, FirstName: Brian, LastName: Smith, GenderCode: M, Birthday: 1994-11-07, StartDate: 2013-08-06, TerminationDate: 2015-04-08, TerminationReasonCode: RE, PhoneNo: (429) 120 0098 }
+ - { EmployeeId: 3, Email: r.Browne@org.com, FirstName: Rachael, LastName: Browne, GenderCode: F, Birthday: 1972-06-28, StartDate: 2019-11-06, PhoneNo: (421) 783 2343 }
+ - { EmployeeId: 4, Email: w.smither@org.com, FirstName: Waylon, LastName: Smithers, GenderCode: M, Birthday: 1952-02-21, StartDate: 2001-01-22, PhoneNo: (428) 893 2793, AddressJson: '{ "street1": "8365 851 PL NE", "city": "Redmond", "state": "WA", "postCode": "98052" }' }
+ - EmergencyContact:
+ - { EmergencyContactId: 201, EmployeeId: 2, FirstName: Garth, LastName: Smith, PhoneNo: (443) 678 1827, RelationshipTypeCode: PAR }
+ - { EmergencyContactId: 202, EmployeeId: 2, FirstName: Sarah, LastName: Smith, PhoneNo: (443) 234 3837, RelationshipTypeCode: PAR }
+ - { EmergencyContactId: 401, EmployeeId: 4, FirstName: Michael, LastName: Manners, PhoneNo: (234) 297 9834, RelationshipTypeCode: FRD }
\ No newline at end of file
diff --git a/tools/CoreEx.Template/content/Company.AppName.UnitTest/EmployeeControllerTest.cs b/tools/CoreEx.Template/content/Company.AppName.UnitTest/EmployeeControllerTest.cs
new file mode 100644
index 00000000..3fbbed10
--- /dev/null
+++ b/tools/CoreEx.Template/content/Company.AppName.UnitTest/EmployeeControllerTest.cs
@@ -0,0 +1,400 @@
+using CoreEx.Entities;
+using CoreEx.Events;
+using CoreEx.Http;
+using DbEx.Migration;
+using DbEx.Migration.Data;
+using Microsoft.Extensions.Configuration;
+using Company.AppName.Api;
+using Company.AppName.Api.Controllers;
+using Company.AppName.Business.Models;
+using Company.AppName.Business.External.Contracts;
+using NUnit.Framework;
+using System;
+using System.Linq;
+using System.Threading.Tasks;
+using UnitTestEx;
+using UnitTestEx.Expectations;
+using UnitTestEx.NUnit;
+
+namespace Company.AppName.UnitTest
+{
+ [TestFixture]
+ [Category("WithDB")]
+ public class EmployeeControllerTest
+ {
+ [OneTimeSetUp]
+ public static async Task Init()
+ {
+ HttpConsts.IncludeFieldsQueryStringName = "include-fields";
+
+ using var test = ApiTester.Create();
+ var cs = test.Configuration.GetConnectionString("Database");
+ if (await Database.Program.RunMigrator(cs, typeof(EmployeeControllerTest).Assembly, MigrationCommand.ResetAndAll.ToString()).ConfigureAwait(false) != 0)
+ Assert.Fail("Database migration failed.");
+ }
+
+ [Test]
+ public void A100_Get_NotFound()
+ {
+ using var test = ApiTester.Create();
+
+ test.Controller()
+ .Run(c => c.GetAsync(404.ToGuid()))
+ .AssertNotFound();
+ }
+
+ [Test]
+ public void A110_Get_Found()
+ {
+ using var test = ApiTester.Create();
+
+ test.Controller()
+ .Run(c => c.GetAsync(1.ToGuid()))
+ .AssertOK()
+ .Assert(new Employee
+ {
+ Id = 1.ToGuid(),
+ Email = "w.jones@org.com",
+ FirstName = "Wendy",
+ LastName = "Jones",
+ Gender = "F",
+ Birthday = new DateTime(1985, 03, 18, 0, 0, 0, DateTimeKind.Unspecified),
+ StartDate = new DateTime(2000, 12, 11, 0, 0, 0, DateTimeKind.Unspecified),
+ PhoneNo = "(425) 612 8113"
+ }, nameof(Employee.ETag));
+ }
+
+ [Test]
+ public void A120_Get_NotModifed()
+ {
+ using var test = ApiTester.Create();
+
+ var e = test.Controller()
+ .Run(c => c.GetAsync(1.ToGuid()))
+ .AssertOK()
+ .GetValue()!;
+
+ test.Controller()
+ .Run(c => c.GetAsync(e.Id), requestOptions: new HttpRequestOptions { ETag = e.ETag })
+ .AssertNotModified();
+ }
+
+ [Test]
+ public void A130_Get_IncludeFields()
+ {
+ using var test = ApiTester.Create();
+
+ test.Controller()
+ .Run(c => c.GetAsync(1.ToGuid()), requestOptions: new HttpRequestOptions().Include("FirstName", "LastName"))
+ .AssertOK()
+ .AssertJson("{\"firstName\":\"Wendy\",\"lastName\":\"Jones\"}");
+ }
+
+ [Test]
+ public void B100_GetAll_All()
+ {
+ using var test = ApiTester.Create();
+
+ var v = test.Controller()
+ .Run(c => c.GetAllAsync())
+ .AssertOK()
+ .GetValue();
+
+ Assert.IsNotNull(v?.Collection);
+ Assert.AreEqual(4, v!.Collection.Count);
+ Assert.AreEqual(new string[] { "Browne", "Jones", "Smith", "Smithers" }, v.Collection.Select(x => x.LastName).ToArray());
+ }
+
+ [Test]
+ public void B110_GetAll_Paging()
+ {
+ using var test = ApiTester.Create();
+
+ var v = test.Controller()
+ .Run(c => c.GetAllAsync(), requestOptions: new HttpRequestOptions { Paging = PagingArgs.CreateSkipAndTake(1, 2, true) })
+ .AssertOK()
+ .GetValue();
+
+ Assert.IsNotNull(v?.Collection);
+ Assert.AreEqual(2, v!.Collection.Count);
+ Assert.AreEqual(new string[] { "Jones", "Smith" }, v.Collection.Select(x => x.LastName).ToArray());
+ Assert.IsNotNull(v.Paging);
+ Assert.AreEqual(4, v.Paging!.TotalCount);
+ }
+
+ [Test]
+ public void B120_GetAll_PagingAndIncludeFields()
+ {
+ using var test = ApiTester.Create();
+
+ var v = test.Controller()
+ .Run(c => c.GetAllAsync(), requestOptions: new HttpRequestOptions { Paging = PagingArgs.CreateSkipAndTake(1, 2) }.Include("lastname"))
+ .AssertOK()
+ .AssertJson("[ { \"lastName\": \"Jones\" }, { \"lastName\": \"Smith\" } ]")
+ .GetValue();
+
+ Assert.IsNull(v!.Paging!.TotalCount); // No count requested.
+ }
+
+ [Test]
+ public void C100_Create_Error()
+ {
+ using var test = ApiTester.Create();
+
+ var e = new Employee
+ {
+ FirstName = "Rebecca",
+ LastName = "Smythe",
+ Birthday = new DateTime(2000, 01, 01, 0, 0, 0, DateTimeKind.Unspecified),
+ Gender = "Z",
+ PhoneNo = "555 123 4567",
+ StartDate = new DateTime(2020, 01, 08, 0, 0, 0, DateTimeKind.Unspecified)
+ };
+
+ test.Controller()
+ .Run(c => c.CreateAsync(null!), e)
+ .AssertErrors(
+ new ApiError("Email", "'Email' must not be empty."),
+ new ApiError("Gender", "'Gender' is invalid."));
+ }
+
+ [Test]
+ public void C110_Create_Success()
+ {
+ using var test = ApiTester.Create();
+
+ var e = new Employee
+ {
+ FirstName = "Rebecca",
+ LastName = "Smythe",
+ Birthday = new DateTime(2000, 01, 01, 0, 0, 0, DateTimeKind.Unspecified),
+ Gender = "M",
+ PhoneNo = "555 123 4567",
+ Email = "rs@email.com",
+ StartDate = new DateTime(2020, 01, 08, 0, 0, 0, DateTimeKind.Unspecified)
+ };
+
+ var v = test.Controller()
+ .Run(c => c.CreateAsync(null!), e)
+ .AssertCreated()
+ .Assert(e, "Id", "ETag")
+ .AssertLocationHeader(v => new Uri($"api/employees/{v!.Id}", UriKind.Relative))
+ .GetValue();
+
+ // Do a GET to make sure it is in the database and all fields equal.
+ test.Controller()
+ .Run(c => c.GetAsync(v!.Id))
+ .AssertOK()
+ .Assert(v);
+ }
+
+ [Test]
+ public void D100_Update_Error()
+ {
+ using var test = ApiTester.Create();
+
+ var e = new Employee
+ {
+ FirstName = "Rebecca",
+ LastName = "Smythe",
+ Birthday = new DateTime(2000, 01, 01, 0, 0, 0, DateTimeKind.Unspecified),
+ Gender = "M",
+ PhoneNo = "555 123 4567",
+ StartDate = new DateTime(2020, 01, 08, 0, 0, 0, DateTimeKind.Unspecified)
+ };
+
+ test.Controller()
+ .Run(c => c.UpdateAsync(404.ToGuid(), null!), e)
+ .AssertErrors(
+ new ApiError("Email", "'Email' must not be empty."));
+ }
+
+ [Test]
+ public void D110_Update_NotFound()
+ {
+ using var test = ApiTester.Create();
+
+ var e = new Employee
+ {
+ FirstName = "Rebecca",
+ LastName = "Smythe",
+ Birthday = new DateTime(2000, 01, 01, 0, 0, 0, DateTimeKind.Unspecified),
+ Gender = "M",
+ PhoneNo = "555 123 4567",
+ Email = "rs@email.com",
+ StartDate = new DateTime(2020, 01, 08, 0, 0, 0, DateTimeKind.Unspecified)
+ };
+
+ test.Controller()
+ .Run(c => c.UpdateAsync(404.ToGuid(), null!), e)
+ .AssertNotFound();
+ }
+
+ [Test]
+ public void D120_Update_Success()
+ {
+ using var test = ApiTester.Create();
+
+ // Get current.
+ var v = test.Controller()
+ .Run(c => c.GetAsync(2.ToGuid()))
+ .AssertOK()
+ .GetValue()!;
+
+ // Update it.
+ v.FirstName += "X";
+
+ v = test.Controller()
+ .Run(c => c.UpdateAsync(v.Id, null!), v)
+ .AssertOK()
+ .Assert(v, "ETag")
+ .GetValue()!;
+
+ // Get again and check all.
+ test.Controller()
+ .Run(c => c.GetAsync(v.Id))
+ .AssertOK()
+ .Assert(v);
+ }
+
+ [Test]
+ public void D130_Update_ConcurrencyError()
+ {
+ using var test = ApiTester.Create();
+
+ // Get current.
+ var v = test.Controller()
+ .Run(c => c.GetAsync(2.ToGuid()))
+ .AssertOK()
+ .GetValue()!;
+
+ // Update it with errant etag.
+ v.FirstName += "X";
+ v.ETag = "ZZZZZZZZZZZZ";
+
+ test.Controller()
+ .Run(c => c.UpdateAsync(v.Id, null!), v)
+ .AssertPreconditionFailed();
+ }
+
+ [Test]
+ public void E100_Delete()
+ {
+ using var test = ApiTester.Create();
+
+ // Get current.
+ test.Controller()
+ .Run(c => c.GetAsync(2.ToGuid()))
+ .AssertOK();
+
+ // Delete it.
+ test.Controller()
+ .Run(c => c.DeleteAsync(2.ToGuid()))
+ .AssertNoContent();
+
+ // Must not exist.
+ test.Controller()
+ .Run(c => c.GetAsync(2.ToGuid()))
+ .AssertNotFound();
+
+ // Delete it again; should appear as if deleted as operation is considered idempotent.
+ test.Controller()
+ .Run(c => c.DeleteAsync(2.ToGuid()))
+ .AssertNoContent();
+ }
+
+ [Test]
+ public void F100_Patch_NotFound()
+ {
+ using var test = ApiTester.Create();
+
+ test.Controller()
+ .RunContent(c => c.PatchAsync(404.ToGuid(), null!), "{}", HttpConsts.MergePatchMediaTypeName)
+ .AssertNotFound();
+ }
+
+ [Test]
+ public void F110_Patch_Concurrency()
+ {
+ using var test = ApiTester.Create();
+
+ // Get current.
+ var v = test.Controller()
+ .Run(c => c.GetAsync(4.ToGuid()))
+ .AssertOK()
+ .GetValue()!;
+
+ // Patch it with errant etag.
+ v.FirstName += "X";
+
+ test.Controller()
+ .RunContent(c => c.PatchAsync(v.Id, null!), $"{{ \"firstName\": \"{v.FirstName}\" }}", new HttpRequestOptions { ETag = "ZZZZZZZZZZZZ" }, HttpConsts.MergePatchMediaTypeName)
+ .AssertPreconditionFailed();
+ }
+
+ [Test]
+ public void F120_Patch()
+ {
+ using var test = ApiTester.Create();
+
+ // Get current.
+ var v = test.Controller()
+ .Run(c => c.GetAsync(4.ToGuid()))
+ .AssertOK()
+ .GetValue()!;
+
+ // Patch it with errant etag.
+ v.FirstName += "X";
+
+ v = test.Controller()
+ .RunContent(c => c.PatchAsync(v.Id, null!), $"{{ \"firstName\": \"{v.FirstName}\" }}", new HttpRequestOptions { ETag = v.ETag }, HttpConsts.MergePatchMediaTypeName)
+ .AssertOK()
+ .Assert(v, "ETag")
+ .GetValue()!;
+
+ // Get again and check all.
+ test.Controller()
+ .Run(c => c.GetAsync(v.Id))
+ .AssertOK()
+ .Assert(v);
+ }
+
+ [Test]
+ public void G100_Verify_NotFound()
+ {
+ using var test = ApiTester.Create();
+
+ test.Controller()
+ .Run(c => c.VerifyAsync(404.ToGuid()))
+ .AssertNotFound();
+ }
+
+ [Test]
+ public void G100_Verify_Publish()
+ {
+ using var test = ApiTester.Create();
+ var imp = new InMemoryPublisher(test.Logger);
+
+ test.ReplaceScoped(_ => imp)
+ .Controller()
+ .Run(c => c.VerifyAsync(1.ToGuid()))
+ .AssertAccepted();
+
+ Assert.AreEqual(1, imp.GetNames().Length);
+ var e = imp.GetEvents("pendingVerifications");
+ Assert.AreEqual(1, e.Length);
+ ObjectComparer.Assert(new EmployeeVerificationRequest { Name = "Wendy", Age = 37, Gender = "F" }, e[0].Value);
+ }
+
+ [Test]
+ public void G100_Verify_Publish_WithExpectations()
+ {
+ using var test = ApiTester.Create();
+ test.UseExpectedEvents()
+ .Controller()
+ .ExpectDestinationEvent("pendingVerifications", new EventData { Value = new EmployeeVerificationRequest { Name = "Wendy", Age = 37, Gender = "F" } })
+ .ExpectStatusCode(System.Net.HttpStatusCode.Accepted)
+ .Run(c => c.VerifyAsync(1.ToGuid()));
+ }
+ }
+}
\ No newline at end of file
diff --git a/tools/CoreEx.Template/content/Company.AppName.UnitTest/EmployeeControllerTest2.cs b/tools/CoreEx.Template/content/Company.AppName.UnitTest/EmployeeControllerTest2.cs
new file mode 100644
index 00000000..7627eff2
--- /dev/null
+++ b/tools/CoreEx.Template/content/Company.AppName.UnitTest/EmployeeControllerTest2.cs
@@ -0,0 +1,390 @@
+using CoreEx.Entities;
+using CoreEx.Events;
+using CoreEx.Http;
+using Company.AppName.Api;
+using Company.AppName.Api.Controllers;
+using Company.AppName.Business.Models;
+using Company.AppName.Business.External.Contracts;
+using NUnit.Framework;
+using System;
+using System.Linq;
+using System.Threading.Tasks;
+using UnitTestEx;
+using UnitTestEx.Expectations;
+using UnitTestEx.NUnit;
+using Company.AppName.Business.Services;
+
+namespace Company.AppName.UnitTest
+{
+ [TestFixture]
+ [Category("WithDB")]
+ public class EmployeeControllerTest2
+ {
+ [OneTimeSetUp]
+ public static Task Init() => EmployeeControllerTest.Init();
+
+ [Test]
+ public void A100_Get_NotFound()
+ {
+ using var test = ApiTester.Create().ConfigureServices(sc => sc.ReplaceScoped());
+
+ test.Controller()
+ .Run(c => c.GetAsync(404.ToGuid()))
+ .AssertNotFound();
+ }
+
+ [Test]
+ public void A110_Get_Found()
+ {
+ using var test = ApiTester.Create().ConfigureServices(sc => sc.ReplaceScoped());
+
+ test.Controller()
+ .Run(c => c.GetAsync(1.ToGuid()))
+ .AssertOK()
+ .Assert(new Employee
+ {
+ Id = 1.ToGuid(),
+ Email = "w.jones@org.com",
+ FirstName = "Wendy",
+ LastName = "Jones",
+ Gender = "F",
+ Birthday = new DateTime(1985, 03, 18, 0, 0, 0, DateTimeKind.Unspecified),
+ StartDate = new DateTime(2000, 12, 11, 0, 0, 0, DateTimeKind.Unspecified),
+ PhoneNo = "(425) 612 8113"
+ }, nameof(Employee.ETag));
+ }
+
+ [Test]
+ public void A120_Get_NotModifed()
+ {
+ using var test = ApiTester.Create().ConfigureServices(sc => sc.ReplaceScoped());
+
+ var e = test.Controller()
+ .Run(c => c.GetAsync(1.ToGuid()))
+ .AssertOK()
+ .GetValue()!;
+
+ test.Controller()
+ .Run(c => c.GetAsync(e.Id), requestOptions: new HttpRequestOptions { ETag = e.ETag })
+ .AssertNotModified();
+ }
+
+ [Test]
+ public void A130_Get_IncludeFields()
+ {
+ using var test = ApiTester.Create().ConfigureServices(sc => sc.ReplaceScoped());
+
+ test.Controller()
+ .Run(c => c.GetAsync(1.ToGuid()), requestOptions: new HttpRequestOptions().Include("FirstName", "LastName"))
+ .AssertOK()
+ .AssertJson("{\"firstName\":\"Wendy\",\"lastName\":\"Jones\"}");
+ }
+
+ [Test]
+ public void B100_GetAll_All()
+ {
+ using var test = ApiTester.Create().ConfigureServices(sc => sc.ReplaceScoped());
+
+ var v = test.Controller()
+ .Run(c => c.GetAllAsync())
+ .AssertOK()
+ .GetValue();
+
+ Assert.IsNotNull(v?.Collection);
+ Assert.AreEqual(4, v!.Collection.Count);
+ Assert.AreEqual(new string[] { "Browne", "Jones", "Smith", "Smithers" }, v.Collection.Select(x => x.LastName).ToArray());
+ }
+
+ [Test]
+ public void B110_GetAll_Paging()
+ {
+ using var test = ApiTester.Create().ConfigureServices(sc => sc.ReplaceScoped());
+
+ var v = test.Controller()
+ .Run(c => c.GetAllAsync(), requestOptions: new HttpRequestOptions { Paging = PagingArgs.CreateSkipAndTake(1, 2, true) })
+ .AssertOK()
+ .GetValue();
+
+ Assert.IsNotNull(v?.Collection);
+ Assert.AreEqual(2, v!.Collection.Count);
+ Assert.AreEqual(new string[] { "Jones", "Smith" }, v.Collection.Select(x => x.LastName).ToArray());
+ Assert.IsNotNull(v.Paging);
+ Assert.AreEqual(4, v.Paging!.TotalCount);
+ }
+
+ [Test]
+ public void B120_GetAll_PagingAndIncludeFields()
+ {
+ using var test = ApiTester.Create().ConfigureServices(sc => sc.ReplaceScoped());
+
+ var v = test.Controller()
+ .Run(c => c.GetAllAsync(), requestOptions: new HttpRequestOptions { Paging = PagingArgs.CreateSkipAndTake(1, 2) }.Include("lastname"))
+ .AssertOK()
+ .AssertJson("[ { \"lastName\": \"Jones\" }, { \"lastName\": \"Smith\" } ]")
+ .GetValue();
+
+ Assert.IsNull(v!.Paging!.TotalCount); // No count requested.
+ }
+
+ [Test]
+ public void C100_Create_Error()
+ {
+ using var test = ApiTester.Create().ConfigureServices(sc => sc.ReplaceScoped());
+
+ var e = new Employee
+ {
+ FirstName = "Rebecca",
+ LastName = "Smythe",
+ Birthday = new DateTime(2000, 01, 01, 0, 0, 0, DateTimeKind.Unspecified),
+ Gender = "Z",
+ PhoneNo = "555 123 4567",
+ StartDate = new DateTime(2020, 01, 08, 0, 0, 0, DateTimeKind.Unspecified)
+ };
+
+ test.Controller()
+ .Run(c => c.CreateAsync(null!), e)
+ .AssertErrors(
+ new ApiError("Email", "'Email' must not be empty."),
+ new ApiError("Gender", "'Gender' is invalid."));
+ }
+
+ [Test]
+ public void C110_Create_Success()
+ {
+ using var test = ApiTester.Create().ConfigureServices(sc => sc.ReplaceScoped());
+
+ var e = new Employee
+ {
+ FirstName = "Rebecca",
+ LastName = "Smythe",
+ Birthday = new DateTime(2000, 01, 01, 0, 0, 0, DateTimeKind.Unspecified),
+ Gender = "M",
+ PhoneNo = "555 123 4567",
+ Email = "rs@email.com",
+ StartDate = new DateTime(2020, 01, 08, 0, 0, 0, DateTimeKind.Unspecified)
+ };
+
+ var v = test.Controller()
+ .Run(c => c.CreateAsync(null!), e)
+ .AssertCreated()
+ .Assert(e, "Id", "ETag")
+ .AssertLocationHeader(v => new Uri($"api/employees/{v!.Id}", UriKind.Relative))
+ .GetValue();
+
+ // Do a GET to make sure it is in the database and all fields equal.
+ test.Controller()
+ .Run(c => c.GetAsync(v!.Id))
+ .AssertOK()
+ .Assert(v);
+ }
+
+ [Test]
+ public void D100_Update_Error()
+ {
+ using var test = ApiTester.Create().ConfigureServices(sc => sc.ReplaceScoped());
+
+ var e = new Employee
+ {
+ FirstName = "Rebecca",
+ LastName = "Smythe",
+ Birthday = new DateTime(2000, 01, 01, 0, 0, 0, DateTimeKind.Unspecified),
+ Gender = "M",
+ PhoneNo = "555 123 4567",
+ StartDate = new DateTime(2020, 01, 08, 0, 0, 0, DateTimeKind.Unspecified)
+ };
+
+ test.Controller()
+ .Run(c => c.UpdateAsync(404.ToGuid(), null!), e)
+ .AssertErrors(
+ new ApiError("Email", "'Email' must not be empty."));
+ }
+
+ [Test]
+ public void D110_Update_NotFound()
+ {
+ using var test = ApiTester.Create().ConfigureServices(sc => sc.ReplaceScoped());
+
+ var e = new Employee
+ {
+ FirstName = "Rebecca",
+ LastName = "Smythe",
+ Birthday = new DateTime(2000, 01, 01, 0, 0, 0, DateTimeKind.Unspecified),
+ Gender = "M",
+ PhoneNo = "555 123 4567",
+ Email = "rs@email.com",
+ StartDate = new DateTime(2020, 01, 08, 0, 0, 0, DateTimeKind.Unspecified)
+ };
+
+ test.Controller()
+ .Run(c => c.UpdateAsync(404.ToGuid(), null!), e)
+ .AssertNotFound();
+ }
+
+ [Test]
+ public void D120_Update_Success()
+ {
+ using var test = ApiTester.Create().ConfigureServices(sc => sc.ReplaceScoped());
+
+ // Get current.
+ var v = test.Controller()
+ .Run(c => c.GetAsync(2.ToGuid()))
+ .AssertOK()
+ .GetValue()!;
+
+ // Update it.
+ v.FirstName += "X";
+
+ v = test.Controller()
+ .Run(c => c.UpdateAsync(v.Id, null!), v)
+ .AssertOK()
+ .Assert(v, "ETag")
+ .GetValue()!;
+
+ // Get again and check all.
+ test.Controller()
+ .Run(c => c.GetAsync(v.Id))
+ .AssertOK()
+ .Assert(v);
+ }
+
+ [Test]
+ public void D130_Update_ConcurrencyError()
+ {
+ using var test = ApiTester.Create().ConfigureServices(sc => sc.ReplaceScoped());
+
+ // Get current.
+ var v = test.Controller()
+ .Run(c => c.GetAsync(2.ToGuid()))
+ .AssertOK()
+ .GetValue()!;
+
+ // Update it with errant etag.
+ v.FirstName += "X";
+ v.ETag = "ZZZZZZZZZZZZ";
+
+ test.Controller()
+ .Run(c => c.UpdateAsync(v.Id, null!), v)
+ .AssertPreconditionFailed();
+ }
+
+ [Test]
+ public void E100_Delete()
+ {
+ using var test = ApiTester.Create().ConfigureServices(sc => sc.ReplaceScoped());
+
+ // Get current.
+ test.Controller()
+ .Run(c => c.GetAsync(2.ToGuid()))
+ .AssertOK();
+
+ // Delete it.
+ test.Controller()
+ .Run(c => c.DeleteAsync(2.ToGuid()))
+ .AssertNoContent();
+
+ // Must not exist.
+ test.Controller()
+ .Run(c => c.GetAsync(2.ToGuid()))
+ .AssertNotFound();
+
+ // Delete it again; should appear as if deleted as operation is considered idempotent.
+ test.Controller()
+ .Run(c => c.DeleteAsync(2.ToGuid()))
+ .AssertNoContent();
+ }
+
+ [Test]
+ public void F100_Patch_NotFound()
+ {
+ using var test = ApiTester.Create().ConfigureServices(sc => sc.ReplaceScoped());
+
+ test.Controller()
+ .RunContent(c => c.PatchAsync(404.ToGuid(), null!), "{}", HttpConsts.MergePatchMediaTypeName)
+ .AssertNotFound();
+ }
+
+ [Test]
+ public void F110_Patch_Concurrency()
+ {
+ using var test = ApiTester.Create().ConfigureServices(sc => sc.ReplaceScoped());
+
+ // Get current.
+ var v = test.Controller()
+ .Run(c => c.GetAsync(4.ToGuid()))
+ .AssertOK()
+ .GetValue()!;
+
+ // Patch it with errant etag.
+ v.FirstName += "X";
+
+ test.Controller()
+ .RunContent(c => c.PatchAsync(v.Id, null!), $"{{ \"firstName\": \"{v.FirstName}\" }}", new HttpRequestOptions { ETag = "ZZZZZZZZZZZZ" }, HttpConsts.MergePatchMediaTypeName)
+ .AssertPreconditionFailed();
+ }
+
+ [Test]
+ public void F120_Patch()
+ {
+ using var test = ApiTester.Create().ConfigureServices(sc => sc.ReplaceScoped());
+
+ // Get current.
+ var v = test.Controller()
+ .Run(c => c.GetAsync(4.ToGuid()))
+ .AssertOK()
+ .GetValue()!;
+
+ // Patch it with errant etag.
+ v.FirstName += "X";
+
+ v = test.Controller()
+ .RunContent(c => c.PatchAsync(v.Id, null!), $"{{ \"firstName\": \"{v.FirstName}\" }}", new HttpRequestOptions { ETag = v.ETag }, HttpConsts.MergePatchMediaTypeName)
+ .AssertOK()
+ .Assert(v, "ETag")
+ .GetValue()!;
+
+ // Get again and check all.
+ test.Controller()
+ .Run(c => c.GetAsync(v.Id))
+ .AssertOK()
+ .Assert(v);
+ }
+
+ [Test]
+ public void G100_Verify_NotFound()
+ {
+ using var test = ApiTester.Create().ConfigureServices(sc => sc.ReplaceScoped());
+
+ test.Controller()
+ .Run(c => c.VerifyAsync(404.ToGuid()))
+ .AssertNotFound();
+ }
+
+ [Test]
+ public void G100_Verify_Publish()
+ {
+ using var test = ApiTester.Create().ConfigureServices(sc => sc.ReplaceScoped());
+ var imp = new InMemoryPublisher(test.Logger);
+
+ test.ReplaceScoped(_ => imp)
+ .Controller()
+ .Run(c => c.VerifyAsync(1.ToGuid()))
+ .AssertAccepted();
+
+ Assert.AreEqual(1, imp.GetNames().Length);
+ var e = imp.GetEvents("pendingVerifications");
+ Assert.AreEqual(1, e.Length);
+ ObjectComparer.Assert(new EmployeeVerificationRequest { Name = "Wendy", Age = 37, Gender = "F" }, e[0].Value);
+ }
+
+ [Test]
+ public void G100_Verify_Publish_WithExpectations()
+ {
+ using var test = ApiTester.Create().ConfigureServices(sc => sc.ReplaceScoped());
+ test.UseExpectedEvents()
+ .Controller()
+ .ExpectDestinationEvent("pendingVerifications", new EventData { Value = new EmployeeVerificationRequest { Name = "Wendy", Age = 37, Gender = "F" } })
+ .ExpectStatusCode(System.Net.HttpStatusCode.Accepted)
+ .Run(c => c.VerifyAsync(1.ToGuid()));
+ }
+ }
+}
\ No newline at end of file
diff --git a/tools/CoreEx.Template/content/Company.AppName.UnitTest/EmployeeFunctionTest.cs b/tools/CoreEx.Template/content/Company.AppName.UnitTest/EmployeeFunctionTest.cs
new file mode 100644
index 00000000..6c62f4b7
--- /dev/null
+++ b/tools/CoreEx.Template/content/Company.AppName.UnitTest/EmployeeFunctionTest.cs
@@ -0,0 +1,361 @@
+using CoreEx.Entities;
+using CoreEx.Http;
+using CoreEx.WebApis;
+using DbEx.Migration;
+using DbEx.Migration.Data;
+using Microsoft.Extensions.Configuration;
+using Company.AppName.Business.Models;
+using Company.AppName.Functions;
+using Company.AppName.Functions.Functions;
+using NUnit.Framework;
+using System;
+using System.Linq;
+using System.Net.Http;
+using System.Threading.Tasks;
+using UnitTestEx;
+using UnitTestEx.NUnit;
+
+namespace Company.AppName.UnitTest
+{
+ [TestFixture]
+ [Category("WithDB")]
+ public class EmployeeFunctionTest
+ {
+ [OneTimeSetUp]
+ public async Task Init()
+ {
+ HttpConsts.IncludeFieldsQueryStringName = "include-fields";
+
+ using var test = FunctionTester.Create();
+ var cs = test.Configuration.GetConnectionString("Database");
+ if (await Database.Program.RunMigrator(cs, typeof(EmployeeControllerTest).Assembly, MigrationCommand.ResetAndAll.ToString()).ConfigureAwait(false) != 0)
+ Assert.Fail("Database migration failed.");
+ }
+
+ [Test]
+ public void A100_Get_NotFound()
+ {
+ using var test = FunctionTester.Create();
+
+ test.HttpTrigger()
+ .Run(f => f.GetAsync(test.CreateHttpRequest(HttpMethod.Get, $"api/employees/{404.ToGuid()}"), 404.ToGuid()))
+ .AssertNotFound();
+ }
+
+ [Test]
+ public void A110_Get_Found()
+ {
+ using var test = FunctionTester.Create();
+
+ test.HttpTrigger()
+ .Run(f => f.GetAsync(test.CreateHttpRequest(HttpMethod.Get, $"api/employees/{1.ToGuid()}"), 1.ToGuid()))
+ .AssertOK()
+ .Assert(new Employee
+ {
+ Id = 1.ToGuid(),
+ Email = "w.jones@org.com",
+ FirstName = "Wendy",
+ LastName = "Jones",
+ Gender = "F",
+ Birthday = new DateTime(1985, 03, 18, 0, 0, 0, DateTimeKind.Unspecified),
+ StartDate = new DateTime(2000, 12, 11, 0, 0, 0, DateTimeKind.Unspecified),
+ PhoneNo = "(425) 612 8113"
+ }, nameof(Employee.ETag));
+ }
+
+ [Test]
+ public void A120_Get_NotModified()
+ {
+ using var test = FunctionTester.Create();
+
+ var e = test.HttpTrigger()
+ .Run(f => f.GetAsync(test.CreateHttpRequest(HttpMethod.Get, $"api/employees/{1.ToGuid()}"), 1.ToGuid()))
+ .AssertOK()
+ .GetValue()!;
+
+ test.HttpTrigger()
+ .Run(f => f.GetAsync(test.CreateHttpRequest(HttpMethod.Get, $"api/employees/{1.ToGuid()}", new CoreEx.Http.HttpRequestOptions { ETag = e.ETag }), 1.ToGuid()))
+ .AssertNotModified();
+ }
+
+ [Test]
+ public void A130_Get_IncludeFields()
+ {
+ using var test = FunctionTester.Create();
+
+ var e = test.HttpTrigger()
+ .Run(f => f.GetAsync(test.CreateHttpRequest(HttpMethod.Get, $"api/employees/{1.ToGuid()}", new CoreEx.Http.HttpRequestOptions().Include("FirstName", "LastName")), 1.ToGuid()))
+ .AssertOK()
+ .AssertJson("{\"firstName\":\"Wendy\",\"lastName\":\"Jones\"}");
+ }
+
+ [Test]
+ public void B100_GetAll_All()
+ {
+ using var test = FunctionTester.Create();
+
+ var v = test.HttpTrigger()
+ .Run(f => f.GetAllAsync(test.CreateHttpRequest(HttpMethod.Get, "api/employees")))
+ .GetValue();
+
+ Assert.IsNotNull(v?.Collection);
+ Assert.AreEqual(4, v!.Collection.Count);
+ Assert.AreEqual(new string[] { "Browne", "Jones", "Smith", "Smithers" }, v.Collection.Select(x => x.LastName).ToArray());
+ }
+
+ [Test]
+ public void B110_GetAll_Paging()
+ {
+ using var test = FunctionTester.Create();
+
+ var v = test.HttpTrigger()
+ .Run(f => f.GetAllAsync(test.CreateHttpRequest(HttpMethod.Get, "api/employees", new CoreEx.Http.HttpRequestOptions { Paging = PagingArgs.CreateSkipAndTake(1, 2, true) })))
+ .AssertOK()
+ .GetValue();
+
+ Assert.IsNotNull(v?.Collection);
+ Assert.AreEqual(2, v!.Collection.Count);
+ Assert.AreEqual(new string[] { "Jones", "Smith" }, v.Collection.Select(x => x.LastName).ToArray());
+ Assert.IsNotNull(v.Paging);
+ Assert.AreEqual(4, v.Paging!.TotalCount);
+ }
+
+ [Test]
+ public void B120_GetAll_PagingAndIncludeFields()
+ {
+ using var test = FunctionTester.Create();
+
+ var v = test.HttpTrigger()
+ .Run(f => f.GetAllAsync(test.CreateHttpRequest(HttpMethod.Get, "api/employees", new CoreEx.Http.HttpRequestOptions { Paging = PagingArgs.CreateSkipAndTake(1, 2, false) }.Include("lastname"))))
+ .AssertOK()
+ .AssertJson("[ { \"lastName\": \"Jones\" }, { \"lastName\": \"Smith\" } ]")
+ .GetValue();
+
+ Assert.IsNull(v!.Paging!.TotalCount); // No count requested.
+ }
+
+ [Test]
+ public void C100_Create_Error()
+ {
+ using var test = FunctionTester.Create();
+
+ var e = new Employee
+ {
+ FirstName = "Rebecca",
+ LastName = "Smythe",
+ Birthday = new DateTime(2000, 01, 01, 0, 0, 0, DateTimeKind.Unspecified),
+ Gender = "M",
+ PhoneNo = "555 123 4567",
+ StartDate = new DateTime(2020, 01, 08, 0, 0, 0, DateTimeKind.Unspecified)
+ };
+
+ test.HttpTrigger()
+ .Run(c => c.CreateAsync(test.CreateJsonHttpRequest(HttpMethod.Post, "api/employees", e)))
+ .AssertErrors(
+ new ApiError("Email", "'Email' must not be empty."));
+ }
+
+ [Test]
+ public void C110_Create_Success()
+ {
+ using var test = FunctionTester.Create();
+
+ var e = new Employee
+ {
+ FirstName = "Rebecca",
+ LastName = "Smythe",
+ Birthday = new DateTime(2000, 01, 01, 0, 0, 0, DateTimeKind.Unspecified),
+ Gender = "M",
+ PhoneNo = "555 123 4567",
+ Email = "rs@email.com",
+ StartDate = new DateTime(2020, 01, 08, 0, 0, 0, DateTimeKind.Unspecified)
+ };
+
+ var v = test.HttpTrigger()
+ .Run(c => c.CreateAsync(test.CreateJsonHttpRequest(HttpMethod.Post, "api/employees", e)))
+ .AssertCreated()
+ .Assert(e, "Id", "ETag")
+ .AssertLocationHeader(v => new Uri($"employees/{v!.Id}", UriKind.Relative))
+ .GetValue();
+
+ // Do a GET to make sure it is in the database and all fields equal.
+ test.HttpTrigger()
+ .Run(f => f.GetAsync(test.CreateHttpRequest(HttpMethod.Get, $"api/employees/{v!.Id}"), v.Id))
+ .AssertOK()
+ .Assert(v);
+ }
+
+ [Test]
+ public void D100_Update_Error()
+ {
+ using var test = FunctionTester.Create();
+
+ var e = new Employee
+ {
+ FirstName = "Rebecca",
+ LastName = "Smythe",
+ Birthday = new DateTime(2000, 01, 01, 0, 0, 0, DateTimeKind.Unspecified),
+ Gender = "M",
+ PhoneNo = "555 123 4567",
+ StartDate = new DateTime(2020, 01, 08, 0, 0, 0, DateTimeKind.Unspecified)
+ };
+
+ test.HttpTrigger()
+ .Run(f => f.UpdateAsync(test.CreateJsonHttpRequest(HttpMethod.Put, $"api/employees/{404.ToGuid()}", e), 404.ToGuid()))
+ .AssertErrors(
+ new ApiError("Email", "'Email' must not be empty."));
+ }
+
+ [Test]
+ public void D110_Update_NotFound()
+ {
+ using var test = FunctionTester.Create();
+
+ var e = new Employee
+ {
+ FirstName = "Rebecca",
+ LastName = "Smythe",
+ Birthday = new DateTime(2000, 01, 01, 0, 0, 0, DateTimeKind.Unspecified),
+ Gender = "M",
+ PhoneNo = "555 123 4567",
+ Email = "rs@email.com",
+ StartDate = new DateTime(2020, 01, 08, 0, 0, 0, DateTimeKind.Unspecified)
+ };
+
+ test.HttpTrigger()
+ .Run(f => f.UpdateAsync(test.CreateJsonHttpRequest(HttpMethod.Put, $"api/employees/{404.ToGuid()}", e), 404.ToGuid()))
+ .AssertNotFound();
+ }
+
+ [Test]
+ public void D120_Update_Success()
+ {
+ using var test = FunctionTester.Create();
+
+ // Get current.
+ var v = test.HttpTrigger()
+ .Run(f => f.GetAsync(test.CreateHttpRequest(HttpMethod.Get, $"api/employees/{2.ToGuid()}"), 2.ToGuid()))
+ .AssertOK()
+ .GetValue()!;
+
+ // Update it.
+ v.FirstName += "X";
+
+ v = test.HttpTrigger()
+ .Run(f => f.UpdateAsync(test.CreateJsonHttpRequest(HttpMethod.Put, $"api/employees/{v.Id}", v), v.Id))
+ .AssertOK()
+ .Assert(v, "ETag")
+ .GetValue()!;
+
+ // Get again and check all.
+ test.HttpTrigger()
+ .Run(f => f.GetAsync(test.CreateHttpRequest(HttpMethod.Get, $"api/employees/{v.Id}"), v.Id))
+ .AssertOK()
+ .Assert(v);
+ }
+
+ [Test]
+ public void D130_Update_ConcurrencyError()
+ {
+ using var test = FunctionTester.Create();
+
+ // Get current.
+ var v = test.HttpTrigger()
+ .Run(f => f.GetAsync(test.CreateHttpRequest(HttpMethod.Get, $"api/employees/{2.ToGuid()}"), 2.ToGuid()))
+ .AssertOK()
+ .GetValue()!;
+
+ // Update it with errant etag.
+ v.FirstName += "X";
+ v.ETag = "ZZZZZZZZZZZZ";
+
+ test.HttpTrigger()
+ .Run(f => f.UpdateAsync(test.CreateJsonHttpRequest(HttpMethod.Put, $"api/employees/{v.Id}", v), v.Id))
+ .AssertPreconditionFailed();
+ }
+
+ [Test]
+ public void E100_Delete()
+ {
+ using var test = FunctionTester.Create();
+
+ // Get current.
+ test.HttpTrigger()
+ .Run(f => f.GetAsync(test.CreateHttpRequest(HttpMethod.Get, $"api/employees/{2.ToGuid()}"), 2.ToGuid()))
+ .AssertOK();
+
+ // Delete it.
+ test.HttpTrigger()
+ .Run(f => f.DeleteAsync(test.CreateHttpRequest(HttpMethod.Delete, $"api/employees/{2.ToGuid()}"), 2.ToGuid()))
+ .AssertNoContent();
+
+ // Must not exist.
+ test.HttpTrigger()
+ .Run(f => f.GetAsync(test.CreateHttpRequest(HttpMethod.Get, $"api/employees/{2.ToGuid()}"), 2.ToGuid()))
+ .AssertNotFound();
+
+ // Delete it again; should appear as if deleted as operation is considered idempotent.
+ test.HttpTrigger()
+ .Run(f => f.DeleteAsync(test.CreateHttpRequest(HttpMethod.Delete, $"api/employees/{2.ToGuid()}"), 2.ToGuid()))
+ .AssertNoContent();
+ }
+
+ [Test]
+ public void F100_Patch_NotFound()
+ {
+ using var test = FunctionTester.Create();
+
+ test.HttpTrigger