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 CallAsync(MockCallArgs args) var outputs = ImmutableDictionary.CreateBuilder(); outputs.AddRange(args.Args); - // mock responses for API calls switch (args.Token) { case "azure:keyvault/getKeyVault:getKeyVault": @@ -94,6 +95,14 @@ public Task CallAsync(MockCallArgs args) outputs.Add("domains", adJson); break; + case "azuread:index/getClientConfig:getClientConfig": + outputs.Add("objectId", "current-user-guid"); + break; + + case "azuread:index/getUser:getUser": + outputs.Add("id", "id-for-user"); + break; + case "azure-native:authorization:getClientConfig": outputs.Add("subscriptionId", SubscriptionId); break; diff --git a/samples/My.Hr/My.Hr.Infra.Tests/TestingExtensions.cs b/tools/CoreEx.Template/content/Company.AppName.Infra.Tests/TestingExtensions.cs similarity index 93% rename from samples/My.Hr/My.Hr.Infra.Tests/TestingExtensions.cs rename to tools/CoreEx.Template/content/Company.AppName.Infra.Tests/TestingExtensions.cs index 664c28f6..9de7483a 100644 --- a/samples/My.Hr/My.Hr.Infra.Tests/TestingExtensions.cs +++ b/tools/CoreEx.Template/content/Company.AppName.Infra.Tests/TestingExtensions.cs @@ -1,4 +1,4 @@ -namespace My.Hr.Infra.Tests; +namespace Company.AppName.Infra.Tests; public static class TestingExtensions { diff --git a/samples/My.Hr/My.Hr.Infra.Tests/Usings.cs b/tools/CoreEx.Template/content/Company.AppName.Infra.Tests/Usings.cs similarity index 100% rename from samples/My.Hr/My.Hr.Infra.Tests/Usings.cs rename to tools/CoreEx.Template/content/Company.AppName.Infra.Tests/Usings.cs diff --git a/samples/My.Hr/My.Hr.Infra/My.Hr.Infra.csproj b/tools/CoreEx.Template/content/Company.AppName.Infra/Company.AppName.Infra.csproj similarity index 91% rename from samples/My.Hr/My.Hr.Infra/My.Hr.Infra.csproj rename to tools/CoreEx.Template/content/Company.AppName.Infra/Company.AppName.Infra.csproj index 8fb22fea..6a6f35af 100644 --- a/samples/My.Hr/My.Hr.Infra/My.Hr.Infra.csproj +++ b/tools/CoreEx.Template/content/Company.AppName.Infra/Company.AppName.Infra.csproj @@ -22,7 +22,7 @@ - + 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() + .Run(f => f.PatchAsync(test.CreateHttpRequest(HttpMethod.Patch, $"api/employees/{404.ToGuid()}", "{}", HttpConsts.MergePatchMediaTypeName), 404.ToGuid())) + .AssertNotFound(); + } + + [Test] + public void F110_Patch_Concurrency() + { + using var test = FunctionTester.Create(); + + // Get current. + var v = test.HttpTrigger() + .Run(f => f.GetAsync(test.CreateHttpRequest(HttpMethod.Get, $"api/employees/{4.ToGuid()}"), 4.ToGuid())) + .AssertOK() + .GetValue()!; + + // Patch it with errant etag. + v.FirstName += "X"; + + var req = test.CreateHttpRequest(HttpMethod.Patch, $"api/employees/{v.Id}", $"{{ \"firstName\": \"{v.FirstName}\" }}", new CoreEx.Http.HttpRequestOptions { ETag = "ZZZZZZZZZZZZ" }, HttpConsts.MergePatchMediaTypeName); + test.HttpTrigger() + .Run(f => f.PatchAsync(req, v.Id)) + .AssertPreconditionFailed(); + } + + [Test] + public void F120_Patch() + { + using var test = FunctionTester.Create(); + + // Get current. + var v = test.HttpTrigger() + .Run(f => f.GetAsync(test.CreateHttpRequest(HttpMethod.Get, $"api/employees/{4.ToGuid()}"), 4.ToGuid())) + .AssertOK() + .GetValue()!; + + // Patch it with errant etag. + v.FirstName += "X"; + + var req = test.CreateHttpRequest(HttpMethod.Patch, $"api/employees/{v.Id}", $"{{ \"firstName\": \"{v.FirstName}\" }}", new CoreEx.Http.HttpRequestOptions { ETag = v.ETag }, HttpConsts.MergePatchMediaTypeName); + v = test.HttpTrigger() + .Run(f => f.PatchAsync(req, 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); + } + } +} \ No newline at end of file diff --git a/tools/CoreEx.Template/content/Company.AppName.UnitTest/HttpTriggerQueueVerificationFunctionTest.cs b/tools/CoreEx.Template/content/Company.AppName.UnitTest/HttpTriggerQueueVerificationFunctionTest.cs new file mode 100644 index 00000000..02b9d9e4 --- /dev/null +++ b/tools/CoreEx.Template/content/Company.AppName.UnitTest/HttpTriggerQueueVerificationFunctionTest.cs @@ -0,0 +1,30 @@ +using CoreEx.Events; +using Company.AppName.Business.External.Contracts; +using Company.AppName.Functions; +using NUnit.Framework; +using System.Net.Http; +using UnitTestEx.NUnit; + +namespace Company.AppName.UnitTest +{ + [TestFixture] + public class HttpTriggerQueueVerificationFunctionTest + { + [Test] + public void A110_Verify_Success() + { + var test = FunctionTester.Create(); + var imp = new InMemoryPublisher(test.Logger); + + test.ReplaceScoped(_ => imp) + .HttpTrigger() + .Run(f => f.RunAsync(test.CreateJsonHttpRequest(HttpMethod.Post, "employee/verify", new EmployeeVerificationRequest { Name = "Wendy", Age = 37, Gender = "F" }))) + .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); + } + } +} \ No newline at end of file diff --git a/tools/CoreEx.Template/content/Company.AppName.UnitTest/ReferenceDataControllerTest.cs b/tools/CoreEx.Template/content/Company.AppName.UnitTest/ReferenceDataControllerTest.cs new file mode 100644 index 00000000..473597e8 --- /dev/null +++ b/tools/CoreEx.Template/content/Company.AppName.UnitTest/ReferenceDataControllerTest.cs @@ -0,0 +1,99 @@ +using CoreEx.Http; +using Company.AppName.Api; +using Company.AppName.Api.Controllers; +using Company.AppName.Business.Models; +using NUnit.Framework; +using System.Linq; +using System.Threading.Tasks; +using UnitTestEx.NUnit; + +namespace Company.AppName.UnitTest +{ + [TestFixture] + [Category("WithDB")] + public class ReferenceDataControllerTest + { + [OneTimeSetUp] + public Task Init() => EmployeeControllerTest.Init(); + + [Test] + public void A100_USState_All() + { + using var test = ApiTester.Create().UseJsonSerializer(new CoreEx.Text.Json.ReferenceDataContentJsonSerializer()); + + var v = test.Controller() + .Run(c => c.USStateGetAll(null, null)) + .AssertOK() + .GetValue()!; + + Assert.AreEqual(50, v.Length); + } + + [Test] + public void A110_USState_Codes() + { + using var test = ApiTester.Create().UseJsonSerializer(new CoreEx.Text.Json.ReferenceDataContentJsonSerializer()); + + var v = test.Controller() + .Run(c => c.USStateGetAll(new string[] { "WA", "CO" }, null)) + .AssertOK() + .GetValue()!; + + Assert.AreEqual(2, v.Length); + Assert.AreEqual(new string[] { "CO", "WA" }, v.Select(x => x.Code)); + } + + [Test] + public void A120_USState_Text() + { + using var test = ApiTester.Create().UseJsonSerializer(new CoreEx.Text.Json.ReferenceDataContentJsonSerializer()); + + var v = test.Controller() + .Run(c => c.USStateGetAll(null, "*or*")) + .AssertOK() + .GetValue()!; + + Assert.AreEqual(8, v.Length); + var x = v.Select(x => x.Code); + Assert.AreEqual(new string[] { "CA", "CO", "FL", "GA", "NY", "NC", "ND", "OR" }, v.Select(x => x.Code)); + } + + [Test] + public void A130_USState_FieldsAndNotModified() + { + using var test = ApiTester.Create().UseJsonSerializer(new CoreEx.Text.Json.ReferenceDataContentJsonSerializer()); + + var r = test.Controller() + .Run(c => c.USStateGetAll(new string[] { "WA", "CO" }, null), new HttpRequestOptions().Include("code", "text")) + .AssertOK() + .AssertJson("[{\"code\":\"CO\",\"text\":\"Colorado\"},{\"code\":\"WA\",\"text\":\"Washington\"}]"); + + test.Controller() + .Run(c => c.USStateGetAll(new string[] { "WA", "CO" }, null), new HttpRequestOptions { ETag = r.Response?.Headers?.ETag?.Tag }.Include("code", "text")) + .AssertNotModified(); + } + + [Test] + public void B100_Gender_All() + { + using var test = ApiTester.Create().UseJsonSerializer(new CoreEx.Text.Json.ReferenceDataContentJsonSerializer()); + + var v = test.Controller() + .Run(c => c.GenderGetAll(null, null)) + .AssertOK() + .GetValue()!; + + Assert.AreEqual(3, v.Length); + } + + [Test] + public void C100_Named() + { + using var test = ApiTester.Create().UseJsonSerializer(new CoreEx.Text.Json.ReferenceDataContentJsonSerializer()); + + var r = test.Controller() + .Run(c => c.GetNamed(), new HttpRequestOptions { UrlQueryString = "gender&usstate" }) + .AssertOK(); + } + } +} \ No newline at end of file diff --git a/tools/CoreEx.Template/content/Company.AppName.UnitTest/Resources/VerificationResult.Unix.json b/tools/CoreEx.Template/content/Company.AppName.UnitTest/Resources/VerificationResult.Unix.json new file mode 100644 index 00000000..1aab9ecb --- /dev/null +++ b/tools/CoreEx.Template/content/Company.AppName.UnitTest/Resources/VerificationResult.Unix.json @@ -0,0 +1,29 @@ +{ + "age": 64, + "gender": "female", + "genderProbability": 0.97, + "country": [ + { + "country_Id": "SV", + "probability": 0.07477553 + }, + { + "country_Id": "GT", + "probability": 0.07223318 + }, + { + "country_Id": "NL", + "probability": 0.067494206 + } + ], + "verificationMessages": [ + "Performed verification for Wendy, F age 37. \n Engine predicted age was 64. \n Engine predicted gender was female with 97% probability.\n Most likely nationality of Wendy is SV with 7% probability", + "Employee age (37) is not within range of 10 years of predicted age: 64", + "Employee gender (F) doesn\u0027t match predicted gender: female" + ], + "request": { + "name": "Wendy", + "age": 37, + "gender": "F" + } +} \ No newline at end of file diff --git a/tools/CoreEx.Template/content/Company.AppName.UnitTest/Resources/VerificationResult.Win32.json b/tools/CoreEx.Template/content/Company.AppName.UnitTest/Resources/VerificationResult.Win32.json new file mode 100644 index 00000000..651f0321 --- /dev/null +++ b/tools/CoreEx.Template/content/Company.AppName.UnitTest/Resources/VerificationResult.Win32.json @@ -0,0 +1,29 @@ +{ + "age": 64, + "gender": "female", + "genderProbability": 0.97, + "country": [ + { + "country_Id": "SV", + "probability": 0.07477553 + }, + { + "country_Id": "GT", + "probability": 0.07223318 + }, + { + "country_Id": "NL", + "probability": 0.067494206 + } + ], + "verificationMessages": [ + "Performed verification for Wendy, F age 37. \r\n Engine predicted age was 64. \r\n Engine predicted gender was female with 97% probability.\r\n Most likely nationality of Wendy is SV with 7% probability", + "Employee age (37) is not within range of 10 years of predicted age: 64", + "Employee gender (F) doesn\u0027t match predicted gender: female" + ], + "request": { + "name": "Wendy", + "age": 37, + "gender": "F" + } +} \ No newline at end of file diff --git a/tools/CoreEx.Template/content/Company.AppName.UnitTest/ServiceBusExecuteVerificationFunctionTest.cs b/tools/CoreEx.Template/content/Company.AppName.UnitTest/ServiceBusExecuteVerificationFunctionTest.cs new file mode 100644 index 00000000..a2721a35 --- /dev/null +++ b/tools/CoreEx.Template/content/Company.AppName.UnitTest/ServiceBusExecuteVerificationFunctionTest.cs @@ -0,0 +1,79 @@ +using CoreEx.Events; +using System; +using System.Net.Http; +using Microsoft.Azure.WebJobs.ServiceBus; +using Moq; +using Company.AppName.Business.External.Contracts; +using Company.AppName.Functions; +using NUnit.Framework; +using UnitTestEx.NUnit; + +namespace Company.AppName.UnitTest +{ + [TestFixture] + public class ServiceBusExecuteVerificationFunctionTest + { + [Test] + public void A110_Verify_Success() + { + var test = FunctionTester.Create(); + var imp = new InMemoryPublisher(test.Logger); + var evr = new EmployeeVerificationRequest { Name = "Wendy", Age = 37, Gender = "F" }; + var sbm = test.CreateServiceBusMessage(evr); + var sba = new Mock(); + + var mcf = MockHttpClientFactory.Create(); + var agify = mcf.CreateClient("Agify"); + var nationalize = mcf.CreateClient("Nationalize"); + var genderize = mcf.CreateClient("Genderize"); + + agify.Request(HttpMethod.Get, $"https://api.agify.mock.io/?name={evr.Name}") + .Respond.WithJson(new + { + age = 64, + count = 82293, + name = evr.Name + }); + nationalize.Request(HttpMethod.Get, $"https://api.nationalize.mock.io/?name={evr.Name}") + .Respond.WithJson(new + { + country = new[]{ + new { + country_Id= "SV", + probability= 0.07477553 + }, + new { + country_Id= "GT", + probability= 0.07223318 + }, + new { + country_Id= "NL", + probability= 0.067494206 + }}, + name = evr.Name + }); + genderize.Request(HttpMethod.Get, $"https://api.genderize.mock.io/?name={evr.Name}") + .Respond.WithJson(new + { + count = 176697, + gender = "female", + name = evr.Name, + probability = 0.97 + }); + + test.ReplaceScoped(_ => imp) + .ReplaceHttpClientFactory(mcf) + .ServiceBusTrigger() + .Run(f => f.RunAsync(sbm, sba.Object)) + .AssertSuccess(); + + Assert.AreEqual(1, imp.GetNames().Length); + var e = imp.GetEvents("verificationResults"); + Assert.AreEqual(1, e.Length); + if (Environment.OSVersion.Platform == PlatformID.Unix) + ObjectComparer.Assert(UnitTestEx.Resource.GetJsonValue("VerificationResult.Unix.json"), e[0].Value); + else + ObjectComparer.Assert(UnitTestEx.Resource.GetJsonValue("VerificationResult.Win32.json"), e[0].Value); + } + } +} \ No newline at end of file diff --git a/tools/CoreEx.Template/content/Company.AppName.UnitTest/appsettings.unittest.json b/tools/CoreEx.Template/content/Company.AppName.UnitTest/appsettings.unittest.json new file mode 100644 index 00000000..cf829b3f --- /dev/null +++ b/tools/CoreEx.Template/content/Company.AppName.UnitTest/appsettings.unittest.json @@ -0,0 +1,14 @@ +{ + "DOTNET_ENVIRONMENT": "Development", + "VerificationResultsQueueName": "verificationResults", + "VerificationQueueName": "pendingVerifications", + "ServiceBusConnection__fullyQualifiedNamespace": "topsecret", + "AgifyApiEndpointUri": "https://api.agify.mock.io", + "NationalizeApiClientApiEndpointUri": "https://api.nationalize.mock.io", + "GenderizeApiClientApiEndpointUri": "https://api.genderize.mock.io", + "logging": { + "logLevel": { + "default": "debug" + } + } +} \ No newline at end of file diff --git a/tools/CoreEx.Template/content/Company.AppName.sln b/tools/CoreEx.Template/content/Company.AppName.sln new file mode 100644 index 00000000..141d4812 --- /dev/null +++ b/tools/CoreEx.Template/content/Company.AppName.sln @@ -0,0 +1,58 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 16 +VisualStudioVersion = 16.0.30114.105 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Company.AppName.Api", "Company.AppName.Api\Company.AppName.Api.csproj", "{F69909C8-9E50-4A26-8609-5B25D4F8C315}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Company.AppName.Database", "Company.AppName.Database\Company.AppName.Database.csproj", "{092B22E9-F40D-4155-B174-EED58F0F3207}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Company.AppName.Business", "Company.AppName.Business\Company.AppName.Business.csproj", "{BA634B6B-E0CF-4066-A225-E6D9C43D7F0B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Company.AppName.Functions", "Company.AppName.Functions\Company.AppName.Functions.csproj", "{A62BAA55-0737-4671-BF31-89D4BE7C4097}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Company.AppName.Infra", "Company.AppName.Infra\Company.AppName.Infra.csproj", "{E448EFD6-5CA6-4C71-B575-1149DD7181C6}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Company.AppName.Infra.Tests", "Company.AppName.Infra.Tests\Company.AppName.Infra.Tests.csproj", "{01B0FC8E-738D-47BB-AA57-F880532A501D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Company.AppName.UnitTest", "Company.AppName.UnitTest\Company.AppName.UnitTest.csproj", "{E745B80D-DD85-4A83-8C9C-A6F8564EBDCF}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {F69909C8-9E50-4A26-8609-5B25D4F8C315}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F69909C8-9E50-4A26-8609-5B25D4F8C315}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F69909C8-9E50-4A26-8609-5B25D4F8C315}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F69909C8-9E50-4A26-8609-5B25D4F8C315}.Release|Any CPU.Build.0 = Release|Any CPU + {092B22E9-F40D-4155-B174-EED58F0F3207}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {092B22E9-F40D-4155-B174-EED58F0F3207}.Debug|Any CPU.Build.0 = Debug|Any CPU + {092B22E9-F40D-4155-B174-EED58F0F3207}.Release|Any CPU.ActiveCfg = Release|Any CPU + {092B22E9-F40D-4155-B174-EED58F0F3207}.Release|Any CPU.Build.0 = Release|Any CPU + {BA634B6B-E0CF-4066-A225-E6D9C43D7F0B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BA634B6B-E0CF-4066-A225-E6D9C43D7F0B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BA634B6B-E0CF-4066-A225-E6D9C43D7F0B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BA634B6B-E0CF-4066-A225-E6D9C43D7F0B}.Release|Any CPU.Build.0 = Release|Any CPU + {A62BAA55-0737-4671-BF31-89D4BE7C4097}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {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 + {E745B80D-DD85-4A83-8C9C-A6F8564EBDCF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E745B80D-DD85-4A83-8C9C-A6F8564EBDCF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E745B80D-DD85-4A83-8C9C-A6F8564EBDCF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E745B80D-DD85-4A83-8C9C-A6F8564EBDCF}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/tools/CoreEx.Template/content/Docker.md b/tools/CoreEx.Template/content/Docker.md new file mode 100644 index 00000000..21269492 --- /dev/null +++ b/tools/CoreEx.Template/content/Docker.md @@ -0,0 +1,58 @@ +# About + +To run with docker-compose: + +```bash +docker-compose -f docker-compose.yml -f docker-compose.override.yml -f docker-compose.local.override.yml up +``` + +where `docker-compose.local.override.yml` should include connection string to service bus: + +```yaml +version: '3.4' + +services: + app-functions: + environment: + - ServiceBusConnection=Endpoint=sb://Company-AppName.servicebus.windows.net/;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=xxxxxx + + app-api: + environment: + - ServiceBusConnection=Endpoint=sb://Company-AppName.servicebus.windows.net/;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=xxxxxx +``` + +Service Bus should have `pendingverifications` queue used by *Company.AppName* sample. + +## To build + +```bash +docker-compose -f docker-compose.yml -f docker-compose.override.yml -f docker-compose.local.override.yml build --build-arg LOCAL=true +``` + +## Services + +Available services: + +* Database at port 5433 +* API at port 5103 +* Functions at 5104 + +Sample curl commands: + +### Function + +```bash +curl localhost:5104/api/health # to [get] to 'HealthInfo' +curl localhost:5104/api/employee/verify # to [post] to 'HttpTriggerQueueVerificationFunction' +curl localhost:5104/api/oauth2-redirect.html # to [GET] to 'OAuth2Redirect' +curl localhost:5104/api/openapi/{version}.{extension} # to [GET] to 'OpenApiDocument' +curl localhost:5104/api/swagger.{extension} # to [GET] to 'SwaggerDocument' +curl localhost:5104/api/swagger/ui # to [GET] to 'SwaggerUI' +``` + +### API + +```bash +curl localhost:5103/health # to [get] to 'HealthInfo' +curl localhost:5103/swagger/index.html # to [GET] to 'SwaggerUI' +``` diff --git a/tools/CoreEx.Template/content/_azuredevops/pipeline-build.yml b/tools/CoreEx.Template/content/_azuredevops/pipeline-build.yml new file mode 100644 index 00000000..1c3b0809 --- /dev/null +++ b/tools/CoreEx.Template/content/_azuredevops/pipeline-build.yml @@ -0,0 +1,120 @@ +name: $(BuildDefinitionName)_$(SourceBranchName)_$(Date:yyyyMMdd)$(Rev:.r) + +pool: + vmImage: ubuntu-latest + +variables: + buildConfiguration: 'Release' + +trigger: + branches: + include: + - develop + - main + - test + - preprod + +jobs: +- job: BuildJob + displayName: Build, Test and Publish apps + workspace: + clean: outputs + steps: + - task: CopyFiles@2 + displayName: Copy Files + inputs: + contents: $(Build.Repository.LocalPath)/** + targetFolder: $(Build.ArtifactStagingDirectory) + + - task: NuGetAuthenticate@0 + + - task: DotNetCoreCLI@2 + displayName: Restore + inputs: + command: 'restore' + projects: '*.sln' + # vstsFeed: '5c1bf9a9-e22e-4f76-9993-1a6b447130f8/c1502c9b-c232-4f35-8045-b01575cce6bc' # uncomment when using ADO Artifacts feed + + - task: DotNetCoreCLI@2 + displayName: Build + inputs: + command: build + projects: '*.sln' + arguments: '--configuration $(buildConfiguration)' + + - task: Bash@3 + displayName: 'Run SQL Server in docker container' + inputs: + targetType: 'inline' + script: | + docker pull mcr.microsoft.com/mssql/server:2022-latest + docker run -e "ACCEPT_EULA=Y" -e "SA_PASSWORD=sAPWD23.^0" --name sqlserver -p 1433:1433 -d mcr.microsoft.com/mssql/server:2022-latest + + # wait for DB to start + chmod +x ./Company.AppName.Database/wait-for-it.sh + ./Company.AppName.Database/wait-for-it.sh localhost:1433 -t 30 -- sleep 10 && echo "db is up" + + echo '##vso[task.setvariable variable=ConnectionStrings__Database]Data Source=localhost,1433;Initial Catalog=My.Hr;User id=sa;Password=sAPWD23.^0;TrustServerCertificate=true' + + mkdir -p $(Build.SourcesDirectory)/TestResults/Coverage/ + + - task: DotNetCoreCLI@2 + displayName: 'Run unit tests - $(buildConfiguration)' + inputs: + command: 'test' + arguments: '-c $(buildConfiguration) --no-build --verbosity normal /p:CollectCoverage=true /p:CoverletOutput=$(Build.SourcesDirectory)/TestResults/Coverage/coverageApp.json' + publishTestResults: true + projects: '**/*.UnitTest.csproj' + + - task: DotNetCoreCLI@2 + displayName: 'Run infrastructure tests - $(buildConfiguration)' + inputs: + command: 'test' + arguments: '-c $(buildConfiguration) --no-build --verbosity normal /p:MergeWith="$(Build.SourcesDirectory)/TestResults/Coverage/coverageApp.json" /p:CollectCoverage=true /p:CoverletOutputFormat=cobertura%2cjson /p:CoverletOutput=$(Build.SourcesDirectory)/TestResults/Coverage/' + publishTestResults: true + projects: '**/*.Infra.Tests.csproj' + + - task: Bash@3 + displayName: 'display code coverage files' + inputs: + targetType: 'inline' + script: | + # Write your commands here + + ls -R $(Build.SourcesDirectory)/TestResults/Coverage/ + + - task: PublishCodeCoverageResults@1 + displayName: 'Publish code coverage report' + inputs: + codeCoverageTool: 'Cobertura' + summaryFileLocation: '$(Build.SourcesDirectory)/TestResults/Coverage/**/*.cobertura.xml' + pathToSources: '$(Build.SourcesDirectory)' + reportDirectory: '$(Build.SourcesDirectory)/TestResults/Report' + + - task: DotNetCoreCLI@2 + displayName: 'Publish function app' + inputs: + command: 'publish' + projects: '**/Company.AppName.Functions.csproj' + publishWebProjects: false + arguments: '--configuration $(buildConfiguration) --output $(Build.ArtifactStagingDirectory)' + + - task: PublishBuildArtifacts@1 + displayName: 'Publish function app artifact' + inputs: + pathToPublish: '$(Build.ArtifactStagingDirectory)/Company.AppName.Functions.zip' + artifactName: Company-AppName-Functions_Package + + - task: DotNetCoreCLI@2 + displayName: 'Publish appservice app' + inputs: + command: 'publish' + projects: '**/Company.AppName.Api.csproj' + publishWebProjects: false + arguments: '--configuration $(buildConfiguration) --output $(Build.ArtifactStagingDirectory)' + + - task: PublishBuildArtifacts@1 + displayName: 'Publish appservice app artifact' + inputs: + pathToPublish: '$(Build.ArtifactStagingDirectory)/Company.AppName.Api.zip' + artifactName: Company-AppName-Api_Package \ No newline at end of file diff --git a/tools/CoreEx.Template/content/_azuredevops/pipeline-infra-destroy.yml b/tools/CoreEx.Template/content/_azuredevops/pipeline-infra-destroy.yml new file mode 100644 index 00000000..c7cce693 --- /dev/null +++ b/tools/CoreEx.Template/content/_azuredevops/pipeline-infra-destroy.yml @@ -0,0 +1,29 @@ +# requires following env variables: +# * secret variable - PULUMI_CONFIG_PASSPHRASE - KeyVault can be used instead of pass phrase, see: https://www.pulumi.com/docs/intro/concepts/secrets/#azure-key-vault + +name: Infra-Destroy_$(Date:yyyyMMdd)$(Rev:.r) + +parameters: +- name: stack_name + type: string + displayName: Name of the stack to delete +- name: env + type: string + displayName: Name of the environment in ADO - env, dev, etc. + +variables: +- name: buildConfiguration + value: 'Release' + +trigger: none + + +stages: +- stage: Delete + jobs: + - template: templates/infra-destroy-template.yml + parameters: + AzureSubscription: $(AzureSubscription) # replace with the name of service connection + env: ${{ parameters.env }} + storageAccountName: pulumistatestore112 + stack_name: ${{ parameters.stack_name }} \ No newline at end of file diff --git a/tools/CoreEx.Template/content/_azuredevops/pipeline-infra.yml b/tools/CoreEx.Template/content/_azuredevops/pipeline-infra.yml new file mode 100644 index 00000000..7873e98b --- /dev/null +++ b/tools/CoreEx.Template/content/_azuredevops/pipeline-infra.yml @@ -0,0 +1,33 @@ +# requires following env variables: +# * secret variable - PULUMI_CONFIG_PASSPHRASE - KeyVault can be used instead of pass phrase, see: https://www.pulumi.com/docs/intro/concepts/secrets/#azure-key-vault + +name: Infra-Deploy-$(SourceBranchName)_$(Date:yyyyMMdd)$(Rev:.r) + +variables: +- name: buildConfiguration + value: 'Release' + +trigger: + branches: + include: + - develop + - main + - test + - preprod + +stages: +- stage: Dev + jobs: + - template: templates/infra-template.yml + parameters: + AzureSubscription: $(AzureSubscription) # replace with the name of service connection + env: dev + storageAccountName: pulumistatestore112 # set to your azure storage account name that contains "state" container + +- stage: Test + jobs: + - template: templates/infra-template.yml + parameters: + AzureSubscription: $(AzureSubscription) # replace with the name of service connection + env: test + storageAccountName: pulumistatestore112 # set to your azure storage account name that contains "state" container diff --git a/tools/CoreEx.Template/content/_azuredevops/pipeline-release.yml b/tools/CoreEx.Template/content/_azuredevops/pipeline-release.yml new file mode 100644 index 00000000..c44e4eaf --- /dev/null +++ b/tools/CoreEx.Template/content/_azuredevops/pipeline-release.yml @@ -0,0 +1,46 @@ +name: Apps-Deploy-$(SourceBranchName)_$(Date:yyyyMMdd)$(Rev:.r) + +variables: +- name: vmImageName + value: 'ubuntu-latest' + +# Explicitly set none for repository trigger +trigger: none + +resources: + pipelines: + - pipeline: 'Company-AppName-Infra' # Name of the pipeline resource (alias) + source: 'Infra Deployment' # Name of the triggering pipeline that created infrastructure + - pipeline: 'Company-AppName-Apps' # Name of the pipeline resource (alias) + source: 'Application build' # Name of the triggering pipeline that created application packages + trigger: + branches: + - develop + - test + - preprod + - main + +# Note: Azure Service connection passed as param due to: https://developercommunity.visualstudio.com/t/using-a-variable-for-the-service-connection-result/676259 + +stages: +- stage: Dev + jobs: + - template: templates/app-template.yml + parameters: + AzureSubscription: $(AzureSubscription) # replace with the name of service connection + env: dev + - template: templates/function-template.yml + parameters: + AzureSubscription: $(AzureSubscription) # replace with the name of service connection + env: dev + +- stage: Test + jobs: + - template: templates/app-template.yml + parameters: + AzureSubscription: $(AzureSubscription) # replace with the name of service connection + env: test + - template: templates/function-template.yml + parameters: + AzureSubscription: $(AzureSubscription) # replace with the name of service connection + env: test diff --git a/tools/CoreEx.Template/content/_azuredevops/pull_request_template/branches/main.md b/tools/CoreEx.Template/content/_azuredevops/pull_request_template/branches/main.md new file mode 100644 index 00000000..b80ff458 --- /dev/null +++ b/tools/CoreEx.Template/content/_azuredevops/pull_request_template/branches/main.md @@ -0,0 +1,27 @@ +# Note + +> This PR will commit changes to the "MAIN" branch, which is deployed to PRODUCTION environment. +> Required approvers will automatically by added by ADO to Pull Request targeting test/preprod/main branches. + +## Summary of the Issue + +[The summary highlights the defect and observed failure. If the bug is recorded in ADO or there is a ticket, then provide the link. Here are a few examples:] + +1. *DO order from EFG order entry are getting created for orders that are rejected by Secura for fraud* +2. *GFS orders rejected by the store are not being updated to RE hold code* + +## Issue Details + +**Root Cause/Fix details**: [Outline the Root Cause/fix that was done to address the problem statement above.] + +**Test plan**: [Outline a high-level test plan to validate the issue. If there is a test plan already in ADO, then please share the link.] + +**Impact to other functions**: [List any other functional area(if any) that could be impacted with the code fix.] + +## Tips + +Here are some tips: + +* Use plain language. +* Make it simple, clear, and easily understandable. Don’t load with heavy technical terms. +* Keep the release notes short and precise. Be precise and include only the most relevant and important information. diff --git a/tools/CoreEx.Template/content/_azuredevops/pull_request_template/branches/preprod.md b/tools/CoreEx.Template/content/_azuredevops/pull_request_template/branches/preprod.md new file mode 100644 index 00000000..f28c2c5c --- /dev/null +++ b/tools/CoreEx.Template/content/_azuredevops/pull_request_template/branches/preprod.md @@ -0,0 +1,27 @@ +# Note + +> This PR will commit changes to the "PREPROD" branch, which is deployed to PREPROD environment. +> Required approvers will automatically by added by ADO to Pull Request targeting test/preprod/main branches. + +## Summary of the Issue + +[The summary highlights the defect and observed failure. If the bug is recorded in ADO or there is a ticket, then provide the link. Here are a few examples:] + +1. *DO order from EFG order entry are getting created for orders that are rejected by Secura for fraud* +2. *GFS orders rejected by the store are not being updated to RE hold code* + +## Issue Details + +**Root Cause/Fix details**: [Outline the Root Cause/fix that was done to address the problem statement above.] + +**Test plan**: [Outline a high-level test plan to validate the issue. If there is a test plan already in ADO, then please share the link.] + +**Impact to other functions**: [List any other functional area(if any) that could be impacted with the code fix.] + +## Tips + +Here are some tips: + +* Use plain language. +* Make it simple, clear, and easily understandable. Don’t load with heavy technical terms. +* Keep the release notes short and precise. Be precise and include only the most relevant and important information. diff --git a/tools/CoreEx.Template/content/_azuredevops/pull_request_template/branches/test.md b/tools/CoreEx.Template/content/_azuredevops/pull_request_template/branches/test.md new file mode 100644 index 00000000..287dc897 --- /dev/null +++ b/tools/CoreEx.Template/content/_azuredevops/pull_request_template/branches/test.md @@ -0,0 +1,27 @@ +# Note + +> This PR will commit changes to the "TEST" branch, which is deployed to TEST environment. +> Required approvers will automatically by added by ADO to Pull Request targeting test/preprod/main branches. + +## Summary of the Issue + +[The summary highlights the defect and observed failure. If the bug is recorded in ADO or there is a ticket, then provide the link. Here are a few examples:] + +1. *DO order from EFG order entry are getting created for orders that are rejected by Secura for fraud* +2. *GFS orders rejected by the store are not being updated to RE hold code* + +## Issue Details + +**Root Cause/Fix details**: [Outline the Root Cause/fix that was done to address the problem statement above.] + +**Test plan**: [Outline a high-level test plan to validate the issue. If there is a test plan already in ADO, then please share the link.] + +**Impact to other functions**: [List any other functional area(if any) that could be impacted with the code fix.] + +## Tips + +Here are some tips: + +* Use plain language. +* Make it simple, clear, and easily understandable. Don’t load with heavy technical terms. +* Keep the release notes short and precise. Be precise and include only the most relevant and important information. diff --git a/tools/CoreEx.Template/content/_azuredevops/pull_request_template/pull_request_template.md b/tools/CoreEx.Template/content/_azuredevops/pull_request_template/pull_request_template.md new file mode 100644 index 00000000..859d201f --- /dev/null +++ b/tools/CoreEx.Template/content/_azuredevops/pull_request_template/pull_request_template.md @@ -0,0 +1,7 @@ +Thank you for your contribution to the Company AppName repository. +Before submitting this PR, please make sure: + +- [ ] Your code builds clean without any errors or warnings +- [ ] You are using approved terminology +- [ ] You have added unit tests +- [ ] You've added required configuration to Azure App Config diff --git a/tools/CoreEx.Template/content/_azuredevops/templates/app-template.yml b/tools/CoreEx.Template/content/_azuredevops/templates/app-template.yml new file mode 100644 index 00000000..525752a6 --- /dev/null +++ b/tools/CoreEx.Template/content/_azuredevops/templates/app-template.yml @@ -0,0 +1,92 @@ +parameters: +- name: AzureSubscription + type: string +- name: env + type: string + displayName: 'Environment shorthand - dev, test, etc.' + +jobs: +- deployment: Deploy_App + displayName: "Deploy app service to ${{ parameters.env }}" + environment: ${{ parameters.env }} + variables: + - name: global_buildDate + value: $[format('{0:yyyy}-{0:MM}-{0:dd}T{0:HH}:{0:mm}:{0:ss}', pipeline.startTime)] + # - group: VG-Company-AppName-${{ parameters.env }} # uncomment to use variable groups in ADO + pool: + vmImage: ubuntu-latest + strategy: + runOnce: + deploy: + steps: + + - task: Bash@3 + displayName: 'Setup variables' + inputs: + targetType: 'inline' + script: | + source $(Pipeline.Workspace)/Company-AppName-Infra/Company-AppName-Infra_Package-${{ parameters.env }}/setup-${{ parameters.env }}.sh + + - task: Bash@3 + displayName: 'Display variables' + inputs: + targetType: 'inline' + script: | + echo resourceGroupName $(RESOURCEGROUPNAME) + echo appServiceName $(APPSERVICENAME) + echo functionName $(FUNCTIONNAME) + + - task: AzureWebApp@1 + displayName: 'Deployment app ${{ variables.APPSERVICENAME }} to env: ${{ parameters.env }}' + inputs: + azureSubscription: '${{ parameters.AzureSubscription }}' + appType: 'webApp' + appName: '$(APPSERVICENAME)' + deploymentMethod: zipDeploy + package: '$(Pipeline.Workspace)/**/Company.AppName.Api.zip' + appSettings: '-Deployment_By "$(Build.RequestedForEmail)" + -Deployment_Build "$(resources.pipeline.Company-AppName-Apps.runName)" + -Deployment_Name "$(Build.BuildNumber)" + -Deployment_Version "$(resources.pipeline.Company-AppName-Apps.sourceBranch)-$(resources.pipeline.Company-AppName-Apps.sourceCommit)" + -Deployment_Date "$(global_buildDate)" + -ApplicationName "Company AppName API"' + + - task: AzureCLI@2 + displayName: Tag Azure + inputs: + azureSubscription: '${{ parameters.AzureSubscription }}' + scriptType: bash + scriptLocation: inlineScript + inlineScript: | + id=$(az resource show --name $(APPSERVICENAME) -g $(RESOURCEGROUPNAME) --resource-type "Microsoft.Web/sites" --query id --output tsv) + az tag update --resource-id $id --operation merge --tags 'Deployment.By="$(Build.RequestedForEmail)"' 'Deployment.Name="$(Build.BuildNumber)"' 'Deployment.Build="$(resources.pipeline.Company-AppName-Apps.runName)"' 'Deployment.Version="$(resources.pipeline.Company-AppName-Apps.sourceBranch)-$(resources.pipeline.Company-AppName-Apps.sourceCommit)"' 'Deployment.Date="$(global_buildDate)"' + + - task: Bash@3 + displayName: 'Smoke Test' + env: + APPHEALTHURL: $(APPHEALTHURL) + inputs: + targetType: 'inline' + failOnStandardError: true + script: | + for attempt in 1 2 3 + do + result=$(curl -s -o /dev/null -w "%{http_code}" $(APPHEALTHURL)) + + if [ $result == "200" ] + then + echo "OK" + exit 0 + else + echo attempt $attempt failed with result $result sleeping 10 + + if [ "$attempt" -lt "3" ] + then + sleep 10 + else + exit 1 + fi + fi + done + + exit 1 \ No newline at end of file diff --git a/tools/CoreEx.Template/content/_azuredevops/templates/function-template.yml b/tools/CoreEx.Template/content/_azuredevops/templates/function-template.yml new file mode 100644 index 00000000..7d56ea33 --- /dev/null +++ b/tools/CoreEx.Template/content/_azuredevops/templates/function-template.yml @@ -0,0 +1,97 @@ +parameters: +- name: AzureSubscription + type: string +- name: env + type: string + displayName: 'Environment shorthand - dev, test, etc.' + +jobs: +- deployment: Deploy_Fun + displayName: "Deploy function app to ${{ parameters.env }}" + environment: ${{ parameters.env }} + variables: + - name: global_buildDate + value: $[format('{0:yyyy}-{0:MM}-{0:dd}T{0:HH}:{0:mm}:{0:ss}', pipeline.startTime)] + # - group: VG-Company-AppName-${{ parameters.env }} # uncomment to use variable groups in ADO + pool: + vmImage: ubuntu-latest + strategy: + runOnce: + deploy: + steps: + + - task: Bash@3 + displayName: 'Setup variables' + inputs: + targetType: 'inline' + script: | + source $(Pipeline.Workspace)/Company-AppName-Infra/Company-AppName-Infra_Package-${{ parameters.env }}/setup-${{ parameters.env }}.sh + + - task: Bash@3 + displayName: 'Display variables' + inputs: + targetType: 'inline' + script: | + echo resourceGroupName $(RESOURCEGROUPNAME) + echo appServiceName $(APPSERVICENAME) + echo functionName $(FUNCTIONNAME) + + - task: AzureFunctionApp@1 + displayName: 'Deployment az fun ${{ variables.FUNCTIONNAME }} to env: ${{ parameters.env }}' + inputs: + azureSubscription: '${{ parameters.AzureSubscription }}' + appType: 'functionApp' + appName: '$(FUNCTIONNAME)' + resourceGroupName: '$(RESOURCEGROUPNAME)' + #slotName: '$(DeploymentSlot)' + package: '$(Pipeline.Workspace)/**/Company.AppName.Functions.zip' + deploymentMethod: 'auto' + appSettings: '-Deployment_By "$(Build.RequestedForEmail)" + -Deployment_Build "$(resources.pipeline.Company-AppName-Apps.runName)" + -Deployment_Name "$(Build.BuildNumber)" + -Deployment_Version "$(resources.pipeline.Company-AppName-Apps.sourceBranch)-$(resources.pipeline.Company-AppName-Apps.sourceCommit)" + -Deployment_Date "$(global_buildDate)" + -ApplicationName "Company AppName Functions" + -AzureFunctionsJobHost__logging__logLevel__default "Warning" + -AzureFunctionsJobHost__logging__logLevel__CoreEx "Information"' + + - task: AzureCLI@2 + displayName: Tag Azure + inputs: + azureSubscription: '${{ parameters.AzureSubscription }}' + scriptType: bash + scriptLocation: inlineScript + inlineScript: | + id=$(az resource show --name $(FUNCTIONNAME) -g $(RESOURCEGROUPNAME) --resource-type "Microsoft.Web/sites" --query id --output tsv) + az tag update --resource-id $id --operation merge --tags 'Deployment.By="$(Build.RequestedForEmail)"' 'Deployment.Name="$(Build.BuildNumber)"' 'Deployment.Build="$(resources.pipeline.Company-AppName-Apps.runName)"' 'Deployment.Version="$(resources.pipeline.Company-AppName-Apps.sourceBranch)-$(resources.pipeline.Company-AppName-Apps.sourceCommit)"' 'Deployment.Date="$(global_buildDate)"' + + - task: Bash@3 + displayName: 'Smoke Test' + env: + FUNCTIONHEALTHURL: $(FUNCTIONHEALTHURL) + inputs: + targetType: 'inline' + failOnStandardError: true + script: | + for attempt in 1 2 3 + do + result=$(curl -s -o /dev/null -w "%{http_code}" $(FUNCTIONHEALTHURL)) + + if [ $result == "200" ] + then + echo "OK" + exit 0 + else + echo attempt $attempt failed with result $result sleeping 10 + + if [ "$attempt" -lt "3" ] + then + sleep 10 + else + exit 1 + fi + fi + done + + exit 1 + diff --git a/tools/CoreEx.Template/content/_azuredevops/templates/infra-destroy-template.yml b/tools/CoreEx.Template/content/_azuredevops/templates/infra-destroy-template.yml new file mode 100644 index 00000000..594b822e --- /dev/null +++ b/tools/CoreEx.Template/content/_azuredevops/templates/infra-destroy-template.yml @@ -0,0 +1,80 @@ +# requires following env variables: +# * secret variable - PULUMI_CONFIG_PASSPHRASE - KeyVault can be used instead of pass phrase, see: https://www.pulumi.com/docs/intro/concepts/secrets/#azure-key-vault + +parameters: +- name: AzureSubscription + type: string + displayName: 'Name of the Azure Service Connection' +- name: storageAccountName + type: string + displayName: 'Name of the storage account used for pulumi state' +- name: stack_name + type: string + displayName: 'Name of the stack to delete' +- name: env + type: string + displayName: 'Environment name - dev, test, etc. Can be longer e.g. Company-AppName-dev' + +jobs: +- job: manual_approval + displayName: "Manual Approval for ${{ parameters.stack_name }} stack in env ${{ parameters.env }} to destroy" + pool: server + steps: + - task: ManualValidation@0 + displayName: "Approve ${{ parameters.stack_name }} stack destroy" + timeoutInMinutes: 1440 # task times out in 1 day + inputs: + # notifyUsers: | + # test@test.com + # example@example.com + instructions: 'Please confirm pulumi stack ${{ parameters.stack_name }} can be deleted' + onTimeout: 'reject' + +- deployment: Destroy + dependsOn: manual_approval + displayName: "Destroy infrastructure ${{ parameters.env }}" + environment: ${{ parameters.env }} + variables: + - name: global_buildDate + value: $[format('{0:yyyy}-{0:MM}-{0:dd}T{0:HH}:{0:mm}:{0:ss}', pipeline.startTime)] + # - group: VG-Company-AppName-${{ parameters.env }} # uncomment to use variable groups in ADO + pool: + vmImage: ubuntu-latest + timeoutInMinutes: 1500 # job times out in 1 day and 1h + strategy: + runOnce: + deploy: + steps: + + - task: Bash@3 + displayName: 'Install Pulumi' + inputs: + targetType: 'inline' + script: | + curl -fsSL https://get.pulumi.com | sh + + echo "##vso[task.prependpath]$HOME/.pulumi/bin" + pulumi about + + - task: AzureCLI@2 + displayName: Destroy Pulumi stack ${{ parameters.stack_name }} + env: + PULUMI_CONFIG_PASSPHRASE: $(PULUMI_CONFIG_PASSPHRASE) + inputs: + scriptType: bash + azureSubscription: '${{ parameters.AzureSubscription }}' + addSpnToEnvironment: true + scriptLocation: inlineScript + inlineScript: | + export AZURE_STORAGE_ACCOUNT="${{ parameters.storageAccountName }}" + export AZURE_STORAGE_KEY=$(az storage account keys list -n "$AZURE_STORAGE_ACCOUNT" --query "[0].value" -o tsv) + export ARM_CLIENT_ID=$servicePrincipalId + export ARM_CLIENT_SECRET=$servicePrincipalKey + export ARM_TENANT_ID=$tenantId + export ARM_SUBSCRIPTION_ID=$(az account show | jq '.id' --raw-output) + + # init pulumi stack + pulumi login --cloud-url azblob://state + + # select/create stack + pulumi destroy -y -s '${{ parameters.stack_name }}' \ No newline at end of file diff --git a/tools/CoreEx.Template/content/_azuredevops/templates/infra-template.yml b/tools/CoreEx.Template/content/_azuredevops/templates/infra-template.yml new file mode 100644 index 00000000..1f395b9c --- /dev/null +++ b/tools/CoreEx.Template/content/_azuredevops/templates/infra-template.yml @@ -0,0 +1,142 @@ +# requires following env variables: +# * secret variable - PULUMI_CONFIG_PASSPHRASE - KeyVault can be used instead of pass phrase, see: https://www.pulumi.com/docs/intro/concepts/secrets/#azure-key-vault + +parameters: +- name: AzureSubscription + type: string + displayName: 'Name of the Azure Service Connection' +- name: storageAccountName + type: string + displayName: 'Name of the storage account used for pulumi state' +- name: env + type: string + displayName: 'Environment name - dev, test, etc. Can be longer e.g. Company-AppName-dev' +- name: region + type: string + default: eastus + displayName: 'Azure region to deploy to' + +jobs: +- deployment: Deploy + displayName: "Deploy infrastructure ${{ parameters.env }}" + environment: ${{ parameters.env }} + variables: + - name: global_buildDate + value: $[format('{0:yyyy}-{0:MM}-{0:dd}T{0:HH}:{0:mm}:{0:ss}', pipeline.startTime)] + - name: stack_name + value: ${{ parameters.env }} # can modify stack name here + # - group: VG-Company-AppName-${{ parameters.env }} # uncomment to use variable groups in ADO + pool: + vmImage: ubuntu-latest + strategy: + runOnce: + deploy: + steps: + + - checkout: self + + - task: Bash@3 + displayName: 'Install Pulumi' + inputs: + targetType: 'inline' + script: | + curl -fsSL https://get.pulumi.com | sh + + echo "##vso[task.prependpath]$HOME/.pulumi/bin" + pulumi about + + - task: DotNetCoreCLI@2 + displayName: Build Infra Project + inputs: + command: build + projects: '**/Company.AppName.Infra.csproj' + arguments: '--configuration $(buildConfiguration)' + + - task: AzureCLI@2 + displayName: Configure Pulumi + env: + PULUMI_CONFIG_PASSPHRASE: $(PULUMI_CONFIG_PASSPHRASE) + inputs: + scriptType: bash + workingDirectory: Company.AppName.Infra + azureSubscription: '${{ parameters.AzureSubscription }}' + scriptLocation: inlineScript + inlineScript: | + export AZURE_STORAGE_ACCOUNT="${{ parameters.storageAccountName }}" + echo "##vso[task.setvariable variable=AZURE_STORAGE_ACCOUNT;isOutput=false]$AZURE_STORAGE_ACCOUNT" + export AZURE_STORAGE_KEY=$(az storage account keys list -n "$AZURE_STORAGE_ACCOUNT" --query "[0].value" -o tsv) + echo "##vso[task.setvariable variable=AZURE_STORAGE_KEY;isOutput=false;issecret=true]$AZURE_STORAGE_KEY" + + # init pulumi stack + pulumi login --cloud-url azblob://state + + # select/create stack + pulumi stack select -c '${{ variables.stack_name }}' + pulumi config set azure-native:location ${{ parameters.region }} + pulumi config set Company.AppName.Infra:isDBSchemaDeploymentEnabled true + + + - task: AzureCLI@2 + displayName: Pulumi Preview + condition: and(succeeded(), or(eq(variables['Build.Reason'], 'PullRequest'), eq(variables['Build.Reason'], 'Manual'))) + env: + AZURE_STORAGE_ACCOUNT: $(AZURE_STORAGE_ACCOUNT) + AZURE_STORAGE_KEY: $(AZURE_STORAGE_KEY) + PULUMI_CONFIG_PASSPHRASE: $(PULUMI_CONFIG_PASSPHRASE) + inputs: + scriptType: bash + azureSubscription: '${{ parameters.AzureSubscription }}' + addSpnToEnvironment: true + workingDirectory: Company.AppName.Infra + scriptLocation: inlineScript + failOnStandardError: true + inlineScript: | + export ARM_CLIENT_ID=$servicePrincipalId + export ARM_CLIENT_SECRET=$servicePrincipalKey + export ARM_TENANT_ID=$tenantId + export ARM_SUBSCRIPTION_ID=$(az account show | jq '.id' --raw-output) + + pulumi login --cloud-url azblob://state + pulumi preview -s '${{ variables.stack_name }}' + + + - task: AzureCLI@2 + condition: or(succeeded(), or(eq(variables['Build.Reason'], 'IndividualCI'), eq(variables['Build.Reason'], 'BatchedCI'))) + displayName: Pulumi Up + env: + AZURE_STORAGE_ACCOUNT: $(AZURE_STORAGE_ACCOUNT) + AZURE_STORAGE_KEY: $(AZURE_STORAGE_KEY) + PULUMI_CONFIG_PASSPHRASE: $(PULUMI_CONFIG_PASSPHRASE) + inputs: + scriptType: bash + azureSubscription: ${{ parameters.AzureSubscription }} + addSpnToEnvironment: true + workingDirectory: Company.AppName.Infra + scriptLocation: inlineScript + failOnStandardError: true + inlineScript: | + export ARM_CLIENT_ID=$servicePrincipalId + export ARM_CLIENT_SECRET=$servicePrincipalKey + export ARM_TENANT_ID=$tenantId + export ARM_SUBSCRIPTION_ID=$(az account show | jq '.id' --raw-output) + + pulumi login --cloud-url azblob://state + pulumi up -s '${{ variables.stack_name }}' --yes + + echo creating $(Build.ArtifactStagingDirectory)/setup-${{ parameters.env }}.sh + touch $(Build.ArtifactStagingDirectory)/setup-${{ parameters.env }}.sh + cat <> $(Build.ArtifactStagingDirectory)/setup-${{ parameters.env }}.sh + echo exporting variables + echo "##vso[task.setvariable variable=RESOURCEGROUPNAME;isOutput=false]$(pulumi stack output ResourceGroupName)" + echo "##vso[task.setvariable variable=APPSERVICENAME;isOutput=false]$(pulumi stack output AppServiceName)" + echo "##vso[task.setvariable variable=FUNCTIONNAME;isOutput=false]$(pulumi stack output FunctionName)" + echo "##vso[task.setvariable variable=FUNCTIONHEALTHURL;isOutput=false;issecret=true]$(pulumi stack output --show-secrets FunctionHealthUrl)" + echo "##vso[task.setvariable variable=APPHEALTHURL;isOutput=false;issecret=true]$(pulumi stack output AppHealthUrl)" + EOT + + - task: PublishBuildArtifacts@1 + condition: succeeded() + displayName: 'Publish infrastructure artifact' + inputs: + pathToPublish: '$(Build.ArtifactStagingDirectory)/setup-${{ parameters.env }}.sh' + artifactName: Company-AppName-Infra_Package-${{ parameters.env }} diff --git a/tools/CoreEx.Template/content/_devcontainer/Dockerfile b/tools/CoreEx.Template/content/_devcontainer/Dockerfile new file mode 100644 index 00000000..98ae8d54 --- /dev/null +++ b/tools/CoreEx.Template/content/_devcontainer/Dockerfile @@ -0,0 +1,26 @@ +# See here for image contents: https://github.com/microsoft/vscode-dev-containers/tree/v0.192.0/containers/dotnet/.devcontainer/base.Dockerfile + +# [Choice] .NET version: 6.0, 5.0, 3.1, 2.1 +ARG VARIANT="6.0" +FROM mcr.microsoft.com/vscode/devcontainers/dotnet:0-${VARIANT} + +# Set up machine requirements to build the repo +RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ + && apt-get -y install --no-install-recommends curl git + +# install pulumi CLI +RUN curl -fsSL https://get.pulumi.com | sh +ENV PATH="${PATH}:/root/.pulumi/bin" + +# install azure functions core tools v4 +# https://github.com/Azure/azure-functions-core-tools#debian-9--10 +# DEBIAN_VERSION=11 +RUN wget -qO- https://packages.microsoft.com/keys/microsoft.asc | gpg --dearmor > microsoft.asc.gpg\ + && sudo mv microsoft.asc.gpg /etc/apt/trusted.gpg.d/ \ + && wget -q https://packages.microsoft.com/config/debian/11/prod.list \ + && sudo mv prod.list /etc/apt/sources.list.d/microsoft-prod.list \ + && sudo chown root:root /etc/apt/trusted.gpg.d/microsoft.asc.gpg \ + && sudo chown root:root /etc/apt/sources.list.d/microsoft-prod.list \ + && echo 'debconf debconf/frontend select Noninteractive' | debconf-set-selections \ + && sudo apt-get update \ + && sudo apt-get -y install azure-functions-core-tools-4 \ No newline at end of file diff --git a/tools/CoreEx.Template/content/_devcontainer/devcontainer.json b/tools/CoreEx.Template/content/_devcontainer/devcontainer.json new file mode 100644 index 00000000..8da43c15 --- /dev/null +++ b/tools/CoreEx.Template/content/_devcontainer/devcontainer.json @@ -0,0 +1,64 @@ +// For format details, see https://aka.ms/devcontainer.json. For config options, see the README at: +// https://github.com/microsoft/vscode-dev-containers/tree/v0.140.1/containers/dotnetcore +{ + "name": "C# (.NET 6)", + // Update the 'dockerComposeFile' list if you have more compose files or use different names. + // The .devcontainer/docker-compose.yml file contains any overrides you need/want to make. + "dockerComposeFile": "docker-compose.yml", + + // The 'service' property is the name of the service for the container that VS Code should use. + "service": "dev", + "workspaceFolder": "/workspace", + "features": { + "github-cli": "2", + "azure-cli": "2.38", + "dotnet": "6.0", + "docker-from-docker": "20.10" + }, + "settings": { + "files.associations": { + "*.csproj": "msbuild", + "*.fsproj": "msbuild", + "*.globalconfig": "ini", + "*.manifest": "xml", + "*.nuspec": "xml", + "*.pkgdef": "ini", + "*.projitems": "msbuild", + "*.props": "msbuild", + "*.resx": "xml", + "*.rsp": "Powershell", + "*.shproj": "msbuild", + "*.slnf": "json", + "*.targets": "msbuild", + "*.vbproj": "msbuild", + "*.vsixmanifest": "xml", + "*.vstemplate": "xml" + }, + // ms-dotnettools.csharp settings + "omnisharp.disableMSBuildDiagnosticWarning": true, + "omnisharp.enableEditorConfigSupport": true, + "omnisharp.enableImportCompletion": true, + "omnisharp.enableRoslynAnalyzers": true, + "omnisharp.useModernNet": true, + "omnisharp.enableAsyncCompletion": true + }, + "extensions": [ + "ms-dotnettools.csharp", + "EditorConfig.EditorConfig", + "tintoy.msbuild-project-tools", + "ms-dotnettools.csharp", + "VisualStudioExptTeam.vscodeintellicode", + "ms-mssql.mssql", + "ms-vscode.azure-account", + "ms-azuretools.vscode-azureappservice", + "ms-azuretools.vscode-azurefunctions", + "ms-azuretools.vscode-docker", + "ms-dotnettools.vscode-dotnet-runtime", + "MS-vsliveshare.vsliveshare-pack", + "Azurite.azurite", + "humao.rest-client" + ], + "postCreateCommand": "dotnet restore", + // Use 'forwardPorts' to make a list of ports inside the container available locally. + "forwardPorts": [80, 1433, 7071, 7129] +} \ No newline at end of file diff --git a/tools/CoreEx.Template/content/_devcontainer/devinit.json b/tools/CoreEx.Template/content/_devcontainer/devinit.json new file mode 100644 index 00000000..e69de29b diff --git a/tools/CoreEx.Template/content/_devcontainer/docker-compose.yml b/tools/CoreEx.Template/content/_devcontainer/docker-compose.yml new file mode 100644 index 00000000..6db685cc --- /dev/null +++ b/tools/CoreEx.Template/content/_devcontainer/docker-compose.yml @@ -0,0 +1,49 @@ +version: '3.8' + +services: + dev: + build: + context: . + dockerfile: Dockerfile + args: + # Update 'VARIANT' to pick an NET VERSION + VARIANT: "6.0" + environment: + - ASPNETCORE_ENVIRONMENT=Development + - ASPNETCORE_URLS=http://0.0.0.0:80 + - ConnectionStrings__Database=Data Source=sqldata,1433;Initial Catalog=Company.AppName;User id=sa;Password=sAPWD23.^0;TrustServerCertificate=true + - PORT=80 + volumes: + - ..:/workspace:cached + + # Overrides default command so things don't shut down after the process ends. + command: sleep infinity + + # Runs app on the same network as the database container, allows "forwardPorts" in devcontainer.json function. + network_mode: service:sqldata + + # Uncomment the next line to use a non-root user for all processes. + # user: node + + # Use "forwardPorts" in **devcontainer.json** to forward an app port locally. + # (Adding the "ports" property to this file will not forward from a Codespace.) + + sqldata: + build: + context: ../ + dockerfile: Company.AppName.Database/Dockerfile + environment: + - ACCEPT_EULA=Y + - MSSQL_SA_PASSWORD=sAPWD23.^0 + - MSSQL_TCP_PORT=1433 + - MSSQL_AGENT_ENABLED=true + - ASPNETCORE_ENVIRONMENT=Development + - ConnectionStrings__Database=Data Source=localhost,1433;Initial Catalog=Company.AppName;User id=sa;Password=sAPWD23.^0;TrustServerCertificate=true + volumes: + - app-sqldata:/var/opt/mssql + + # Add "forwardPorts": ["1433"] to **devcontainer.json** to forward SQL locally. + # (Adding the "ports" property to this file will not forward from a Codespace.) + +volumes: + app-sqldata: \ No newline at end of file diff --git a/tools/CoreEx.Template/content/_dockerignore b/tools/CoreEx.Template/content/_dockerignore new file mode 100644 index 00000000..f2dfd3bc --- /dev/null +++ b/tools/CoreEx.Template/content/_dockerignore @@ -0,0 +1,37 @@ +**/.classpath +**/.dockerignore +**/.env +**/.git +**/.gitignore +**/.project +**/.settings +**/.toolstarget +**/.vs +**/.vscode +**/*.*proj.user +**/*.dbmdl +**/*.jfm +**/azds.yaml +**/bin +**/charts +**/docker-compose* +**/Dockerfile* +**/node_modules +**/npm-debug.log +**/obj +**/secrets.dev.yaml +**/values.dev.yaml +LICENSE +README.md + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ diff --git a/tools/CoreEx.Template/content/_gitignore b/tools/CoreEx.Template/content/_gitignore new file mode 100644 index 00000000..773907c7 --- /dev/null +++ b/tools/CoreEx.Template/content/_gitignore @@ -0,0 +1,757 @@ +# File created using '.gitignore Generator' for Visual Studio Code: https://bit.ly/vscode-gig + +# Created by https://www.toptal.com/developers/gitignore/api/windows,visualstudiocode,macos,csharp,dotnetcore,jupyternotebooks,linux,node,python +# Edit at https://www.toptal.com/developers/gitignore?templates=windows,visualstudiocode,macos,csharp,dotnetcore,jupyternotebooks,linux,node,python + +### Csharp ### +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.tlog +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Nuget personal access tokens and Credentials +nuget.config + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd + +# VS Code files for those working on multiple tools +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace + +# Local History for Visual Studio Code +.history/ + +# Windows Installer files from build outputs +*.cab +*.msi +*.msix +*.msm +*.msp + +# JetBrains Rider +.idea/ +*.sln.iml + +### DotnetCore ### +# .NET Core build folders +bin/ +obj/ + +# Common node modules locations +/node_modules +/wwwroot/node_modules + +### JupyterNotebooks ### +# gitignore template for Jupyter Notebooks +# website: http://jupyter.org/ + +.ipynb_checkpoints +*/.ipynb_checkpoints/* + +# IPython +profile_default/ +ipython_config.py + +# Remove previous ipynb_checkpoints +# git rm -r .ipynb_checkpoints/ + +### Linux ### + +# temporary files which can be created if a process still has a handle open of a deleted file +.fuse_hidden* + +# KDE directory preferences +.directory + +# Linux trash folder which might appear on any partition or disk +.Trash-* + +# .nfs files are created when an open file is removed but is still being accessed +.nfs* + +### macOS ### +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### Node ### +# Logs +logs +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env +.env.test +.env.production + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v2 +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +### Node Patch ### +# Serverless Webpack directories +.webpack/ + +### Python ### +# Byte-compiled / optimized / DLL files +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook + +# IPython + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +### VisualStudioCode ### + +# Local History for Visual Studio Code + +### VisualStudioCode Patch ### +# Ignore all local history of files +.history +.ionide + +### Windows ### +# Windows thumbnail cache files +Thumbs.db +Thumbs.db:encryptable +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files + +# Windows shortcuts +*.lnk + +# End of https://www.toptal.com/developers/gitignore/api/windows,visualstudiocode,macos,csharp,dotnetcore,jupyternotebooks,linux,node,python + +# Custom rules (everything added below won't be overriden by 'Generate .gitignore File' if you use 'Update' option) +__azurite_db_blob__.json +__azurite_db_blob_extent__.json +__blobstorage__/ +docker-compose.local.override.yml +__azurite_db_* + +# Pulumi +# Pulumi.*.yaml # comment out to store env information in git \ No newline at end of file diff --git a/tools/CoreEx.Template/content/_vscode/extensions.json b/tools/CoreEx.Template/content/_vscode/extensions.json new file mode 100644 index 00000000..c581f1a8 --- /dev/null +++ b/tools/CoreEx.Template/content/_vscode/extensions.json @@ -0,0 +1,15 @@ +{ + "recommendations": [ + "ms-azuretools.vscode-azurefunctions", + "ms-dotnettools.csharp", + "VisualStudioExptTeam.vscodeintellicode", + "ms-mssql.mssql", + "ms-vscode.azure-account", + "ms-azuretools.vscode-azureappservice", + "ms-azuretools.vscode-docker", + "ms-dotnettools.vscode-dotnet-runtime", + "MS-vsliveshare.vsliveshare-pack", + "Azurite.azurite", + "humao.rest-client" + ] +} \ No newline at end of file diff --git a/tools/CoreEx.Template/content/_vscode/launch.json b/tools/CoreEx.Template/content/_vscode/launch.json new file mode 100644 index 00000000..d526e723 --- /dev/null +++ b/tools/CoreEx.Template/content/_vscode/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Attach to .NET Functions", + "type": "coreclr", + "request": "attach", + "processId": "${command:azureFunctions.pickProcess}" + } + ] +} \ No newline at end of file diff --git a/tools/CoreEx.Template/content/_vscode/settings.json b/tools/CoreEx.Template/content/_vscode/settings.json new file mode 100644 index 00000000..2bb65da3 --- /dev/null +++ b/tools/CoreEx.Template/content/_vscode/settings.json @@ -0,0 +1,7 @@ +{ + "azureFunctions.deploySubpath": "Company.AppName.Functions/bin/Release/net6.0/publish", + "azureFunctions.projectLanguage": "C#", + "azureFunctions.projectRuntime": "~4", + "debug.internalConsoleOptions": "neverOpen", + "azureFunctions.preDeployTask": "publish (functions)" +} \ No newline at end of file diff --git a/tools/CoreEx.Template/content/_vscode/tasks.json b/tools/CoreEx.Template/content/_vscode/tasks.json new file mode 100644 index 00000000..1da50713 --- /dev/null +++ b/tools/CoreEx.Template/content/_vscode/tasks.json @@ -0,0 +1,81 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "clean (functions)", + "command": "dotnet", + "args": [ + "clean", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary" + ], + "type": "process", + "problemMatcher": "$msCompile", + "options": { + "cwd": "${workspaceFolder}/Company.AppName.Functions" + } + }, + { + "label": "build (functions)", + "command": "dotnet", + "args": [ + "build", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary" + ], + "type": "process", + "dependsOn": "clean (functions)", + "group": { + "kind": "build", + "isDefault": true + }, + "problemMatcher": "$msCompile", + "options": { + "cwd": "${workspaceFolder}/Company.AppName.Functions" + } + }, + { + "label": "clean release (functions)", + "command": "dotnet", + "args": [ + "clean", + "--configuration", + "Release", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary" + ], + "type": "process", + "problemMatcher": "$msCompile", + "options": { + "cwd": "${workspaceFolder}/Company.AppName.Functions" + } + }, + { + "label": "publish (functions)", + "command": "dotnet", + "args": [ + "publish", + "--configuration", + "Release", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary" + ], + "type": "process", + "dependsOn": "clean release (functions)", + "problemMatcher": "$msCompile", + "options": { + "cwd": "${workspaceFolder}/Company.AppName.Functions" + } + }, + { + "type": "func", + "dependsOn": "build (functions)", + "options": { + "cwd": "${workspaceFolder}/Company.AppName.Functions/bin/Debug/net6.0" + }, + "command": "host start", + "isBackground": true, + "problemMatcher": "$func-dotnet-watch" + } + ] +} \ No newline at end of file diff --git a/tools/CoreEx.Template/content/docker-compose.DB.only.yml b/tools/CoreEx.Template/content/docker-compose.DB.only.yml new file mode 100644 index 00000000..79c6b7de --- /dev/null +++ b/tools/CoreEx.Template/content/docker-compose.DB.only.yml @@ -0,0 +1,14 @@ +version: '3.4' + +services: + + app-api: + entrypoint: ["echo", "Service api disabled"] + + app-functions: + entrypoint: ["echo", "Service functions disabled"] + + +volumes: + app-sqldata: + external: false diff --git a/tools/CoreEx.Template/content/docker-compose.override.yml b/tools/CoreEx.Template/content/docker-compose.override.yml new file mode 100644 index 00000000..494d8b21 --- /dev/null +++ b/tools/CoreEx.Template/content/docker-compose.override.yml @@ -0,0 +1,44 @@ +version: '3.4' + +services: + + sqldata: + environment: + - ACCEPT_EULA=Y + - MSSQL_SA_PASSWORD=sAPWD23.^0 + - MSSQL_TCP_PORT=1433 + - MSSQL_AGENT_ENABLED=true + - ASPNETCORE_ENVIRONMENT=Development + - ConnectionStrings__Database=Data Source=localhost,1433;Initial Catalog=Company.AppName;User id=sa;Password=sAPWD23.^0;TrustServerCertificate=true + ports: + - "5433:1433" + volumes: + - app-sqldata:/var/opt/mssql + + app-api: + environment: + - ASPNETCORE_ENVIRONMENT=Development + - ASPNETCORE_URLS=http://0.0.0.0:80 + - ConnectionStrings__Database=Data Source=sqldata,1433;Initial Catalog=Company.AppName;User id=sa;Password=sAPWD23.^0;TrustServerCertificate=true + - PORT=80 + ports: + - "5103:80" + + app-functions: + environment: + - ASPNETCORE_ENVIRONMENT=Development + - AZURE_FUNCTIONS_ENVIRONMENT=Development + - OpenApi__HideSwaggerUI=false + - AgifyApiEndpointUri=https://api.agify.io + - NationalizeApiClientApiEndpointUri=https://api.nationalize.io + - GenderizeApiClientApiEndpointUri=https://api.genderize.io + - VerificationQueueName=pendingVerifications + - VerificationResultsQueueName=verificationResults + - ConnectionStrings__Database=Data Source=sqldata,1433;Initial Catalog=Company.AppName;User id=sa;Password=sAPWD23.^0;TrustServerCertificate=true + - PORT=80 + ports: + - "5104:80" + +volumes: + app-sqldata: + external: false diff --git a/tools/CoreEx.Template/content/docker-compose.yml b/tools/CoreEx.Template/content/docker-compose.yml new file mode 100644 index 00000000..fc72c047 --- /dev/null +++ b/tools/CoreEx.Template/content/docker-compose.yml @@ -0,0 +1,25 @@ +# To run: docker-compose -f docker-compose.yml -f docker-compose.override.yml up +version: '3.4' + +services: + + sqldata: + build: + context: . + dockerfile: Company.AppName.Database/Dockerfile + + app-api: + build: + context: . + dockerfile: Company.AppName.Api/Dockerfile + depends_on: + - sqldata + + app-functions: + build: + context: . + args: + LOCAL: "true" + dockerfile: Company.AppName.Functions/Dockerfile + depends_on: + - sqldata \ No newline at end of file diff --git a/tools/CoreEx.Template/readme.md b/tools/CoreEx.Template/readme.md new file mode 100644 index 00000000..b0828290 --- /dev/null +++ b/tools/CoreEx.Template/readme.md @@ -0,0 +1,68 @@ +# Temp readme file + +## How to create templates + +* [Samples](https://github.com/dotnet/samples/tree/main/core/tutorials/cli-templates-create-item-template) +* [Wiki](https://github.com/dotnet/templating/wiki) +* [Docs](https://learn.microsoft.com/en-us/dotnet/core/tools/custom-templates) +* [Tutorial](https://learn.microsoft.com/en-us/dotnet/core/tutorials/cli-templates-create-item-template) +* [NTangle template](https://github.com/Avanade/NTangle/tree/main/tools/NTangle.Template) +* [Template Analyzer](https://github.com/sayedihashimi/template-sample#template-analyzer) + +## Dev container + +Add [Dev Container](https://code.visualstudio.com/docs/remote/create-dev-container#_use-docker-compose) with docker-compose support that would run the solution. + +Extensions required: + +* azure functions +* function tools +* az CLI +* Pulumi CLI +* dotnet SDK +* Pulumi VS Extension +* Azurite Extension +* REST Client + +--> DONE + +Expose ports for function, app service and sql server +--> DONE + +## Update readme to use REST Client -> DONE + Create: [POST] http://localhost:7071/api/api/employees + + Delete: [DELETE] http://localhost:7071/api/api/employees/{id} + + Get: [GET] http://localhost:7071/api/api/employees/{id} + + GetAll: [GET] http://localhost:7071/api/api/employees + + HealthInfo: [GET] http://localhost:7071/api/health + + HttpTriggerQueueVerificationFunction: [POST] http://localhost:7071/api/employee/verify + + Patch: [PATCH] http://localhost:7071/api/api/employees/{id} + + RenderOAuth2Redirect: [GET] http://localhost:7071/api/oauth2-redirect.html + + RenderOpenApiDocument: [GET] http://localhost:7071/api/openapi/{version}.{extension} + + RenderSwaggerDocument: [GET] http://localhost:7071/api/swagger.{extension} + + RenderSwaggerUI: [GET] http://localhost:7071/api/swagger/ui + + Update: [PUT] http://localhost:7071/api/api/employees/{id} + +## Add file that contains recommended VS CODE extensions + +DONE + +## Readme on configuring ADO + +https://github.com/Azure-Samples/todo-csharp-sql/tree/main/.azdo/pipelines + +## Readme on running the DB container locally + +## Cosmos Tests +todo: update cosmos tests with env variable \ No newline at end of file