diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..38bece4 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,25 @@ +**/.dockerignore +**/.env +**/.git +**/.gitignore +**/.project +**/.settings +**/.toolstarget +**/.vs +**/.vscode +**/.idea +**/*.*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 \ No newline at end of file diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..0714dc3 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,99 @@ +[*] +charset = utf-8 +end_of_line = lf +trim_trailing_whitespace = false +insert_final_newline = false +indent_style = space +indent_size = 4 +tab_width = 4 + +# Microsoft .NET properties +csharp_new_line_before_members_in_object_initializers = false +csharp_preferred_modifier_order = public, private, protected, internal, new, static, abstract, virtual, sealed, readonly, override, extern, unsafe, volatile, async:suggestion +# Above is the C#10 version, below is the C#11 version +# csharp_preferred_modifier_order = public, private, protected, internal, file, new, static, abstract, virtual, sealed, readonly, override, extern, unsafe, volatile, async, required:suggestion +# csharp_style_prefer_utf8_string_literals = true:suggestion +csharp_style_var_elsewhere = true:suggestion +csharp_style_var_for_built_in_types = true:suggestion +csharp_style_var_when_type_is_apparent = true:suggestion +dotnet_style_parentheses_in_arithmetic_binary_operators = never_if_unnecessary:none +dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:none +dotnet_style_parentheses_in_relational_binary_operators = never_if_unnecessary:none +dotnet_style_predefined_type_for_locals_parameters_members = true:suggestion +dotnet_style_predefined_type_for_member_access = true:suggestion +dotnet_style_qualification_for_event = false:suggestion +dotnet_style_qualification_for_field = false:suggestion +dotnet_style_qualification_for_method = false:suggestion +dotnet_style_qualification_for_property = false:suggestion +dotnet_style_require_accessibility_modifiers = for_non_interface_members:suggestion + +# Resharper config are for those using Visual Studio community, professional and enterprise with Jetbrains R# +# Those rules are not applied on Jetbrains Rider, as they come by default there, nor do they apply in Visual Studio Code + +# ReSharper properties +resharper_autodetect_indent_settings = true +resharper_formatter_off_tag = @formatter:off +resharper_formatter_on_tag = @formatter:on +resharper_formatter_tags_enabled = true +resharper_use_indent_from_vs = false + +# ReSharper inspection severities +resharper_arrange_redundant_parentheses_highlighting = hint +resharper_arrange_this_qualifier_highlighting = hint +resharper_arrange_type_member_modifiers_highlighting = hint +resharper_arrange_type_modifiers_highlighting = hint +resharper_built_in_type_reference_style_for_member_access_highlighting = hint +resharper_built_in_type_reference_style_highlighting = hint +resharper_redundant_base_qualifier_highlighting = warning +resharper_suggest_var_or_type_built_in_types_highlighting = hint +resharper_suggest_var_or_type_elsewhere_highlighting = hint +resharper_suggest_var_or_type_simple_types_highlighting = hint +resharper_web_config_module_not_resolved_highlighting = warning +resharper_web_config_type_not_resolved_highlighting = warning +resharper_web_config_wrong_module_highlighting = warning + +[*.cs] +# Newline settings +csharp_new_line_before_open_brace = all +csharp_new_line_before_else = true +csharp_new_line_before_catch = true +csharp_new_line_before_finally = true +csharp_new_line_before_members_in_object_initializers = true +csharp_new_line_before_members_in_anonymous_types = true + +# name all constant fields using PascalCase +dotnet_naming_rule.constant_fields_should_be_pascal_case.severity = suggestion +dotnet_naming_rule.constant_fields_should_be_pascal_case.symbols = constant_fields +dotnet_naming_rule.constant_fields_should_be_pascal_case.style = pascal_case_style +dotnet_naming_symbols.constant_fields.applicable_kinds = field +dotnet_naming_symbols.constant_fields.required_modifiers = const +dotnet_naming_style.pascal_case_style.capitalization = pascal_case + +# static fields should have s_ prefix +dotnet_naming_rule.static_fields_should_have_prefix.severity = suggestion +dotnet_naming_rule.static_fields_should_have_prefix.symbols = static_fields +dotnet_naming_rule.static_fields_should_have_prefix.style = static_prefix_style +dotnet_naming_symbols.static_fields.applicable_kinds = field +dotnet_naming_symbols.static_fields.required_modifiers = static +dotnet_naming_symbols.static_fields.applicable_accessibilities = private, internal, private_protected +dotnet_naming_style.static_prefix_style.required_prefix = s_ +dotnet_naming_style.static_prefix_style.capitalization = camel_case + +# internal and private fields should be _camelCase +dotnet_naming_rule.camel_case_for_private_internal_fields.severity = suggestion +dotnet_naming_rule.camel_case_for_private_internal_fields.symbols = private_internal_fields +dotnet_naming_rule.camel_case_for_private_internal_fields.style = camel_case_underscore_style +dotnet_naming_symbols.private_internal_fields.applicable_kinds = field +dotnet_naming_symbols.private_internal_fields.applicable_accessibilities = private, internal +dotnet_naming_style.camel_case_underscore_style.required_prefix = _ +dotnet_naming_style.camel_case_underscore_style.capitalization = camel_case + +[*.{xml,config,*proj,nuspec,props,resx,targets,yml,tasks}] +indent_size = 2 + +# Xml config files +[*.{props,targets,ruleset,config,nuspec,resx,vsixmanifest,vsct}] +indent_size = 2 + +[*.json] +indent_size = 2 \ No newline at end of file diff --git a/.github/example.dependabot.yml b/.github/example.dependabot.yml new file mode 100644 index 0000000..ff1be69 --- /dev/null +++ b/.github/example.dependabot.yml @@ -0,0 +1,27 @@ +version: 2 + +updates: + - package-ecosystem: nuget + directory: / + open-pull-requests-limit: 10 + + # Schedule run every Monday, local time + schedule: + interval: weekly + time: '10:30' + timezone: 'Europe/London' + + versioning-strategy: increase + + allow: + - dependency-type: direct + + # Update GitHub Actions + - package-ecosystem: github-actions + directory: / + + # Schedule run every Monday, local time + schedule: + interval: weekly + time: '10:30' + timezone: 'Europe/London' \ No newline at end of file diff --git a/.github/template/apply.sh b/.github/template/apply.sh new file mode 100755 index 0000000..974b2f1 --- /dev/null +++ b/.github/template/apply.sh @@ -0,0 +1,28 @@ +#!/bin/bash + +repository=$1 + +REPO_NAME=$(echo "${repository}" | awk -F '/' '{print $2}') +REPO_NAME_PASCAL_CASE=$(echo "$REPO_NAME" | sed -r 's/(^|-)([a-z])/\U\2/g') + +echo "applying template..." + +echo "renaming cdp-dotnet-backend-template -> ${REPO_NAME}" +find . -name .git -prune -o -name .github -prune -o -type f -exec sed -i "s/cdp-dotnet-backend-template/${REPO_NAME}/g" {} \; + +echo "Backend.Api -> ${REPO_NAME_PASCAL_CASE}" +find . -name .git -prune -o -name .github -prune -o -type f -exec sed -i "s/Backend\.Api/${REPO_NAME_PASCAL_CASE}/g" {} \; + +echo "CDP C# ASP\.NET Backend Template -> ${REPO_NAME}" +find . -name .git -prune -o -name .github -prune -o -type f -exec sed -i "s/CDP C# ASP\.NET Backend Template/${REPO_NAME}/g" {} \; + +echo "Moving Backend\.Api* -> ${REPO_NAME_PASCAL_CASE}" +mv './Backend.Api/Backend.Api.csproj' "./Backend.Api/${REPO_NAME_PASCAL_CASE}.csproj" +mv './Backend.Api.Test/Backend.Api.Test.csproj' "./Backend.Api.Test/${REPO_NAME_PASCAL_CASE}.Test.csproj" +mv './Backend.Api' "${REPO_NAME_PASCAL_CASE}" +mv './Backend.Api.Test' "${REPO_NAME_PASCAL_CASE}.Test" + +echo "Renaming CdpDotnetBackendTemplate.sln -> ${REPO_NAME_PASCAL_CASE}.sln" +mv CdpDotnetBackendTemplate.sln "${REPO_NAME_PASCAL_CASE}".sln + +tree \ No newline at end of file diff --git a/.github/template/cleanup.sh b/.github/template/cleanup.sh new file mode 100755 index 0000000..0a0b421 --- /dev/null +++ b/.github/template/cleanup.sh @@ -0,0 +1,4 @@ +#!/bin/bash +rm .github/workflows/template.yml +rm .github/workflows/validate-template.yml +rm -rf .github/template/ diff --git a/.github/template/template-compose.yml b/.github/template/template-compose.yml new file mode 100644 index 0000000..9ab8147 --- /dev/null +++ b/.github/template/template-compose.yml @@ -0,0 +1,32 @@ +services: + mongodb: + container_name: mongodb + networks: + - cdp-network + image: mongo:6.0.13 + volumes: + - ./ssl:/etc/ssl:ro + command: [ + "--tlsMode", "requireTLS", + "--tlsCertificateKeyFile", "etc/ssl/mongodb.pem" + ] + service: + build: + context: ../.. + container_name: cdp-dotnet-backend-template + image: cdp-dotnet-backend-template + networks: + - cdp-network + ports: + - "8085:8085" + depends_on: + - mongodb + environment: + ASPNETCORE_URLS: "http://+:8085" + Mongo__DatabaseUri: "mongodb://mongodb:27017/?tls=true" + TRUSTSTORE_MONGO: ${MONGODB_TEST_CA_PEM} + +networks: + cdp-network: + driver: bridge + diff --git a/.github/template/validate.sh b/.github/template/validate.sh new file mode 100755 index 0000000..936db84 --- /dev/null +++ b/.github/template/validate.sh @@ -0,0 +1,81 @@ +#!/bin/bash + +compose_file='.github/template/template-compose.yml' + +checkUrl() { + URL=$1 + + set +e + # Call the URL and get the HTTP status code + HTTP_STATUS=$(curl -o /dev/null -s -w "%{http_code}\n" "$URL") + set -e + + # Check if the HTTP status code is 200 + if [ "$HTTP_STATUS" -eq 200 ]; then + echo " ✔ $URL returned a 200 OK status." + return 0 + else + echo " ❌ $URL returned a status of $HTTP_STATUS. Exiting with code 1." + return 1 + fi +} + +checkLogSchema() { + set +e + local log + log=$(docker compose -f "$compose_file" logs service -n 1 --no-color --no-log-prefix 2>/dev/null) + + # Check if jq validation was successful + if echo "$log" | jq empty > /dev/null; then + echo " ✔ Log entry is valid JSON." + set -e + return 0 + else + echo " ❌ Log entry is not valid JSON." + set -e + return 1 + fi +} + +setup() { + set -e + # Generate Self Signed Certs + mkdir -p .github/template/ssl + openssl req -newkey rsa:2048 -new -x509 -days 365 -nodes -out .github/template/ssl/mongodb-cert.crt -keyout .github/template/ssl/mongodb-cert.key \ + -subj "/C=UK/ST=STATE/L=CITY/O=ORG_NAME/OU=OU_NAME/CN=mongodb" \ + -addext "subjectAltName = DNS:localhost, DNS:mongodb" + cat .github/template/ssl/mongodb-cert.key .github/template/ssl/mongodb-cert.crt > .github/template/ssl/mongodb.pem + mongodbTestCaPem="$(cat .github/template/ssl/mongodb.pem | base64)" + export MONGODB_TEST_CA_PEM=$mongodbTestCaPem + + # Start mongodb + templated service + docker compose -f "$compose_file" up --wait --wait-timeout 60 -d --quiet-pull + sleep 3 +} + +# Stop docker on exist and cleanup tmp files +cleanup() { + rv=$? + echo "cleaning up $rv" + rm -f .github/template/ssl/* + docker compose -f "$compose_file" down + exit $rv +} +trap cleanup EXIT + +run_tests() { + # Run the tests + echo "-- Running template tests ---" + + # Check endpoints respond + checkUrl "http://localhost:8085/health" + checkUrl "http://localhost:8085/example" + + # Check its using ECS + checkLogSchema +} + +# Start Docker +setup +run_tests + diff --git a/.github/workflows/check-pull-request.yml b/.github/workflows/check-pull-request.yml new file mode 100644 index 0000000..c01cfaa --- /dev/null +++ b/.github/workflows/check-pull-request.yml @@ -0,0 +1,35 @@ +name: Check Pull Request + +on: + pull_request: + branches: + - main + types: + - opened + - edited + - reopened + - synchronize + - ready_for_review + +jobs: + pr-validator: + name: Run Pull Request Checks + runs-on: ubuntu-latest + steps: + - name: Check out code + uses: actions/checkout@v3 + - name: Test + uses: actions/setup-dotnet@v3 + with: + dotnet-version: | + 8.0 + - run: dotnet test + ## SonarCloud + ## Uncomment to unable SonarCloud scan + ## Requires project to be set up in SonarCloud + ## and the SonarCloud token to be set in the repository secrets +# sonarcloud-scan: +# name: CDP SonarCloud Scan +# uses: ./.github/workflows/sonarcloud.yml +# needs: pr-validator +# secrets: inherit diff --git a/.github/workflows/publish-hotfix.yml b/.github/workflows/publish-hotfix.yml new file mode 100644 index 0000000..5784f6b --- /dev/null +++ b/.github/workflows/publish-hotfix.yml @@ -0,0 +1,37 @@ +name: Publish Hot Fix + +on: + workflow_dispatch: + +permissions: + id-token: write + contents: write + pull-requests: write + +env: + AWS_REGION: eu-west-2 + AWS_ACCOUNT_ID: "094954420758" + +jobs: + build: + if: github.run_number != 1 + name: CDP-build-hotfix-workflow + runs-on: ubuntu-latest + steps: + - name: Check out code + uses: actions/checkout@v4 + with: + fetch-depth: 0 # Depth 0 required for branch-based versioning + - name: Publish Hot Fix + uses: DEFRA/cdp-build-action/build-hotfix@main + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + ## SonarCloud + ## Uncomment to unable SonarCloud scan + ## Requires project to be set up in SonarCloud + ## and the SonarCloud token to be set in the repository secrets +# sonarcloud-scan: +# name: CDP SonarCloud Scan +# uses: ./.github/workflows/sonarcloud.yml +# needs: build +# secrets: inherit diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..a9809a9 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,37 @@ +name: Publish + +on: + push: + branches: + - main + +permissions: + id-token: write + contents: write + pull-requests: write + +env: + AWS_REGION: eu-west-2 + AWS_ACCOUNT_ID: "094954420758" + +jobs: + build: + if: github.run_number != 1 + name: CDP-build-workflow + runs-on: ubuntu-latest + steps: + - name: Check out code + uses: actions/checkout@v3 + - name: Build and Publish + uses: DEFRA/cdp-build-action/build@main + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + ## SonarCloud + ## Uncomment to unable SonarCloud scan + ## Requires project to be set up in SonarCloud + ## and the SonarCloud token to be set in the repository secrets +# sonarcloud-scan: +# name: CDP SonarCloud Scan +# uses: ./.github/workflows/sonarcloud.yml +# needs: build +# secrets: inherit diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml new file mode 100644 index 0000000..3eb400c --- /dev/null +++ b/.github/workflows/sonarcloud.yml @@ -0,0 +1,70 @@ +name: Publish Hot Fix + +on: + workflow_call: + +permissions: + id-token: write + contents: read + pull-requests: write + +jobs: + build: + if: github.run_number != 1 + name: CDP SonarCloud coverage scan + runs-on: ubuntu-latest + steps: + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: 17 + distribution: "zulu" + - name: Check out code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Set up .NET 8.0 + uses: actions/setup-dotnet@v3 + with: + dotnet-version: | + 8.0 + - name: Cache SonarCloud packages + uses: actions/cache@v4 + with: + path: ~/sonar/cache + key: ${{ runner.os }}-sonar + restore-keys: ${{ runner.os }}-sonar + - name: Cache SonarCloud scanner + id: cache-sonar-scanner + uses: actions/cache@v4 + with: + path: ./.sonar/scanner + key: ${{ runner.os }}-sonar-scanner + restore-keys: ${{ runner.os }}-sonar-scanner + - name: Cache dotNet code coverage + id: cache-sonar-coverage + uses: actions/cache@v4 + with: + path: ./.sonar/coverage + key: ${{ runner.os }}-sonar-coverage + restore-keys: ${{ runner.os }}-sonar-coverage + - name: Install SonarCloud scanner + if: steps.cache-sonar-scanner.outputs.cache-hit != 'true' + run: | + mkdir -p ./.sonar/scanner + dotnet tool update dotnet-sonarscanner --tool-path ./.sonar/scanner + - name: Install dotNet code coverage + if: steps.cache-sonar-coverage.outputs.cache-hit != 'true' + run: | + mkdir -p ./.sonar/coverage + dotnet tool update dotnet-coverage --tool-path ./.sonar/coverage + - name: Build and analyze + if: github.actor != 'dependabot[bot]' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + run: | + ./.sonar/scanner/dotnet-sonarscanner begin /k:"DEFRA_cdp-dotnet-backend-template" /o:"defra" /d:sonar.token="${{ secrets.SONAR_TOKEN }}" /d:sonar.host.url="https://sonarcloud.io" /d:sonar.cs.vscoveragexml.reportsPaths=coverage.xml + dotnet build --no-incremental + ./.sonar/coverage/dotnet-coverage collect "dotnet test" -f xml -o "coverage.xml" + ./.sonar/scanner/dotnet-sonarscanner end /d:sonar.token="${{ secrets.SONAR_TOKEN }}" diff --git a/.github/workflows/template.yml b/.github/workflows/template.yml new file mode 100644 index 0000000..22d4091 --- /dev/null +++ b/.github/workflows/template.yml @@ -0,0 +1,33 @@ +name: Templating + +on: + push: + branches: + - main + +permissions: + id-token: write + contents: write + pull-requests: write + +jobs: + templating: + if: github.run_number == 1 && github.repository != 'cdp-dotnet-backend-template' + name: CDP-templating-workflow + runs-on: ubuntu-latest + steps: + - name: Check out code + uses: actions/checkout@v3 + - name: Apply template + run: | + .github/template/apply.sh ${{ github.repository }} + - name: Cleanup + run: | + .github/template/cleanup.sh + - name: Commit and push changes + run: | + git config --global user.name 'GitHub Actions' + git config --global user.email 'github-actions@github.com' + git add -A + git commit -m "Applying template changes" + git push origin main diff --git a/.github/workflows/validate-template.yml b/.github/workflows/validate-template.yml new file mode 100644 index 0000000..a3b211a --- /dev/null +++ b/.github/workflows/validate-template.yml @@ -0,0 +1,24 @@ +name: Validate Template + +on: + pull_request: + branches: + - main + types: + - opened + - edited + - reopened + - synchronize + - ready_for_review + +jobs: + pr-validator: + name: Run Pull Request Checks + runs-on: ubuntu-latest + steps: + - name: Check out code + uses: actions/checkout@v4 + - name: Apply template + run: .github/template/apply.sh DEFRA/service-backend + - name: Check container starts in production-like mode + run: .github/template/validate.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1cf3b81 --- /dev/null +++ b/.gitignore @@ -0,0 +1,269 @@ + +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. + +# User-specific files +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ + +# Visual Studio 2015 cache/options directory +.vs/ +# Visual Studio Code +.vscode/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUNIT +*.VisualState.xml +TestResult.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# DNX +project.lock.json +project.fragment.lock.json +artifacts/ + +*_i.c +*_p.c +*_i.h +*.ilk +*.meta +*.obj +*.pch +*.pdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.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 + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# JustCode is a .NET coding add-in +.JustCode + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# 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 +# TODO: 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 +# The packages folder can be ignored because of Package Restore +**/packages/* +# except build/, which is used as an MSBuild target. +!**/packages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/packages/repositories.config +# NuGet v3's project.json files produces more ignoreable files +*.nuget.props +*.nuget.targets + +# 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 + +# 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 +node_modules/ +orleans.codegen.cs + +# 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 + +# SQL Server files +*.mdf +*.ldf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# 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/ + +# JetBrains Rider +.idea/ +*.sln.iml + +# CodeRush +.cr/ + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc +/obj/ +.DS_Store + +library.db +.envrc diff --git a/Backend.Api.Test/Backend.Api.Test.csproj b/Backend.Api.Test/Backend.Api.Test.csproj new file mode 100644 index 0000000..eb5bd83 --- /dev/null +++ b/Backend.Api.Test/Backend.Api.Test.csproj @@ -0,0 +1,33 @@ + + + + net8.0 + enable + enable + false + + + + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + diff --git a/Backend.Api.Test/Config/EnvironmentTest.cs b/Backend.Api.Test/Config/EnvironmentTest.cs new file mode 100644 index 0000000..dddc229 --- /dev/null +++ b/Backend.Api.Test/Config/EnvironmentTest.cs @@ -0,0 +1,17 @@ +using Microsoft.AspNetCore.Builder; + +namespace Backend.Api.Test.Config; + +public class EnvironmentTest +{ + + [Fact] + public void IsNotDevModeByDefault() + { + var _builder = WebApplication.CreateBuilder(); + + var isDev = Backend.Api.Config.Environment.IsDevMode(_builder); + + Assert.False(isDev); + } +} diff --git a/Backend.Api.Test/Example/Services/ExamplePersistenceTests.cs b/Backend.Api.Test/Example/Services/ExamplePersistenceTests.cs new file mode 100644 index 0000000..b91b2c4 --- /dev/null +++ b/Backend.Api.Test/Example/Services/ExamplePersistenceTests.cs @@ -0,0 +1,89 @@ +using MongoDB.Driver; +using Backend.Api.Utils.Mongo; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using Backend.Api.Example.Models; +using Backend.Api.Example.Services; +using FluentAssertions; +using MongoDB.Bson; + +namespace Backend.Api.Test.Example.Services; + +public class ExamplePersistenceTests +{ + + private readonly IMongoDbClientFactory _conFactoryMock = Substitute.For(); + private readonly IMongoCollection _collectionMock = Substitute.For>(); + private readonly IMongoDatabase _databaseMock = Substitute.For(); + private readonly CollectionNamespace _collectionNamespace = new("test", "example"); + + private readonly ExamplePersistence _persistence; + + public ExamplePersistenceTests() + { + _collectionMock + .CollectionNamespace + .Returns(_collectionNamespace); + _collectionMock + .Database + .Returns(_databaseMock); + _databaseMock + .DatabaseNamespace + .Returns(new DatabaseNamespace("test")); + _conFactoryMock + .GetClient() + .Returns(Substitute.For()); + _conFactoryMock + .GetCollection("example") + .Returns(_collectionMock); + + _persistence = new ExamplePersistence(_conFactoryMock, NullLoggerFactory.Instance); + } + + [Fact] + public async Task CreateAsyncOk() + { + _collectionMock + .InsertOneAsync(Arg.Any()) + .Returns(Task.CompletedTask); + + var example = new ExampleModel() + { + Id = new ObjectId(), + Value = "some value", + Name = "Test", + Counter = 0 + }; + var result = await _persistence.CreateAsync(example); + result.Should().BeTrue(); + } + + [Fact] + public async Task CreateAsyncLogError() + { + + var loggerFactoryMock = Substitute.For(); + var logMock = Substitute.For>(); + loggerFactoryMock.CreateLogger().Returns(logMock); + + _collectionMock + .InsertOneAsync(Arg.Any()) + .Returns(Task.FromException(new Exception())); + + var persistence = new ExamplePersistence(_conFactoryMock, loggerFactoryMock); + + var example = new ExampleModel() + { + Id = new ObjectId(), + Value = "some value", + Name = "Test", + Counter = 0 + }; + + var result = await persistence.CreateAsync(example); + + result.Should().BeFalse(); + } + +} diff --git a/Backend.Api.Test/Example/Validators/ExampleValidatorTests.cs b/Backend.Api.Test/Example/Validators/ExampleValidatorTests.cs new file mode 100644 index 0000000..25a2589 --- /dev/null +++ b/Backend.Api.Test/Example/Validators/ExampleValidatorTests.cs @@ -0,0 +1,69 @@ +using Backend.Api.Example.Models; +using Backend.Api.Example.Validators; +using FluentValidation.TestHelper; +using MongoDB.Bson; + +namespace Backend.Api.Test.Example.Validators; + +public class ExampleValidatorTests +{ + private readonly ExampleValidator _validator = new(); + + [Fact] + public void ValidModel() + { + var model = new ExampleModel() + { + Id = new ObjectId(), + Value = "some value", + Name = "Test", + Counter = 0 + }; + var result = _validator.TestValidate(model); + result.ShouldNotHaveAnyValidationErrors(); + } + + [Fact] + public void InvalidName() + { + var model = new ExampleModel() + { + Id = new ObjectId(), + Value = "Some value", + Name = "Test $FOO someName" // letters/numbers/spaces only + }; + var result = _validator.TestValidate(model); + result.ShouldHaveValidationErrorFor(b => b.Name); + } + + [Fact] + public void InvalidCounter() + { + var model = new ExampleModel() + { + Id = new ObjectId(), + Value = "Some value", + Name = "Test", + Counter = -1 + + }; + var result = _validator.TestValidate(model); + result.ShouldHaveValidationErrorFor(b => b.Counter); + } + + [Fact] + public void EmptyValue() + { + var model = new ExampleModel() + { + Id = new ObjectId(), + Value = "", + Name = "Test", + Counter = 0 + + }; + var result = _validator.TestValidate(model); + result.ShouldHaveValidationErrorFor(b => b.Value); + } + +} diff --git a/Backend.Api.Test/Utils/Http/ProxyTest.cs b/Backend.Api.Test/Utils/Http/ProxyTest.cs new file mode 100644 index 0000000..7121fa4 --- /dev/null +++ b/Backend.Api.Test/Utils/Http/ProxyTest.cs @@ -0,0 +1,114 @@ +using Backend.Api.Utils.Http; +using NSubstitute; +using Microsoft.Extensions.Logging.Abstractions; +using FluentAssertions; +using Serilog.Core; +using Elastic.CommonSchema; +using Serilog; + +namespace Backend.Api.Test.Utils.Http; + +public class ProxyTest +{ + + private readonly Logger logger = new LoggerConfiguration().CreateLogger(); + + private readonly string proxyUri = "http://user:password@localhost:8080"; + private readonly string localProxy = "http://localhost:8080/"; + private readonly string localhost = "http://localhost/"; + + public ProxyTest() + { + } + + [Fact] + public void ExtractProxyCredentials() + { + + var proxy = new System.Net.WebProxy + { + BypassProxyOnLocal = true + }; + + Proxy.ConfigureProxy(proxy, proxyUri, logger); + + var credentials = proxy.Credentials?.GetCredential(new System.Uri(proxyUri), "Basic"); + + credentials?.UserName.Should().Be("user"); + credentials?.Password.Should().Be("password"); + } + + [Fact] + public void ExtractProxyEmptyCredentials() + { + var noPasswordUri = "http://user@localhost:8080"; + + var proxy = new System.Net.WebProxy + { + BypassProxyOnLocal = true + }; + + Proxy.ConfigureProxy(proxy, noPasswordUri, logger); + + proxy.Credentials.Should().BeNull(); + } + + [Fact] + public void ExtractProxyUri() + { + + var proxy = new System.Net.WebProxy + { + BypassProxyOnLocal = true + }; + + Proxy.ConfigureProxy(proxy, proxyUri, logger); + proxy.Address.Should().NotBeNull(); + proxy.Address?.AbsoluteUri.Should().Be(localProxy); + } + + [Fact] + public void CreateProxyFromUri() + { + + var proxy = Proxy.CreateProxy(proxyUri, logger); + + proxy.Address.Should().NotBeNull(); + proxy.Address?.AbsoluteUri.Should().Be(localProxy); + } + + [Fact] + public void CreateNoProxyFromEmptyUri() + { + var proxy = Proxy.CreateProxy(null, logger); + + proxy.Address.Should().BeNull(); + } + + [Fact] + public void ProxyShouldBypassLocal() + { + + var proxy = Proxy.CreateProxy(proxyUri, logger); + + proxy.BypassProxyOnLocal.Should().BeTrue(); + proxy.IsBypassed(new Uri(localhost)).Should().BeTrue(); + proxy.IsBypassed(new Uri("https://defra.gov.uk")).Should().BeFalse(); + } + + [Fact] + public void HandlerShouldHaveProxy() + { + var handler = Proxy.CreateHttpClientHandler(proxyUri, logger); + + handler.Proxy.Should().NotBeNull(); + handler.UseProxy.Should().BeTrue(); + handler.Proxy?.Credentials.Should().NotBeNull(); + handler.Proxy?.GetProxy(new Uri(localhost)).Should().NotBeNull(); + handler.Proxy?.GetProxy(new Uri("http://google.com")).Should().NotBeNull(); + handler.Proxy?.GetProxy(new Uri(localhost))?.AbsoluteUri.Should().Be(localhost); + handler.Proxy?.GetProxy(new Uri("http://google.com"))?.AbsoluteUri.Should().Be(localProxy); + } + + +} diff --git a/Backend.Api.Test/Utils/Mongo/MongoServiceTest.cs b/Backend.Api.Test/Utils/Mongo/MongoServiceTest.cs new file mode 100644 index 0000000..0ce21ed --- /dev/null +++ b/Backend.Api.Test/Utils/Mongo/MongoServiceTest.cs @@ -0,0 +1,112 @@ +using Microsoft.Extensions.Logging; +using MongoDB.Driver; +using NSubstitute; +using Microsoft.Extensions.Logging.Abstractions; +using Backend.Api.Utils.Mongo; + +namespace Backend.Api.Tests.Utils.Mongo +{ + public class MongoServiceTests + { + private readonly IMongoDbClientFactory _connectionFactoryMock; + private readonly ILoggerFactory _loggerFactoryMock; + private readonly IMongoClient _clientMock; + private readonly IMongoCollection _collectionMock; + + private readonly TestMongoService _service; + + public MongoServiceTests() + { + _connectionFactoryMock = Substitute.For(); + _loggerFactoryMock = Substitute.For(); + _clientMock = Substitute.For(); + _collectionMock = Substitute.For>(); + + _connectionFactoryMock + .GetClient() + .Returns(Substitute.For()); + + _connectionFactoryMock + .GetCollection(Arg.Any()) + .Returns(_collectionMock); + + _collectionMock.CollectionNamespace.Returns(new CollectionNamespace("test", "example")); + _collectionMock.Database.DatabaseNamespace.Returns(new DatabaseNamespace("test")); + + + _service = new TestMongoService(_connectionFactoryMock, "testCollection", NullLoggerFactory.Instance); + + _collectionMock.DidNotReceive().Indexes.CreateMany(Arg.Any>>()); + } + + [Fact] + public void EnsureIndexes_CreatesIndexes_WhenIndexesAreDefined() + { + var _indexes = new List>() + { + new CreateIndexModel(Builders.IndexKeys.Ascending(x => x.Name)) + }; + _service.setIndexes(_indexes); + _service.RunEnsureIndexes(); + + _collectionMock.Received(1).Indexes.CreateMany(_indexes); + } + + [Fact] + public void EnsureIndexes_DoesNotCreateIndexes_WhenIndexesAreNotDefined() + { + _service.setIndexes(new List>()); + _service.RunEnsureIndexes(); + + _collectionMock.DidNotReceive().Indexes.CreateMany(Arg.Any>>()); + + } + + public class TestModel + { + public string? Name { get; set; } + } + + public interface ITestMongoService + { + public List> getIndexes(); + public void setIndexes(List> indexes); + } + private class TestMongoService : MongoService, ITestMongoService + { + protected List> _indexes = new List>(); + + public TestMongoService(IMongoDbClientFactory connectionFactory, string collectionName, ILoggerFactory loggerFactory) + : base(connectionFactory, collectionName, loggerFactory) + { + } + + public List> getIndexes() + { + return _indexes; + } + + public void setIndexes(List> indexes) + { + this._indexes = indexes; + } + + protected override List> DefineIndexes(IndexKeysDefinitionBuilder builder) + { + if (getIndexes() == null) + { + throw new System.Exception("Indexes not defined"); + } + return getIndexes(); + } + + public void RunEnsureIndexes() + { + base.EnsureIndexes(); + } + + + } + + } +} diff --git a/Backend.Api/Backend.Api.csproj b/Backend.Api/Backend.Api.csproj new file mode 100644 index 0000000..3b10d61 --- /dev/null +++ b/Backend.Api/Backend.Api.csproj @@ -0,0 +1,27 @@ + + + + net8.0 + enable + enable + Linux + + + + + .dockerignore + + + + + + + + + + + + + + + diff --git a/Backend.Api/Config/Environment.cs b/Backend.Api/Config/Environment.cs new file mode 100644 index 0000000..35a23d0 --- /dev/null +++ b/Backend.Api/Config/Environment.cs @@ -0,0 +1,9 @@ +namespace Backend.Api.Config; + +public static class Environment +{ + public static bool IsDevMode(this WebApplicationBuilder builder) + { + return !builder.Environment.IsProduction(); + } +} diff --git a/Backend.Api/Example/Endpoints/ExampleEndpoints.cs b/Backend.Api/Example/Endpoints/ExampleEndpoints.cs new file mode 100644 index 0000000..66e246c --- /dev/null +++ b/Backend.Api/Example/Endpoints/ExampleEndpoints.cs @@ -0,0 +1,79 @@ +using Backend.Api.Example.Models; +using Backend.Api.Example.Services; +using FluentValidation; +using FluentValidation.Results; +using System.Diagnostics.CodeAnalysis; + +namespace Backend.Api.Example.Endpoints; + + [ExcludeFromCodeCoverage] +public static class ExampleEndpoints +{ + public static void UseExampleEndpoints(this IEndpointRouteBuilder app) + { + app.MapPost("example", Create); + + app.MapGet("example", GetAll); + + app.MapGet("example/{name}", GetByName); + + app.MapPut("example/{name}", Update); + + app.MapDelete("example/{name}", Delete); + } + + private static async Task Create( + ExampleModel example, IExamplePersistence examplePersistence, IValidator validator) + { + var validationResult = await validator.ValidateAsync(example); + if (!validationResult.IsValid) return Results.BadRequest(validationResult.Errors); + + var created = await examplePersistence.CreateAsync(example); + if (!created) + return Results.BadRequest(new List + { + new("Example", "An example record with this name already exists") + }); + + return Results.Created($"/example/{example.Name}", example); + } + + private static async Task GetAll( + IExamplePersistence examplePersistence, string? searchTerm) + { + if (searchTerm is not null && !string.IsNullOrWhiteSpace(searchTerm)) + { + var matched = await examplePersistence.SearchByValueAsync(searchTerm); + return Results.Ok(matched); + } + + var matches = await examplePersistence.GetAllAsync(); + return Results.Ok(matches); + } + + private static async Task GetByName( + string name, IExamplePersistence examplePersistence) + { + var example = await examplePersistence.GetByExampleName(name); + return example is not null ? Results.Ok(example) : Results.NotFound(); + } + + private static async Task Update( + string name, ExampleModel example, IExamplePersistence examplePersistence, + IValidator validator) + { + example.Name = name; + var validationResult = await validator.ValidateAsync(example); + if (!validationResult.IsValid) return Results.BadRequest(validationResult.Errors); + + var updated = await examplePersistence.UpdateAsync(example); + return updated ? Results.Ok(example) : Results.NotFound(); + } + + private static async Task Delete( + string name, IExamplePersistence examplePersistence) + { + var deleted = await examplePersistence.DeleteAsync(name); + return deleted ? Results.Ok() : Results.NotFound(); + } +} diff --git a/Backend.Api/Example/Models/ExampleModel.cs b/Backend.Api/Example/Models/ExampleModel.cs new file mode 100644 index 0000000..7d14a61 --- /dev/null +++ b/Backend.Api/Example/Models/ExampleModel.cs @@ -0,0 +1,21 @@ +using System.Text.Json.Serialization; +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; +using MongoDB.Bson.Serialization.IdGenerators; + +namespace Backend.Api.Example.Models; + +public class ExampleModel +{ + [BsonId(IdGenerator = typeof(ObjectIdGenerator))] + [property: JsonIgnore(Condition = JsonIgnoreCondition.Always)] + public ObjectId Id { get; init; } = default!; + + public string Name { get; set; } = default!; + + public string Value { get; set; } = default!; + + public int? Counter { get; set; } = 0; + + public DateTime? Created { get; set; } = DateTime.UtcNow; +} diff --git a/Backend.Api/Example/Services/ExamplePersistence.cs b/Backend.Api/Example/Services/ExamplePersistence.cs new file mode 100644 index 0000000..dfce81f --- /dev/null +++ b/Backend.Api/Example/Services/ExamplePersistence.cs @@ -0,0 +1,106 @@ +using Backend.Api.Example.Models; +using Backend.Api.Utils.Mongo; +using MongoDB.Driver; +using System.Diagnostics.CodeAnalysis; + +namespace Backend.Api.Example.Services; + +public interface IExamplePersistence +{ + public Task CreateAsync(ExampleModel example); + + public Task GetByExampleName(string name); + + public Task> GetAllAsync(); + + public Task> SearchByValueAsync(string searchTerm); + + public Task UpdateAsync(ExampleModel example); + + public Task DeleteAsync(string name); +} + +/** + * An example of how to persist data in MongoDB. + * The base class `MongoService` provides access to the db collection as well as providing helpers to + * ensure the indexes for this collection are created on startup. + */ + +public class ExamplePersistence(IMongoDbClientFactory connectionFactory, ILoggerFactory loggerFactory) + : MongoService(connectionFactory, "example", loggerFactory), IExamplePersistence +{ + public async Task CreateAsync(ExampleModel example) + { + try + { + await Collection.InsertOneAsync(example); + return true; + } + catch (Exception e) + { + _logger.LogError(e, "Failed to insert {example}", example); + return false; + } + } + + [ExcludeFromCodeCoverage] + public async Task GetByExampleName(string name) + { + var result = await Collection.Find(b => b.Name == name).FirstOrDefaultAsync(); + _logger.LogInformation("Searching for {Name}, found {Result}", name, result); + return result; + } + + [ExcludeFromCodeCoverage] + public async Task> GetAllAsync() + { + return await Collection.Find(_ => true).ToListAsync(); + } + + + [ExcludeFromCodeCoverage] + public async Task> SearchByValueAsync(string searchTerm) + { + var searchOptions = new TextSearchOptions { CaseSensitive = false, DiacriticSensitive = false }; + var filter = Builders.Filter.Text(searchTerm, searchOptions); + var result = await Collection.Find(filter).ToListAsync(); + return result; + } + + /** + * Updates the value field for a given name and increments the counter. + * Rather than replacing the whole record we selectively $set and $inc fields while leaving others + * unchanged. + */ + [ExcludeFromCodeCoverage] + public async Task UpdateAsync(ExampleModel example) + { + var filter = Builders.Filter.Eq(e => e.Name, example.Name); + var update = Builders.Update + .Inc(e => e.Counter, 1) + .Set(e => e.Value, example.Value); + + var result = await Collection.UpdateOneAsync(filter, update); + return result.ModifiedCount > 0; + } + + [ExcludeFromCodeCoverage] + public async Task DeleteAsync(string name) + { + var result = await Collection.DeleteOneAsync(e => e.Name == name); + return result.DeletedCount > 0; + } + + /** + * Ensure indexes are created for this collection. + * In this example it creates a single index on the `name` field. The Unique flag is set preventing duplicate names + * being inserted. + */ + [ExcludeFromCodeCoverage] + protected override List> DefineIndexes(IndexKeysDefinitionBuilder builder) + { + var options = new CreateIndexOptions { Unique = true }; + var nameIndex = new CreateIndexModel(builder.Ascending(e => e.Name), options); + return [nameIndex]; + } +} diff --git a/Backend.Api/Example/Validators/ExampleValidator.cs b/Backend.Api/Example/Validators/ExampleValidator.cs new file mode 100644 index 0000000..7240889 --- /dev/null +++ b/Backend.Api/Example/Validators/ExampleValidator.cs @@ -0,0 +1,21 @@ +using Backend.Api.Example.Models; +using FluentValidation; + +namespace Backend.Api.Example.Validators; + +public class ExampleValidator : AbstractValidator +{ + /** + * Example model validator. + */ + public ExampleValidator() + { + RuleFor(model => model.Name) + .Matches(@"^[\w\s]+$") + .Length(3, 20) + .WithMessage("Name was not valid. Must be between 3 and 20 characters and contain only letters, numbers and whitespace."); + + RuleFor(model => model.Counter).GreaterThanOrEqualTo(0); + RuleFor(model => model.Value).NotEmpty(); + } +} \ No newline at end of file diff --git a/Backend.Api/Program.cs b/Backend.Api/Program.cs new file mode 100644 index 0000000..485105c --- /dev/null +++ b/Backend.Api/Program.cs @@ -0,0 +1,95 @@ +using Backend.Api.Example.Endpoints; +using Backend.Api.Example.Services; +using Backend.Api.Utils; +using Backend.Api.Utils.Http; +using Backend.Api.Utils.Logging; +using Backend.Api.Utils.Mongo; +using FluentValidation; +using Serilog; +using Serilog.Core; +using System.Diagnostics.CodeAnalysis; + +//-------- Configure the WebApplication builder------------------// + +var app = CreateWebApplication(args); +await app.RunAsync(); + + +[ExcludeFromCodeCoverage] +static WebApplication CreateWebApplication(string[] args) +{ + var _builder = WebApplication.CreateBuilder(args); + + ConfigureWebApplication(_builder); + + var _app = BuildWebApplication(_builder); + + return _app; +} + +[ExcludeFromCodeCoverage] +static void ConfigureWebApplication(WebApplicationBuilder _builder) +{ + _builder.Configuration.AddEnvironmentVariables(); + + var logger = ConfigureLogging(_builder); + + // Load certificates into Trust Store - Note must happen before Mongo and Http client connections + _builder.Services.AddCustomTrustStore(logger); + + ConfigureMongoDb(_builder); + + ConfigureEndpoints(_builder); + + _builder.Services.AddHttpClient(); + + // calls outside the platform should be done using the named 'proxy' http client. + _builder.Services.AddHttpProxyClient(logger); + + _builder.Services.AddValidatorsFromAssemblyContaining(); +} + +[ExcludeFromCodeCoverage] +static Logger ConfigureLogging(WebApplicationBuilder _builder) +{ + _builder.Logging.ClearProviders(); + var logger = new LoggerConfiguration() + .ReadFrom.Configuration(_builder.Configuration) + .Enrich.With() + .Enrich.WithProperty("service.version", Environment.GetEnvironmentVariable("SERVICE_VERSION")) + .CreateLogger(); + _builder.Logging.AddSerilog(logger); + logger.Information("Starting application"); + return logger; +} + +[ExcludeFromCodeCoverage] +static void ConfigureMongoDb(WebApplicationBuilder _builder) +{ + _builder.Services.AddSingleton(_ => + new MongoDbClientFactory(_builder.Configuration.GetValue("Mongo:DatabaseUri")!, + _builder.Configuration.GetValue("Mongo:DatabaseName")!)); +} + +[ExcludeFromCodeCoverage] +static void ConfigureEndpoints(WebApplicationBuilder _builder) +{ + // our Example service, remove before deploying! + _builder.Services.AddSingleton(); + + _builder.Services.AddHealthChecks(); +} + +[ExcludeFromCodeCoverage] +static WebApplication BuildWebApplication(WebApplicationBuilder _builder) +{ + var app = _builder.Build(); + + app.UseRouting(); + app.MapHealthChecks("/health"); + + // Example module, remove before deploying! + app.UseExampleEndpoints(); + + return app; +} diff --git a/Backend.Api/Properties/launchSettings.json b/Backend.Api/Properties/launchSettings.json new file mode 100644 index 0000000..d9531c6 --- /dev/null +++ b/Backend.Api/Properties/launchSettings.json @@ -0,0 +1,13 @@ +{ + "profiles": { + "Backend.Api": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/Backend.Api/Utils/Http/Proxy.cs b/Backend.Api/Utils/Http/Proxy.cs new file mode 100644 index 0000000..74b08ae --- /dev/null +++ b/Backend.Api/Utils/Http/Proxy.cs @@ -0,0 +1,84 @@ +using System.Net; +using Serilog.Core; +using System.Diagnostics.CodeAnalysis; + +namespace Backend.Api.Utils.Http; + +public static class Proxy +{ + public const string ProxyClient = "proxy"; + + /** + * A preconfigured HTTP Client that uses the Platform's outbound proxy. + * + * Usage: + * 1. inject an `IHttpClientFactory` into your class. + * 2. Use the IHttpClientFactory to create a named instance of HttpClient: + * `clientFactory.CreateClient(Proxy.ProxyClient);` + */ + [ExcludeFromCodeCoverage] + public static void AddHttpProxyClient(this IServiceCollection services, Logger logger) + { + services.AddHttpClient(ProxyClient).ConfigurePrimaryHttpMessageHandler(() => + { + return ConfigurePrimaryHttpMessageHandler(logger); + }); + } + + [ExcludeFromCodeCoverage] + public static HttpClientHandler ConfigurePrimaryHttpMessageHandler(Logger logger) + { + var proxyUri = Environment.GetEnvironmentVariable("CDP_HTTPS_PROXY"); + return CreateHttpClientHandler(proxyUri, logger); + } + + public static HttpClientHandler CreateHttpClientHandler(string? proxyUri, Logger logger) + { + var proxy = CreateProxy(proxyUri, logger); + return new HttpClientHandler { Proxy = proxy, UseProxy = proxyUri != null }; + } + + public static WebProxy CreateProxy(string? proxyUri, Logger logger) + { + var proxy = new WebProxy + { + BypassProxyOnLocal = true + }; + if (proxyUri != null) + { + ConfigureProxy(proxy, proxyUri, logger); + } + else + { + logger.Warning("CDP_HTTP_PROXY is NOT set, proxy client will be disabled"); + } + return proxy; + } + + public static void ConfigureProxy(WebProxy proxy, string proxyUri, Logger logger) + { + logger.Debug("Creating proxy http client"); + var uri = new UriBuilder(proxyUri); + + var credentials = GetCredentialsFromUri(uri); + if (credentials != null) + { + logger.Debug("Setting proxy credentials"); + proxy.Credentials = credentials; + } + + // Remove credentials from URI to so they don't get logged. + uri.UserName = ""; + uri.Password = ""; + proxy.Address = uri.Uri; + } + + private static NetworkCredential? GetCredentialsFromUri(UriBuilder uri) + { + var username = uri.UserName; + var password = uri.Password; + if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password)) return null; + return new NetworkCredential(username, password); + } + +} diff --git a/Backend.Api/Utils/Logging/LogLevelMapper.cs b/Backend.Api/Utils/Logging/LogLevelMapper.cs new file mode 100644 index 0000000..b6b1c5f --- /dev/null +++ b/Backend.Api/Utils/Logging/LogLevelMapper.cs @@ -0,0 +1,27 @@ +using Serilog.Core; +using Serilog.Events; +using System.Diagnostics.CodeAnalysis; + +namespace Backend.Api.Utils.Logging; + +[ExcludeFromCodeCoverage] +/** + * Maps log levels from the C# default 'Information' etc to the node style 'info'. + */ +public class LogLevelMapper : ILogEventEnricher +{ + public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory) + { + var logLevel = logEvent.Level switch + { + LogEventLevel.Information => "info", + LogEventLevel.Debug => "debug", + LogEventLevel.Error => "error", + LogEventLevel.Fatal => "fatal", + LogEventLevel.Warning => "warn", + _ => "all" + }; + + logEvent.AddOrUpdateProperty(propertyFactory.CreateProperty("log.level", logLevel)); + } +} diff --git a/Backend.Api/Utils/Mongo/IMongoDbClientFactory.cs b/Backend.Api/Utils/Mongo/IMongoDbClientFactory.cs new file mode 100644 index 0000000..9e85064 --- /dev/null +++ b/Backend.Api/Utils/Mongo/IMongoDbClientFactory.cs @@ -0,0 +1,10 @@ +using MongoDB.Driver; + +namespace Backend.Api.Utils.Mongo; + +public interface IMongoDbClientFactory +{ + IMongoClient GetClient(); + + IMongoCollection GetCollection(string collection); +} \ No newline at end of file diff --git a/Backend.Api/Utils/Mongo/MongoDbClientFactory.cs b/Backend.Api/Utils/Mongo/MongoDbClientFactory.cs new file mode 100644 index 0000000..f315f16 --- /dev/null +++ b/Backend.Api/Utils/Mongo/MongoDbClientFactory.cs @@ -0,0 +1,45 @@ +using MongoDB.Bson.Serialization.Conventions; +using MongoDB.Driver; +using System.Diagnostics.CodeAnalysis; + +namespace Backend.Api.Utils.Mongo; + +[ExcludeFromCodeCoverage] + +public class MongoDbClientFactory : IMongoDbClientFactory +{ + private readonly IMongoDatabase _mongoDatabase; + private readonly MongoClient _client; + + public MongoDbClientFactory(string? connectionString, string databaseName) + { + if (string.IsNullOrWhiteSpace(connectionString)) + throw new ArgumentException("MongoDB connection string cannot be empty"); + + var settings = MongoClientSettings.FromConnectionString(connectionString); + _client = new MongoClient(settings); + + var camelCaseConvention = new ConventionPack { new CamelCaseElementNameConvention() }; + // convention must be registered before initialising collection + ConventionRegistry.Register("CamelCase", camelCaseConvention, _ => true); + + _mongoDatabase = _client.GetDatabase(databaseName); + } + + public IMongoClient CreateClient() + { + + return _client; + } + + public IMongoCollection GetCollection(string collection) + { + return _mongoDatabase.GetCollection(collection); + } + + public IMongoClient GetClient() + { + return _client; + } + +} diff --git a/Backend.Api/Utils/Mongo/MongoService.cs b/Backend.Api/Utils/Mongo/MongoService.cs new file mode 100644 index 0000000..11cd952 --- /dev/null +++ b/Backend.Api/Utils/Mongo/MongoService.cs @@ -0,0 +1,37 @@ +using MongoDB.Driver; +using System.Diagnostics.CodeAnalysis; + +namespace Backend.Api.Utils.Mongo; + +[ExcludeFromCodeCoverage] +public abstract class MongoService +{ + protected readonly IMongoClient Client; + protected readonly IMongoCollection Collection; + + protected readonly ILogger _logger; + + protected MongoService(IMongoDbClientFactory connectionFactory, string collectionName, ILoggerFactory loggerFactory) + { + Client = connectionFactory.GetClient(); + Collection = connectionFactory.GetCollection(collectionName); + var loggerName = GetType().FullName ?? GetType().Name; + _logger = loggerFactory.CreateLogger(loggerName); + EnsureIndexes(); + } + + protected abstract List> DefineIndexes(IndexKeysDefinitionBuilder builder); + + protected void EnsureIndexes() + { + var builder = Builders.IndexKeys; + var indexes = DefineIndexes(builder); + if (indexes.Count == 0) return; + + _logger.LogInformation( + "Ensuring index is created if it does not exist for collection {CollectionNamespaceCollectionName} in DB {DatabaseDatabaseNamespace}", + Collection.CollectionNamespace.CollectionName, + Collection.Database.DatabaseNamespace); + Collection.Indexes.CreateMany(indexes); + } +} diff --git a/Backend.Api/Utils/TrustStore.cs b/Backend.Api/Utils/TrustStore.cs new file mode 100644 index 0000000..239de65 --- /dev/null +++ b/Backend.Api/Utils/TrustStore.cs @@ -0,0 +1,64 @@ +using System.Collections; +using System.Security.Cryptography.X509Certificates; +using System.Text; +using Serilog.Core; +using System.Diagnostics.CodeAnalysis; + +namespace Backend.Api.Utils; + +[ExcludeFromCodeCoverage] +public static class TrustStore +{ + public static void AddCustomTrustStore(this IServiceCollection _, Logger logger) + { + logger.Information("Loading Certificates into Trust store"); + var certificates = GetCertificates(logger); + AddCertificates(certificates); + } + + private static List GetCertificates(Logger logger) + { + return Environment.GetEnvironmentVariables().Cast() + .Where(entry => entry.Key.ToString()!.StartsWith("TRUSTSTORE") && IsBase64String(entry.Value!.ToString() ?? "")) + .Select(entry => + { + var data = Convert.FromBase64String(entry.Value!.ToString() ?? ""); + logger.Information($"{entry.Key} certificate decoded"); + return Encoding.UTF8.GetString(data); + }).ToList(); + } + + private static void AddCertificates(IReadOnlyCollection certificates) + { + if (certificates.Count == 0) return; // to stop trust store access denied issues on Macs + var x509Certificate2S = certificates.Select( + cert => new X509Certificate2(Encoding.ASCII.GetBytes(cert))); + var certificateCollection = new X509Certificate2Collection(); + + foreach (var certificate2 in x509Certificate2S) + { + certificateCollection.Add(certificate2); + } + + var store = new X509Store(StoreName.Root, StoreLocation.CurrentUser); + try + { + store.Open(OpenFlags.ReadWrite); + store.AddRange(certificateCollection); + } + catch (Exception ex) + { + throw new FileLoadException("Root certificate import failed: " + ex.Message, ex); + } + finally + { + store.Close(); + } + } + + private static bool IsBase64String(string str) + { + var buffer = new Span(new byte[str.Length]); + return Convert.TryFromBase64String(str, buffer, out _); + } +} diff --git a/Backend.Api/appsettings.Development.json b/Backend.Api/appsettings.Development.json new file mode 100644 index 0000000..baee8e1 --- /dev/null +++ b/Backend.Api/appsettings.Development.json @@ -0,0 +1,30 @@ +{ + "Mongo": { + "DatabaseUri": "mongodb://127.0.0.1:27017", + "DatabaseName": "cdp-dotnet-backend-template" + }, + "DetailedErrors": true, + "AllowedHosts": "*", + "Serilog": { + "MinimumLevel": { + "Default": "Information", + "Override": { + "Microsoft": "Information", + "System": "Information" + } + }, + "Enrich": [ + "FromLogContext", + "WithMachineName", + "WithThreadId" + ], + "WriteTo": [ + { + "Name": "Console", + "Args": { + "outputTemplate": "{Timestamp:o} [{Level:u4}] ({Application}/{MachineName}/{ThreadId}/{SourceContext}.{Method}) {Message}{NewLine}{Exception}" + } + } + ] + } +} diff --git a/Backend.Api/appsettings.json b/Backend.Api/appsettings.json new file mode 100644 index 0000000..aa16f77 --- /dev/null +++ b/Backend.Api/appsettings.json @@ -0,0 +1,29 @@ +{ + "Mongo": { + "DatabaseUri": "mongodb://set-automatically-when-deployed/admin?authSource=$external&authMechanism=MONGODB-AWS", + "DatabaseName": "cdp-dotnet-backend-template" + }, + "AllowedHosts": "*", + "Serilog": { + "MinimumLevel": { + "Default": "Information", + "Override": { + "Microsoft": "Information", + "System": "Information" + } + }, + "Enrich": [ + "FromLogContext", + "WithMachineName", + "WithThreadId" + ], + "WriteTo": [ + { + "Name": "Console", + "Args": { + "formatter": "Elastic.CommonSchema.Serilog.EcsTextFormatter, Elastic.CommonSchema.Serilog" + } + } + ] + } +} diff --git a/CdpDotnetBackendTemplate.sln b/CdpDotnetBackendTemplate.sln new file mode 100644 index 0000000..8cb2d17 --- /dev/null +++ b/CdpDotnetBackendTemplate.sln @@ -0,0 +1,28 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Backend.Api", "Backend.Api\Backend.Api.csproj", "{7D935959-D3BE-4EDC-BAEC-541C72741633}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Backend.Api.Test", "Backend.Api.Test\Backend.Api.Test.csproj", "{5CB86A4A-162D-4A20-9403-5BB89D671BF6}" +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 + {7D935959-D3BE-4EDC-BAEC-541C72741633}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7D935959-D3BE-4EDC-BAEC-541C72741633}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7D935959-D3BE-4EDC-BAEC-541C72741633}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7D935959-D3BE-4EDC-BAEC-541C72741633}.Release|Any CPU.Build.0 = Release|Any CPU + {5CB86A4A-162D-4A20-9403-5BB89D671BF6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5CB86A4A-162D-4A20-9403-5BB89D671BF6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5CB86A4A-162D-4A20-9403-5BB89D671BF6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5CB86A4A-162D-4A20-9403-5BB89D671BF6}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3934eb6 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,35 @@ +# Base dotnet image +FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base +WORKDIR /app +EXPOSE 80 +EXPOSE 443 + +# Add curl to template. +# CDP PLATFORM HEALTHCHECK REQUIREMENT +RUN apt update && \ + apt install curl -y && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* + +# Build stage image +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build +WORKDIR /src + +COPY . . +WORKDIR "/src" + +# unit test and code coverage +RUN dotnet test Backend.Api.Test + +FROM build AS publish +RUN dotnet publish Backend.Api -c Release -o /app/publish /p:UseAppHost=false + + +ENV ASPNETCORE_FORWARDEDHEADERS_ENABLED=true + +# Final production image +FROM base AS final +WORKDIR /app +COPY --from=publish /app/publish . +EXPOSE 8085 +ENTRYPOINT ["dotnet", "Backend.Api.dll"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5aeaede --- /dev/null +++ b/LICENSE @@ -0,0 +1,8 @@ +The Open Government Licence (OGL) Version 3 + +Copyright (c) 2023 Defra + +This source code is licensed under the Open Government Licence v3.0. To view this +licence, visit www.nationalarchives.gov.uk/doc/open-government-licence/version/3 +or write to the Information Policy Team, The National Archives, Kew, Richmond, +Surrey, TW9 4DU. diff --git a/README.md b/README.md new file mode 100644 index 0000000..a412893 --- /dev/null +++ b/README.md @@ -0,0 +1,50 @@ +# CDP C# ASP.NET Backend Template + +Core delivery C# ASP.NET backend template. + +* [Install MongoDB](#install-mongodb) +* [Inspect MongoDB](#inspect-mongodb) +* [Testing](#testing) +* [Running](#running) +* [Dependabot](#dependabot) + +### Install MongoDB +- Install [MongoDB](https://www.mongodb.com/docs/manual/tutorial/#installation) on your local machine +- Start MongoDB: +```bash +sudo mongod --dbpath ~/mongodb-cdp +``` + +### Inspect MongoDB + +To inspect the Database and Collections locally: +```bash +mongosh +``` + +### Testing + +Run the tests with: + +Tests run by running a full `WebApplication` backed by [Ephemeral MongoDB](https://github.com/asimmon/ephemeral-mongo). +Tests do not use mocking of any sort and read and write from the in-memory database. + +```bash +dotnet test +```` + +### Running + +Run CDP-Deployments application: +```bash +dotnet run --project Backend.Api --launch-profile Development +``` + +### SonarCloud + +Example SonarCloud configuration are available in the GitHub Action workflows. + +### Dependabot + +We have added an example dependabot configuration file to the repository. You can enable it by renaming +the [.github/example.dependabot.yml](.github/example.dependabot.yml) to `.github/dependabot.yml`