first commit

This commit is contained in:
2026-06-29 07:36:04 +02:00
commit 5e5b875e37
108 changed files with 13347 additions and 0 deletions
+15
View File
@@ -0,0 +1,15 @@
Before running the checklist, run `git fetch origin`. If the current branch is behind `origin/master`, stop and rebase before proceeding.
Run the pre-commit checklist for this project:
1. Update `CHANGELOG.md` `[Unreleased]` section — add an entry under the appropriate subsection (Added / Changed / Fixed / Removed) describing the changes made, referencing the issue number.
2. Run `dotnet build --configuration Release` — must succeed.
3. Run `dotnet test --settings .runsettings` — all tests must pass.
4. If `dotnet csharpier` is available, run `dotnet csharpier --check .` — must pass
(run `dotnet csharpier .` to auto-fix). Skip this step with a note if not installed.
5. If `coderabbit` CLI is installed, run `coderabbit review --type uncommitted --prompt-only`:
- If actionable/serious findings are reported, stop and address them before proposing the commit.
- If only nitpick-level findings, report them and continue to the commit proposal.
- If `coderabbit` is not installed, skip this step with a note.
Run steps 1–4, report the results clearly, then run step 5 (CodeRabbit review) if available, then propose a branch name and commit message for my approval using the format `type(scope): description (#issue)` (max 80 chars; types: `feat` `fix` `chore` `docs` `test` `refactor` `ci` `perf`). Do not create the branch or commit until I explicitly confirm.
+121
View File
@@ -0,0 +1,121 @@
Run the pre-release checklist for this project. Work through all three phases
in order, pausing for explicit confirmation at each decision point before
proceeding. Never create a branch, commit, tag, or push without approval.
---
## Phase 1 — Determine next release
1. Run `git status` and confirm the working tree is clean and on `master`.
If not, stop and report the problem.
2. Run `git tag --sort=-v:refname` to list existing tags. Identify the most
recent tag matching `v*.*.*-*` and extract its stadium codename.
3. Read the A–Z stadium table from `CHANGELOG.md` to find the next stadium:
- **No tags yet**: start at `A` (first stadium in the table).
- **Normal case**: use the stadium that follows the last used codename
alphabetically. If letters were skipped, pick the next after the
highest existing codename — do not backfill gaps.
- **Last codename is `Z`** (Zentralstadion): the list is finite. Stop and
refer to ADR 0012 for guidance on extending or revisiting the convention.
4. Read the `[Unreleased]` section of `CHANGELOG.md` and infer the version
bump using these rules (applied in order — first match wins):
- Any entry contains the word **BREAKING** (case-insensitive), a
`BREAKING CHANGE:` token in a commit footer, or a `!` suffix after
the commit type/scope (e.g. `feat!:` or `feat(scope)!:`) → **major** bump
- Any `### Added` subsection has entries → **minor** bump
- Otherwise (only `### Changed`, `### Fixed`, `### Removed`) → **patch** bump
5. Compute the next version by applying the bump to the current latest tag's
semver (e.g. `v2.1.0-dusseldorf` + minor → `2.2.0`).
6. Present a summary for confirmation before continuing:
- Last tag and stadium
- Next version and stadium codename
- Bump type and the reasoning (what triggered it)
- Proposed tag: `vX.Y.Z-{stadium}`
- Proposed branch: `release/vX.Y.Z-{stadium}`
**Wait for explicit approval before proceeding to Phase 2.**
---
## Phase 2 — Prepare release branch
1. Create branch `release/vX.Y.Z-{stadium}` from `master`.
2. Edit `CHANGELOG.md`:
- Replace `## [Unreleased]` with `## [X.Y.Z - StadiumName] - YYYY-MM-DD`
(use today's date; use the stadium's display name from the table, e.g.
"Bernabeu", "Centenario").
- Consolidate duplicate subsection headings (e.g. two `### Added` blocks
should be merged into one).
- Add a new empty `## [Unreleased]` section at the top (above the new
versioned heading) with the standard subsections.
- Update the compare links at the bottom of the file:
- `[unreleased]` → `.../compare/vX.Y.Z-{stadium}...HEAD`
- Add `[X.Y.Z - StadiumName]` → `.../compare/v{prev-tag}...vX.Y.Z-{stadium}`
3. Show the full diff of `CHANGELOG.md`.
4. If `coderabbit` CLI is installed, run `coderabbit review --type uncommitted --prompt-only`
on the uncommitted CHANGELOG changes:
- If actionable/serious findings are reported, stop and address them before proceeding.
- If only nitpick-level findings, report them and continue.
- If `coderabbit` is not installed, skip with a note.
5. Propose this commit message:
```text
docs(changelog): prepare release notes for vX.Y.Z-{stadium} (#issue)
```
**Wait for explicit approval before committing.**
6. Run `dotnet build --configuration Release` — must succeed.
7. Run `dotnet test --settings .runsettings` — all tests must pass.
8. If `dotnet csharpier` is available, run `dotnet csharpier --check .` — must pass
(run `dotnet csharpier .` to auto-fix). Skip with a note if not installed.
9. Stage `CHANGELOG.md` and commit using the approved message from step 5.
10. Propose opening a PR from `release/vX.Y.Z-{stadium}` into `master`.
**Wait for explicit approval before opening.**
11. Open the PR with:
- Title: `docs(changelog): prepare release notes for vX.Y.Z-{stadium}`
- Body summarising what is included in this release.
---
## Phase 3 — Tag and release
1. Wait — do not proceed until the user confirms:
- CI is green
- The PR has been merged into `master`
2. Once confirmed, run:
```bash
git checkout master && git pull origin master
```
and show the resulting `git log --oneline -3`.
3. Propose the annotated tag:
```bash
git tag -a vX.Y.Z-{stadium} -m "Release X.Y.Z - StadiumName"
```
**Wait for explicit approval before creating the tag.**
4. Create the tag, then propose:
```bash
git push origin vX.Y.Z-{stadium}
```
**Wait for explicit approval before pushing.** Remind the user that pushing
the tag triggers the CD workflow which will build, publish the Docker image,
and create the GitHub Release.
+7
View File
@@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(dotnet *)"
]
}
}
+406
View File
@@ -0,0 +1,406 @@
# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
# https://docs.coderabbit.ai/getting-started/configure-coderabbit
# CodeRabbit Configuration
# Optimized for .NET 10 / ASP.NET Core Web API project
language: en-US
early_access: true
reviews:
profile: chill
request_changes_workflow: false
high_level_summary: true
high_level_summary_placeholder: "@coderabbitai summary"
review_status: true
commit_status: true
fail_commit_status: false
collapse_walkthrough: false
changed_files_summary: true
sequence_diagrams: true
estimate_code_review_effort: true
assess_linked_issues: true
related_issues: true
related_prs: true
suggested_labels: true
auto_apply_labels: false
suggested_reviewers: false
poem: false
abort_on_close: true
path_instructions:
- path: "**/*.cs"
instructions: |
- Follow C# naming conventions: PascalCase for classes/methods/properties, camelCase for local variables
- Ensure nullable reference types are handled properly
- Verify async/await patterns are used consistently
- Check for proper dependency injection usage
- Validate that var is used appropriately for obvious types
- Ensure ILogger<T> is used for logging, not Console.WriteLine
- Code should follow CSharpier formatting standards
- path: "src/**/Controllers/**/*.cs"
instructions: |
- Controllers should be thin - delegate to services
- Verify proper HTTP status codes (200, 201, 404, 409, 500); payload
validation failures (FluentValidation) must return 422 Unprocessable
Entity via TypedResults.Problem(new HttpValidationProblemDetails(...))
— not 400 Bad Request. See ADR-0006 in docs/adr/.
- Check that [ApiController] and route attributes are present
- Ensure validation happens before processing
- Confirm async controller actions return appropriate Result types
- path: "src/**/Services/**/*.cs"
instructions: |
- Services should contain business logic and orchestration
- Verify proper use of IMemoryCache for read operations
- Check that AutoMapper is used for object transformations
- Ensure structured logging with ILogger<T>
- Validate cache invalidation on data modifications
- path: "src/**/Repositories/**/*.cs"
instructions: |
- Repositories should abstract data access
- Verify AsNoTracking() is used for read-only queries
- Check that all database operations are async
- Ensure proper null handling for not-found scenarios
- Validate LINQ queries for performance
- path: "src/**/Models/**/*.cs"
instructions: |
- Models should be clean POCOs
- Check for proper use of nullable reference types
- Verify navigation properties are virtual (if using lazy loading)
- Ensure DTOs are separate from domain entities
- path: "src/**/Validators/**/*.cs"
instructions: |
- Validators should use FluentValidation
- Check validation rules are for data structure, not business logic
- Ensure error messages are descriptive
- Verify validators are registered in DI container
- path: "src/**/Mappings/**/*.cs"
instructions: |
- Profiles should inherit from AutoMapper Profile
- Verify CreateMap<Source, Destination>() calls cover all DTOs in use
- Check reverse mappings are defined where bidirectional mapping is needed
- Ensure profiles are registered via AddAutoMapper() in the DI setup
- Flag any manual property mappings that could be replaced by convention
- path: "src/**/Middlewares/**/*.cs"
instructions: |
- Middleware must implement InvokeAsync(HttpContext context) signature
- Verify _next(context) is called unless the middleware intentionally short-circuits
- Ensure exceptions are not swallowed — rethrow or translate to Problem Details
- Middleware should be stateless; dependencies requiring request scope belong in services
- path: "src/**/Extensions/**/*.cs"
instructions: |
- Extension methods should target IServiceCollection and return IServiceCollection
- Each method should encapsulate a single registration concern
- Verify service lifetimes (Singleton/Scoped/Transient) match the dependency's usage
- Follow the Add{Feature} naming convention (e.g. AddPlayerServices, AddRateLimiting)
- path: "test/**/*.cs"
instructions: |
- Tests should use xUnit, Moq, and FluentAssertions
- Verify test naming follows the two-pattern convention (exactly 3 underscore-delimited segments):
- Controller tests: {HttpMethod}_{Resource}_{Condition}_Returns{Outcome} (e.g. Get_Players_Existing_ReturnsPlayers)
- Service/Validator tests: {MethodName}_{StateUnderTest}_{ExpectedBehavior} (e.g. RetrieveAsync_CacheMiss_QueriesRepositoryAndCachesResult)
- Check that mocks are properly configured
- Ensure async tests use Task return type
- Validate test data uses PlayerFakes factory methods from test/Utilities/
- Tests should use [Fact] and [Theory] attributes with [Trait("Category", "Unit")]
- path: "**/Dockerfile"
instructions: |
- Verify multi-stage builds are used
- Check that .NET 10 SDK and runtime versions match
- Ensure non-root user is used for security
- Validate HEALTHCHECK instruction is present
- path: "**/appsettings*.json"
instructions: |
- Verify no sensitive data is committed
- Check configuration structure matches usage in code
- Ensure Development and Production configs are appropriate
- path: "**/*.csproj"
instructions: |
- Verify .NET 10 (net10.0) target framework
- Check that nullable reference types are enabled
- Ensure package versions are up to date
- Validate that ImplicitUsings is enabled
- DATABASE_PROVIDER selects the active DB engine at runtime: unset =
SQLite (default), DATABASE_PROVIDER=postgres = PostgreSQL. Do not
hardcode a provider. Both SQLite and PostgreSQL migration paths must
remain valid. See ADR-0014 in docs/adr/.
- path: ".github/workflows/**"
instructions: |
- Any CI job that runs migrations or integration tests must set
DATABASE_PROVIDER explicitly. Unset defaults to SQLite. See ADR-0014
in docs/adr/.
path_filters:
- "!**/bin/**"
- "!**/obj/**"
- "!**/logs/**"
- "!**/storage/**"
- "!**/*.db"
- "!**/*.db-shm"
- "!**/*.db-wal"
- "!**/packages.lock.json"
- "!**/Migrations/**"
- "!**/*.Designer.cs"
- "!**/ModelSnapshot.cs"
auto_review:
enabled: true
auto_incremental_review: true
ignore_title_keywords:
- "WIP"
- "DO NOT REVIEW"
- "wip"
drafts: false
base_branches:
- master
- main
finishing_touches:
docstrings:
enabled: true
unit_tests:
enabled: true
custom:
- name: "sync documentation"
instructions: |
This is a PoC/learning project targeting developers unfamiliar with the stack.
Documentation is a first-class concern. Review the PR changes and perform the
following three checks:
## 1. Method/function docstrings
For every public function, method, or handler touched in the PR:
- If it lacks a docstring/doc comment, add one using the idiomatic format
for the language and framework in use.
- If it has one but no longer matches the current signature, parameters,
or behavior, update it.
- Docstrings should explain *why* and *what*, not just restate the signature.
Assume the reader is learning the language.
## 2. README.md
Check whether the PR introduces or removes endpoints, changes behavior,
adds dependencies, or modifies how to run the project.
If so, update the relevant sections of README.md to reflect the current state.
Do not rewrite sections unrelated to the changes.
## 3. CLAUDE.md
If the PR introduces patterns, conventions, or architectural decisions that
should guide future AI-assisted contributions, add or update the relevant
instructions in CLAUDE.md.
Focus on things a developer (or AI assistant) unfamiliar with this specific
stack implementation should know before writing code here.
- name: "enforce http error handling"
instructions: |
Audit all HTTP handler functions in the changed files.
Ensure errors return appropriate HTTP status codes (400 for bad input,
404 for not found, 500 for unexpected errors) and a consistent JSON error
body with at least a "message" field.
Flag handlers that return 200 on error or swallow errors silently.
Use idiomatic error handling patterns for the language and framework in use.
- name: "idiomatic review"
instructions: |
Review the changed files for non-idiomatic patterns given the language and
framework in use. Flag code that looks like it was translated from another
language rather than written naturally for this stack. Suggest idiomatic
alternatives with brief explanations. This is a PoC comparison project,
so idiomatic usage is a first-class concern.
- name: "verify api contract"
instructions: |
Review the changed files and verify that all HTTP endpoints (method, path,
request body shape, and response shape) match the project's intended REST API
contract. Check the README or any spec/contract file in the repo for reference.
Flag any deviations — missing fields, wrong status codes, inconsistent naming.
Do not make changes; only report findings as a comment.
pre_merge_checks:
docstrings:
mode: warning
threshold: 80
title:
mode: warning
requirements: |
- Use Conventional Commits format (feat:, fix:, chore:, docs:, test:, refactor:)
- Keep under 80 characters
- Be descriptive and specific
description:
mode: off
issue_assessment:
mode: off
tools:
# Secret scanners
gitleaks:
enabled: true
trufflehog:
enabled: true
# IaC / infrastructure
checkov:
enabled: true
trivy:
enabled: true
hadolint:
enabled: true
# General static analysis
semgrep:
enabled: true
opengrep:
enabled: true
# File-type linters
yamllint:
enabled: true
actionlint:
enabled: true
markdownlint:
enabled: true
dotenvLint:
enabled: true
checkmake:
enabled: true
osvScanner:
enabled: true
github-checks:
enabled: true
timeout_ms: 120000
# Disable irrelevant tools for this .NET project
shellcheck:
enabled: false
ruff:
enabled: false
biome:
enabled: false
swiftlint:
enabled: false
phpstan:
enabled: false
phpmd:
enabled: false
phpcs:
enabled: false
golangci-lint:
enabled: false
detekt:
enabled: false
eslint:
enabled: false
flake8:
enabled: false
pylint:
enabled: false
rubocop:
enabled: false
buf:
enabled: false
regal:
enabled: false
pmd:
enabled: false
clang:
enabled: false
cppcheck:
enabled: false
clippy:
enabled: false
sqlfluff:
enabled: false
prismaLint:
enabled: false
oxc:
enabled: false
shopifyThemeCheck:
enabled: false
luacheck:
enabled: false
brakeman:
enabled: false
htmlhint:
enabled: false
languagetool:
enabled: false
circleci:
enabled: false
fortitudeLint:
enabled: false
stylelint:
enabled: false
blinter:
enabled: false
psscriptanalyzer:
enabled: false
chat:
art: true
auto_reply: true
knowledge_base:
opt_out: false
web_search:
enabled: true
code_guidelines:
enabled: true
filePatterns:
- "CLAUDE.md"
- "docs/adr/README.md"
learnings:
scope: auto
issues:
scope: auto
pull_requests:
scope: auto
mcp:
usage: auto
code_generation:
docstrings:
language: en-US
path_instructions:
- path: "**/*.cs"
instructions: |
- Use XML documentation comments (///) for public APIs
- Include <summary>, <param>, and <returns> tags
- Keep documentation concise and meaningful
- Don't state the obvious - add value
unit_tests:
path_instructions:
- path: "test/**/*.cs"
instructions: |
- Use xUnit framework with [Fact] and [Theory] attributes
- Follow the two-pattern naming convention (exactly 3 underscore-delimited segments):
- Controller tests: {HttpMethod}_{Resource}_{Condition}_Returns{Outcome}
- Service/Validator tests: {MethodName}_{StateUnderTest}_{ExpectedBehavior}
- Use [Trait("Category", "Unit")] attribute for all unit tests
- Use Moq for mocking dependencies
- Use FluentAssertions for readable assertions
- Ensure async tests return Task
- Structure tests with Arrange, Act, Assert comments
issue_enrichment:
auto_enrich:
enabled: true
planning:
enabled: true
auto_planning:
enabled: true
labels:
- planning
labeling:
labeling_instructions: []
auto_apply_labels: false
+339
View File
@@ -0,0 +1,339 @@
# https://github.com/dotnet/runtime/blob/main/.dockerignore
### VisualStudio ###
# Tool Runtime Dir
**/.dotnet/
**/.packages/
**/.tools/
# User-specific files
**/*.suo
**/*.user
**/*.userosscache
**/*.sln.docstates
# Build results
**/artifacts/
**/.idea/
**/[Dd]ebug/
**/[Dd]ebugPublic/
**/[Rr]elease/
**/[Rr]eleases/
**/bld/
**/[Bb]in/
**/[Oo]bj/
**/msbuild.log
**/msbuild.err
**/msbuild.wrn
**/msbuild.binlog
**/.deps/
**/.dirstamp
**/.libs/
**/*.lo
**/*.o
# Cross building rootfs
**/cross/rootfs/
**/cross/android-rootfs/
# Visual Studio
**/.vs/
# Ionide
**/.ionide/
# 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
**/*_i.c
**/*_p.c
**/*.ilk
**/*.meta
**/*.obj
**/*.pch
**/*.pdb
!**/_.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
# Visual Studio profiler
**/*.psess
**/*.vsp
**/*.vspx
# 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 addin-in
**/.JustCode
# TeamCity is a build add-in
**/_TeamCity*
# DotCover is a Code Coverage Tool
**/*.dotCover
# NCrunch
**/_NCrunch_*
**/.*crunch*.local.xml
# 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
# Publish Web Output
**/*.[Pp]ublish.xml
**/*.azurePubxml
**/*.pubxml
**/*.publishproj
# NuGet Packages
**/*.nupkg
**/*.nuget.g.props
**/*.nuget.g.targets
**/*.nuget.cache
**/**/packages/*
**/project.lock.json
**/project.assets.json
**/*.nuget.dgspec.json
# Windows Azure Build Output
**/csx/
**/*.build.csdef
# Windows Store app package directory
**/AppPackages/
# Others
**/*.Cache
**/ClientBin/
**/[Ss]tyle[Cc]op.*
**/~$*
**/*.dbmdl
**/*.dbproj.schemaview
**/*.pfx
**/*.publishsettings
**/node_modules/
**/*.metaproj
**/*.metaproj.tmp
**/bin.localpkg/
# 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/
# C/C++ extension for Visual Studio Code
**/browse.VC.db
# Local settings folder for Visual Studio Code
**/**/.vscode/**
!**/**/.vscode/c_cpp_properties.json
### MonoDevelop ###
**/*.pidb
**/*.userprefs
### Windows ###
# Windows image file caches
**/Thumbs.db
**/ehthumbs.db
# Folder config file
**/Desktop.ini
# Recycle Bin used on file shares
**/$RECYCLE.BIN/
# Windows Installer files
**/*.cab
**/*.msi
**/*.msm
**/*.msp
# Windows shortcuts
**/*.lnk
### Linux ###
**/*~
# KDE directory preferences
**/.directory
### OSX ###
**/.DS_Store
**/.AppleDouble
**/.LSOverride
# Icon must end with two \r
**/Icon
# Thumbnails
**/._*
# Files that might appear on external disk
**/.Spotlight-V100
**/.Trashes
# Directories potentially created on remote AFP share
**/.AppleDB
**/.AppleDesktop
**/Network Trash Folder
**/Temporary Items
**/.apdisk
# vim temporary files
**/[._]*.s[a-w][a-z]
**/[._]s[a-w][a-z]
**/*.un~
**/Session.vim
**/.netrwhist
**/*~
# Visual Studio Code
**/.vscode/
# Private test configuration and binaries.
**/config.ps1
**/**/IISApplications
# VS debug support files
**/launchSettings.json
# Snapcraft files
**/.snapcraft
**/*.snap
**/parts/
**/prime/
**/stage/
# CLR prebuilt generated files
!**/src/pal/prebuilt/idl/*_i.c
# Valid 'debug' folder, that contains CLR debugging code
!**/src/**/debug
# Ignore folders created by the CLR test build
**/TestWrappers_x64_[d|D]ebug
**/TestWrappers_x64_[c|C]hecked
**/TestWrappers_x64_[r|R]elease
**/TestWrappers_x86_[d|D]ebug
**/TestWrappers_x86_[c|C]hecked
**/TestWrappers_x86_[r|R]elease
**/TestWrappers_arm_[d|D]ebug
**/TestWrappers_arm_[c|C]hecked
**/TestWrappers_arm_[r|R]elease
**/TestWrappers_arm64_[d|D]ebug
**/TestWrappers_arm64_[c|C]hecked
**/TestWrappers_arm64_[r|R]elease
**/tests/src/common/test_runtime/project.json
**/Vagrantfile
**/.vagrant
# CMake files
**/CMakeFiles/
**/cmake_install.cmake
**/CMakeCache.txt
**/Makefile
# Cross compilation
**/cross/rootfs/*
**/cross/android-rootfs/*
# add x86 as it is ignored in 'Build results'
!**/cross/x86
#python import files
**/*.pyc
# JIT32 files
**/src/jit32
# performance testing sandbox
**/sandbox
# Allow the SQLite database to be included
!src/Dotnet.Samples.AspNetCore.WebApi/Data/players-sqlite3.db
+9
View File
@@ -0,0 +1,9 @@
# Database provider: sqlite (default, no Docker dependency) | postgres (opt-in, requires Docker)
DATABASE_PROVIDER=sqlite
# Required only when DATABASE_PROVIDER=postgres
# For Docker Compose, "postgres" resolves to the postgres service container
DATABASE_URL=Host=postgres;Database=players;Username=postgres;Password=P0579r35_p455W0rd!
# Required only when DATABASE_PROVIDER=postgres (used by the postgres service in compose.yaml)
POSTGRES_PASSWORD=P0579r35_p455W0rd!
+7
View File
@@ -0,0 +1,7 @@
logs/
bin/
obj/
TestResults/
storage/*.db
.env
.claude/settings.local.json
+6
View File
@@ -0,0 +1,6 @@
{
"MD013": false,
"MD024": {
"siblings_only": true
}
}
+22
View File
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<RunSettings>
<DataCollectionRunSettings>
<DataCollectors>
<DataCollector friendlyName="XPlat Code Coverage">
<Configuration>
<Format>cobertura</Format>
<SkipAutoProps>true</SkipAutoProps>
<BasePath>$(MSBuildProjectDirectory)</BasePath>
<CodeCoverage>
<ModulePaths>
<!-- Exclude all test projects -->
<Exclude>
<ModulePath>.*\.Tests\.dll$</ModulePath>
</Exclude>
</ModulePaths>
</CodeCoverage>
</Configuration>
</DataCollector>
</DataCollectors>
</DataCollectionRunSettings>
</RunSettings>
+108
View File
@@ -0,0 +1,108 @@
# =============================================================================
# SonarCloud Automatic Analysis configuration
# https://docs.sonarsource.com/sonarqube-cloud/advanced-setup/automatic-analysis/
#
# SonarCloud's automatic analysis (GitHub App, no CI workflow) reads THIS file.
# Wildcard patterns are NOT supported here, so all exclusion paths must be
# listed explicitly.
# =============================================================================
# NOTE: sonar.sources and sonar.tests are NOT supported by the Scanner for .NET
# and are silently ignored. The scanner auto-discovers sources and test projects
# from .csproj files. Do not add them back.
sonar.sourceEncoding=UTF-8
# =============================================================================
# Global exclusions
# =============================================================================
sonar.exclusions=\
src/Dotnet.Samples.AspNetCore.WebApi/Migrations/20260409141647_InitialCreate.cs,\
src/Dotnet.Samples.AspNetCore.WebApi/Migrations/20260409141647_InitialCreate.Designer.cs,\
src/Dotnet.Samples.AspNetCore.WebApi/Migrations/20260409141707_SeedStarting11.cs,\
src/Dotnet.Samples.AspNetCore.WebApi/Migrations/20260409141707_SeedStarting11.Designer.cs,\
src/Dotnet.Samples.AspNetCore.WebApi/Migrations/20260409141721_SeedSubstitutes.cs,\
src/Dotnet.Samples.AspNetCore.WebApi/Migrations/20260409141721_SeedSubstitutes.Designer.cs,\
src/Dotnet.Samples.AspNetCore.WebApi/Migrations/PlayerDbContextModelSnapshot.cs,\
src/Dotnet.Samples.AspNetCore.WebApi/Migrations/Npgsql/20260409151000_InitialCreate.cs,\
src/Dotnet.Samples.AspNetCore.WebApi/Migrations/Npgsql/20260409151000_InitialCreate.Designer.cs,\
src/Dotnet.Samples.AspNetCore.WebApi/Migrations/Npgsql/20260409151100_SeedStarting11.cs,\
src/Dotnet.Samples.AspNetCore.WebApi/Migrations/Npgsql/20260409151100_SeedStarting11.Designer.cs,\
src/Dotnet.Samples.AspNetCore.WebApi/Migrations/Npgsql/20260409151200_SeedSubstitutes.cs,\
src/Dotnet.Samples.AspNetCore.WebApi/Migrations/Npgsql/20260409151200_SeedSubstitutes.Designer.cs
# =============================================================================
# Coverage exclusions
# Test files and generated/infrastructure code should not count against
# production code coverage metrics.
# =============================================================================
sonar.coverage.exclusions=\
test/Dotnet.Samples.AspNetCore.WebApi.Tests/Unit/PlayerControllerTests.cs,\
test/Dotnet.Samples.AspNetCore.WebApi.Tests/Unit/PlayerServiceTests.cs,\
test/Dotnet.Samples.AspNetCore.WebApi.Tests/Unit/PlayerValidatorTests.cs,\
test/Dotnet.Samples.AspNetCore.WebApi.Tests/Utilities/DatabaseFakes.cs,\
test/Dotnet.Samples.AspNetCore.WebApi.Tests/Utilities/PlayerFakes.cs,\
test/Dotnet.Samples.AspNetCore.WebApi.Tests/Utilities/PlayerMocks.cs,\
test/Dotnet.Samples.AspNetCore.WebApi.Tests/Utilities/PlayerStubs.cs,\
test/Dotnet.Samples.AspNetCore.WebApi.Tests/Usings.cs,\
src/Dotnet.Samples.AspNetCore.WebApi/Migrations/20260409141647_InitialCreate.cs,\
src/Dotnet.Samples.AspNetCore.WebApi/Migrations/20260409141647_InitialCreate.Designer.cs,\
src/Dotnet.Samples.AspNetCore.WebApi/Migrations/20260409141707_SeedStarting11.cs,\
src/Dotnet.Samples.AspNetCore.WebApi/Migrations/20260409141707_SeedStarting11.Designer.cs,\
src/Dotnet.Samples.AspNetCore.WebApi/Migrations/20260409141721_SeedSubstitutes.cs,\
src/Dotnet.Samples.AspNetCore.WebApi/Migrations/20260409141721_SeedSubstitutes.Designer.cs,\
src/Dotnet.Samples.AspNetCore.WebApi/Migrations/PlayerDbContextModelSnapshot.cs,\
src/Dotnet.Samples.AspNetCore.WebApi/Migrations/Npgsql/20260409151000_InitialCreate.cs,\
src/Dotnet.Samples.AspNetCore.WebApi/Migrations/Npgsql/20260409151000_InitialCreate.Designer.cs,\
src/Dotnet.Samples.AspNetCore.WebApi/Migrations/Npgsql/20260409151100_SeedStarting11.cs,\
src/Dotnet.Samples.AspNetCore.WebApi/Migrations/Npgsql/20260409151100_SeedStarting11.Designer.cs,\
src/Dotnet.Samples.AspNetCore.WebApi/Migrations/Npgsql/20260409151200_SeedSubstitutes.cs,\
src/Dotnet.Samples.AspNetCore.WebApi/Migrations/Npgsql/20260409151200_SeedSubstitutes.Designer.cs,\
src/Dotnet.Samples.AspNetCore.WebApi/Data/PlayerDbContext.cs,\
src/Dotnet.Samples.AspNetCore.WebApi/Program.cs
# =============================================================================
# Duplicate code (CPD) exclusions
# Automatic analysis does not support wildcard patterns, so each file is
# listed explicitly.
#
# Migrations — EF Core migration files are intentionally repetitive
# (sequential InsertData calls generated by EF Core tooling).
#
# PlayerDbContext.cs — scaffolded EF Core infrastructure.
#
# PlayerData.cs — parallel WithId/without-Id method pairs mirror each other
# by design; duplication is structural, not accidental.
#
# PlayerRequestModelValidator.cs — the "Create" and "Update" rule sets share
# common rules by design; duplication is intentional.
#
# Test files — Fakes, Mocks, and Stubs are intentionally repetitive by design.
# =============================================================================
sonar.cpd.exclusions=\
src/Dotnet.Samples.AspNetCore.WebApi/Migrations/20260409141647_InitialCreate.cs,\
src/Dotnet.Samples.AspNetCore.WebApi/Migrations/20260409141647_InitialCreate.Designer.cs,\
src/Dotnet.Samples.AspNetCore.WebApi/Migrations/20260409141707_SeedStarting11.cs,\
src/Dotnet.Samples.AspNetCore.WebApi/Migrations/20260409141707_SeedStarting11.Designer.cs,\
src/Dotnet.Samples.AspNetCore.WebApi/Migrations/20260409141721_SeedSubstitutes.cs,\
src/Dotnet.Samples.AspNetCore.WebApi/Migrations/20260409141721_SeedSubstitutes.Designer.cs,\
src/Dotnet.Samples.AspNetCore.WebApi/Migrations/PlayerDbContextModelSnapshot.cs,\
src/Dotnet.Samples.AspNetCore.WebApi/Migrations/Npgsql/20260409151000_InitialCreate.cs,\
src/Dotnet.Samples.AspNetCore.WebApi/Migrations/Npgsql/20260409151000_InitialCreate.Designer.cs,\
src/Dotnet.Samples.AspNetCore.WebApi/Migrations/Npgsql/20260409151100_SeedStarting11.cs,\
src/Dotnet.Samples.AspNetCore.WebApi/Migrations/Npgsql/20260409151100_SeedStarting11.Designer.cs,\
src/Dotnet.Samples.AspNetCore.WebApi/Migrations/Npgsql/20260409151200_SeedSubstitutes.cs,\
src/Dotnet.Samples.AspNetCore.WebApi/Migrations/Npgsql/20260409151200_SeedSubstitutes.Designer.cs,\
src/Dotnet.Samples.AspNetCore.WebApi/Data/PlayerDbContext.cs,\
src/Dotnet.Samples.AspNetCore.WebApi/Utilities/PlayerData.cs,\
src/Dotnet.Samples.AspNetCore.WebApi/Validators/PlayerRequestModelValidator.cs,\
test/Dotnet.Samples.AspNetCore.WebApi.Tests/Unit/PlayerControllerTests.cs,\
test/Dotnet.Samples.AspNetCore.WebApi.Tests/Unit/PlayerServiceTests.cs,\
test/Dotnet.Samples.AspNetCore.WebApi.Tests/Unit/PlayerValidatorTests.cs,\
test/Dotnet.Samples.AspNetCore.WebApi.Tests/Utilities/DatabaseFakes.cs,\
test/Dotnet.Samples.AspNetCore.WebApi.Tests/Utilities/PlayerFakes.cs,\
test/Dotnet.Samples.AspNetCore.WebApi.Tests/Utilities/PlayerMocks.cs,\
test/Dotnet.Samples.AspNetCore.WebApi.Tests/Utilities/PlayerStubs.cs,\
test/Dotnet.Samples.AspNetCore.WebApi.Tests/Usings.cs
Binary file not shown.
@@ -0,0 +1,54 @@
{
"Version": 1,
"WorkspaceRootPath": "R:\\source\\repos\\Dotnet.Samples.AspNetCore.WebApi\\",
"Documents": [
{
"AbsoluteMoniker": "D:0:0:{1ED8A3DB-5092-4D79-ABA2-403797A6898C}|src\\Dotnet.Samples.AspNetCore.WebApi\\Dotnet.Samples.AspNetCore.WebApi.csproj|r:\\source\\repos\\dotnet.samples.aspnetcore.webapi\\src\\dotnet.samples.aspnetcore.webapi\\program.cs||{A6C744A8-0E4A-4FC6-886A-064283054674}",
"RelativeMoniker": "D:0:0:{1ED8A3DB-5092-4D79-ABA2-403797A6898C}|src\\Dotnet.Samples.AspNetCore.WebApi\\Dotnet.Samples.AspNetCore.WebApi.csproj|solutionrelative:src\\dotnet.samples.aspnetcore.webapi\\program.cs||{A6C744A8-0E4A-4FC6-886A-064283054674}"
},
{
"AbsoluteMoniker": "D:0:0:{1ED8A3DB-5092-4D79-ABA2-403797A6898C}|src\\Dotnet.Samples.AspNetCore.WebApi\\Dotnet.Samples.AspNetCore.WebApi.csproj|r:\\source\\repos\\dotnet.samples.aspnetcore.webapi\\src\\dotnet.samples.aspnetcore.webapi\\middlewares\\exceptionmiddleware.cs||{A6C744A8-0E4A-4FC6-886A-064283054674}",
"RelativeMoniker": "D:0:0:{1ED8A3DB-5092-4D79-ABA2-403797A6898C}|src\\Dotnet.Samples.AspNetCore.WebApi\\Dotnet.Samples.AspNetCore.WebApi.csproj|solutionrelative:src\\dotnet.samples.aspnetcore.webapi\\middlewares\\exceptionmiddleware.cs||{A6C744A8-0E4A-4FC6-886A-064283054674}"
}
],
"DocumentGroupContainers": [
{
"Orientation": 0,
"VerticalTabListWidth": 256,
"DocumentGroups": [
{
"DockedWidth": 200,
"SelectedChildIndex": 0,
"Children": [
{
"$type": "Document",
"DocumentIndex": 0,
"Title": "Program.cs",
"DocumentMoniker": "R:\\source\\repos\\Dotnet.Samples.AspNetCore.WebApi\\src\\Dotnet.Samples.AspNetCore.WebApi\\Program.cs",
"RelativeDocumentMoniker": "src\\Dotnet.Samples.AspNetCore.WebApi\\Program.cs",
"ToolTip": "R:\\source\\repos\\Dotnet.Samples.AspNetCore.WebApi\\src\\Dotnet.Samples.AspNetCore.WebApi\\Program.cs",
"RelativeToolTip": "src\\Dotnet.Samples.AspNetCore.WebApi\\Program.cs",
"ViewState": "AgIAAHUAAAAAAAAAAAAAABIAAAAHAAAAAAAAAA==",
"Icon": "ae27a6b0-e345-4288-96df-5eaf394ee369.000738|",
"WhenOpened": "2026-06-29T04:56:38.278Z",
"EditorCaption": ""
},
{
"$type": "Document",
"DocumentIndex": 1,
"Title": "ExceptionMiddleware.cs",
"DocumentMoniker": "R:\\source\\repos\\Dotnet.Samples.AspNetCore.WebApi\\src\\Dotnet.Samples.AspNetCore.WebApi\\Middlewares\\ExceptionMiddleware.cs",
"RelativeDocumentMoniker": "src\\Dotnet.Samples.AspNetCore.WebApi\\Middlewares\\ExceptionMiddleware.cs",
"ToolTip": "R:\\source\\repos\\Dotnet.Samples.AspNetCore.WebApi\\src\\Dotnet.Samples.AspNetCore.WebApi\\Middlewares\\ExceptionMiddleware.cs",
"RelativeToolTip": "src\\Dotnet.Samples.AspNetCore.WebApi\\Middlewares\\ExceptionMiddleware.cs",
"ViewState": "AgIAAE4AAAAAAAAAAAAYwAAAAAAAAAAAAAAAAA==",
"Icon": "ae27a6b0-e345-4288-96df-5eaf394ee369.000738|",
"WhenOpened": "2026-06-29T04:55:48.913Z",
"EditorCaption": ""
}
]
}
]
}
]
}
+257
View File
@@ -0,0 +1,257 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## Stadium Release Names
This project uses famous football stadiums (A-Z) that hosted FIFA World Cup matches (with notable fallbacks where necessary):
| Letter | Stadium Name | Location | Tag Name |
| ------ | ----------- | -------- | -------- |
| A | Azteca | Mexico (1970, 1986, 2026) | `azteca` |
| B | Bernabéu | Spain (1982) | `bernabeu` |
| C | Centenario | Uruguay (1930) | `centenario` |
| D | Düsseldorf (Merkur Spiel-Arena) | Germany (2006) | `dusseldorf` |
| E | Ekaterinburg Arena | Russia (2018) | `ekaterinburg` |
| F | Frankfurt Waldstadion | Germany (1974, 2006) | `frankfurt` |
| G | Gelsenkirchen | Germany (2006) | `gelsenkirchen` |
| H | Hard Rock Stadium | USA (2026) | `hardrock` |
| I | Ibn Batouta Stadium | Morocco (2030) | `ibnbatouta` |
| J | Johannesburg Soccer City | South Africa (2010) | `johannesburg` |
| K | Kazan Arena | Russia (2018) | `kazan` |
| L | Lusail | Qatar (2022) | `lusail` |
| M | Maracanã | Brazil (1950, 2014) | `maracana` |
| N | Nantes Beaujoire | France (1998) | `nantes` |
| O | Olympiastadion Berlin | Germany (1974, 2006) | `olympiastadion` |
| P | Parc des Princes | France (1938, 1998) | `parcdesprinces` |
| Q | Qatar 974 | Qatar (2022) | `qatar974` |
| R | Rose Bowl | USA (1994) | `rosebowl` |
| S | San Siro | Italy (1934, 1990) | `sansiro` |
| T | Toronto BMO Field | Canada (2026) | `toronto` |
| U | Ullevi | Sweden (1958) | `ullevi` |
| V | Volgograd Arena | Russia (2018) | `volgograd` |
| W | Wembley | England (1966) | `wembley` |
| X | Xiamen Egret Stadium | (famous fallback) | `xiamen` |
| Y | Yokohama International Stadium | Japan (2002) | `yokohama` |
| Z | Zentralstadion Leipzig | Germany (1974, 2006) | `zentralstadion` |
---
## [Unreleased]
### Added
- `CLAUDE.md` as the single source of truth for project guidance and AI-agent instructions (issue #505); consolidates content previously split between `CLAUDE.md` and `.github/copilot-instructions.md`.
- `Invariants` section to `CLAUDE.md` documenting port, API contract, commit format, and CHANGELOG update requirements.
- `Architecture Decision Records` section to `CLAUDE.md` referencing `docs/adr/` with guidance on when to load and when to create new ADRs.
- Pre-commit step 6 to `CLAUDE.md`: update CLAUDE.md and create/amend the relevant ADR when a commit introduces or changes an architectural decision.
- `docs/adr/` path instruction to `.coderabbit.yaml` covering `**/*.csproj` (DATABASE_PROVIDER runtime selection) and `.github/workflows/**` (explicit provider requirement in CI jobs that run migrations or integration tests).
- `DATABASE_PROVIDER` environment variable (`sqlite` default, `postgres` opt-in) to select the database engine at startup (issue #249).
- PostgreSQL 17 support via `Npgsql.EntityFrameworkCore.PostgreSQL` 10.0.1; migrations in `Migrations/Npgsql/` use proper PostgreSQL column types (`uuid`, `boolean`, `timestamp with time zone`).
- `ProviderSpecificMigrationsAssembly` that filters the EF Core migration set to the active provider's namespace, ensuring `MigrateAsync()` applies the correct migrations for both SQLite and PostgreSQL.
- `postgres` Docker Compose profile and service (`postgres:17-alpine`), started only when `DATABASE_PROVIDER=postgres` is set; the API service uses `depends_on` with `required: false` so SQLite mode incurs no dependency on the postgres service.
- `.env.example` documenting `DATABASE_PROVIDER`, `DATABASE_URL`, and `POSTGRES_PASSWORD`.
- `.env` added to `.gitignore`.
- ADR-0014 (`docs/adr/0014-configurable-database-provider.md`) documenting the decision; supersedes ADR-0003.
- ADR-0015: Use Full-Replace PUT as the Partial Update Strategy
- ADR-0016: Adopt AI-Assisted Development Workflow
- ADR-0017: Adopt Spec-Driven Development (SDD)
### Changed
- Improved `CLAUDE.md` project guidance via the [CLAUDE.md Management plugin](https://claude.com/plugins/claude-md-management): added `Migrations/`, `Utilities/` (src) and `Integration/` (test) to the structure tree; documented `DATABASE_URL` Npgsql format, `STORAGE_PATH` SQLite path override, and exact `dotnet ef migrations add` commands for both providers; collapsed issue template bullets for conciseness; clarified `MigrateAsync()` runs automatically at startup.
- `adr/` directory moved to `docs/adr/`; all references in `README.md`, `CONTRIBUTING.md`, and `.coderabbit.yaml` updated to the new path (issue #505).
- `.coderabbit.yaml` controller path instruction updated: status code list corrected from `(200, 201, 400, 404, 409, 500)` to `(200, 201, 404, 409, 500)` with explicit note that FluentValidation failures must return 422 via `TypedResults.Problem(new HttpValidationProblemDetails(...))` (issue #505).
- `.coderabbit.yaml` knowledge base guidelines updated from `.github/copilot-instructions.md` to `CLAUDE.md` and `docs/adr/README.md` (issue #505).
- `.coderabbit.yaml` caching description in Tech Stack corrected to reflect both 10-minute sliding and 1-hour absolute expiration (issue #505).
- `AddDbContextPoolWithSqlite` renamed to `AddDbContextPool` in `ServiceCollectionExtensions`; now reads `DATABASE_PROVIDER` and wires either `UseSqlite` or `UseNpgsql` accordingly.
- `compose.yaml`: `api` service receives `DATABASE_PROVIDER` and `DATABASE_URL` environment variables; `postgres-data` named volume added.
- `scripts/entrypoint.sh`: SQLite file-presence check is skipped when `DATABASE_PROVIDER=postgres`.
- ADR-0003 status updated to "Superseded by ADR-0014".
- `README.md`: added Database section documenting SQLite and PostgreSQL modes.
### Fixed
- Populate `BuildTargetModel` in Npgsql seed migration designer files so Npgsql's SQL generator can resolve column types when applying `InsertData` operations.
- Suppress `PendingModelChangesWarning` for the postgres provider path — hand-crafted designer files cannot replicate Npgsql-injected runtime annotations (`Relational:MaxIdentifierLength`, `UseIdentityByDefaultColumn`), causing a false-positive that aborted `MigrateAsync()` at startup.
- Normalize `DATABASE_PROVIDER` to lowercase in `entrypoint.sh` via `tr` so `POSTGRES`, `Postgres`, etc. are handled consistently with `AddDbContextPool`.
- Trim and normalize `DATABASE_PROVIDER` in `AddDbContextPool` before the provider switch; add explicit `sqlite`/empty case; throw `InvalidOperationException` for unrecognized values so typos no longer silently fall through to SQLite.
- Move `Npgsql.EntityFrameworkCore.PostgreSQL` package from the "Development dependencies" `ItemGroup` to "Runtime dependencies".
### Removed
---
## [2.1.2 - Frankfurt] - 2026-04-26
### Changed
- Field validation failures now return `422 Unprocessable Entity` (RFC 4918) instead of `400 Bad Request`; `400 Bad Request` is now reserved for malformed requests (unparseable JSON, route/body mismatch); unsupported media types return `415 Unsupported Media Type` (RFC 9110 §15.5.16) via the `[Consumes]` attribute. Error responses follow the Problem Details format (RFC 9457).
### Removed
- Remove `## How to Release` section from `CHANGELOG.md`; capitalize release codenames in `##` version headers and reference links (#468)
---
## [2.1.1 - Ekaterinburg] - 2026-04-12
### Added
- Extract `test` job from `release` in CD pipeline so tests run in isolation
before any publish step; add `linux/arm64` to build platforms; add
`id-token: write` and `attestations: write` permissions to `release`; set
`provenance: mode=max` and attest the image digest with
`actions/attest-build-provenance@v4.1.0` (`push-to-registry: true`); add
`--no-merges` to the changelog `git log` command; add empty changelog guard;
normalize first-release message to `"No changes (first release)"` (#465)
- Add `adr/0013-testing-strategy.md` documenting the decision to implement the full test pyramid as a deliberate educational choice (#421)
- Add `test/.../Integration/PlayerWebApplicationTests.cs` with 14 HTTP-layer integration tests covering all player endpoints and `/health` via `WebApplicationFactory<Program>` backed by in-memory SQLite; includes `Utilities/TestAuthHandler.cs` to bypass `[Authorize]` on `GET /players/{id:Guid}`; expose `Program` to the test project via `public partial class Program {}` in `Program.cs`; add `Microsoft.AspNetCore.Mvc.Testing` to the test project (#421)
- Add `test/.../Integration/PlayerRepositoryTests.cs` with 9 integration tests covering `Repository<T>` (`GetAllAsync`, `FindByIdAsync`, `RemoveAsync`) and `PlayerRepository` (`FindBySquadNumberAsync`, `SquadNumberExistsAsync`); all tests use `DatabaseFakes.MigrateAsync()` on in-memory SQLite and are tagged `[Trait("Category", "Integration")]` (#461)
- Add `ValidateAsync_SquadNumberNegative_ReturnsValidationError` test to exercise the `GreaterThan(0)` rule with a negative value, which passes `NotEmpty()` but fails the greater-than rule (#427)
- Add `ValidateAsync_FirstNameEmptyInUpdateRuleSet_ReturnsValidationError` test to verify the `"Update"` rule set enforces structural field validation (#427)
- Add `adr/` directory with 12 Architecture Decision Records documenting architectural choices, technology decisions, and design trade-offs (#372)
- Add ADR index and template at `adr/README.md` (#372)
- Add Architecture Decisions section to `README.md` referencing the ADR index (#372)
- Add ADR guidance section to `CONTRIBUTING.md` (#372)
- Add ADR context loading instructions to `.github/copilot-instructions.md` (#372)
### Changed
- Call `.DisableRateLimiting()` on `MapHealthChecks("/health")` to enforce the exemption from the global rate limiter at the endpoint level, ensuring health check probes are never throttled (#451)
- Move `UseCors()` before `MapControllers()` in `Program.cs` to follow the standard ASP.NET Core middleware pipeline order; add an `Infrastructure` service registration section separating cross-cutting concerns (health checks, CORS, rate limiting, Swagger) from the `Controllers` section; add descriptive phrases to top-level section banners; add inline comments explaining the purpose and ordering rationale of each middleware; document the dev-only CORS policy intent in both `Program.cs` and `ServiceCollectionExtensions.AddCorsDefaultPolicy` (#451)
- Replace pre-seeded `storage/players-sqlite3.db` binary blob with EF Core `MigrateAsync()` at startup: schema and seed data are now applied automatically before the first request is served; `STORAGE_PATH` env var controls the database file path (Docker volume path in production, `AppContext.BaseDirectory/storage/` locally); the committed database file, `Dockerfile` db copy step, and `scripts/run-migrations-and-copy-database.sh` have been removed (#459)
- Recreate EF Core migrations using `HasData()` in `OnModelCreating`: three self-contained migrations (`InitialCreate` DDL, `SeedStarting11` DML, `SeedSubstitutes` DML) generated by EF Core with literal `InsertData` values — no migration calls application methods; `NormalizePlayerDataset` patch migration eliminated by folding corrections into seed data from the start (#459)
- Replace `DatabaseFakes.CreateTable()` (placeholder schema) and `DatabaseFakes.Seed()` (manual insert bypassing migrations) with `DatabaseFakes.MigrateAsync()`, which applies the full EF Core migration chain on in-memory SQLite (#459)
- Switch runtime base image from `mcr.microsoft.com/dotnet/aspnet:10.0` (Debian)
to `mcr.microsoft.com/dotnet/aspnet:10.0-alpine` (before: 113.4 MB →
after: 73.9 MB compressed; measured via `docker manifest inspect` and
`docker save | wc -c`); replace `apt-get` with `apk` and
`useradd`/`groupadd` with `adduser`/`addgroup` accordingly (#456)
- Refactor `scripts/entrypoint.sh`: add `log()` helper with timestamp prefix,
replace raw `echo` calls, and print API base URL on startup (#456)
- Rename `ValidateAsync_SquadNumber_BelongsToPlayerBeingUpdated_ReturnsNoErrors` to `ValidateAsync_SquadNumberBelongsToPlayerBeingUpdated_ReturnsNoErrors` to align with the 3-segment naming convention for service/validator tests (#427)
- Make CSharpier step in `/pre-commit` conditional (skip with a note if not installed), consistent with the Docker and CodeRabbit steps (#427)
- Add "Verify tag commit is reachable from master" step to CD workflow using `git merge-base --is-ancestor` before any build or publish steps (#439)
- Rename five controller test methods to normalize data-state vocabulary: `NonExisting` → `Nonexistent` for the POST 201 scenario, `NonExisting` → `Unknown` for the four 404-by-lookup scenarios (#452)
- Add XML doc `<remarks>` block to `PlayerFakes` documenting the three-term data-state vocabulary (`existing`, `nonexistent`, `unknown`) (#452)
### Fixed
- `GET /players` now returns `200 OK` with an empty list `[]` when no players exist, instead of `404 Not Found` (#425)
- AutoMapper `Player → PlayerResponseModel` profile now explicitly ignores the `Id` source member via `ForSourceMember`, making the exclusion intentional rather than implicit (#425)
### Removed
---
## [2.1.0 - Dusseldorf] - 2026-03-29
### Added
- Add SonarCloud configuration via `.sonarcloud.properties` with explicit CPD exclusions for migrations, generated code, test files, and structurally repetitive production files (#426, #435)
- Add bug report issue template (`.github/ISSUE_TEMPLATE/bug_report.md`) (#426)
### Changed
- Normalize player dataset: add Lo Celso (squad 27), correct Fernández/Mac Allister/Messi team data, replace random UUIDs with deterministic UUID v5 values (#435)
- Align CRUD test fixtures: Lo Celso (squad 27) for Create and Delete, Messi (squad 10) for Retrieve, Damián Martínez (squad 23) for Update (#435)
- Bump `codecov/codecov-action` from 5.5.2 to 5.5.3 (#423)
### Fixed
- Scope `BeUniqueSquadNumber` validator to `"Create"` rule set to prevent false rejection of valid `PUT` requests (#424)
---
## [2.0.0 - Centenario] - 2026-03-18
### Changed
- Refactor error responses to use RFC 7807 Problem Details (#418)
- Add `/squadNumber/` path segment to PUT and DELETE routes — **breaking change** (#418)
- Extract `NotFoundTitle` constant and remove redundant null-conditional operator (#418)
- Rename test methods to follow Microsoft .NET naming standard (#396)
- Upgrade AutoMapper from 14.x to 16.1.1 (#414) — see Security section
- Bump FluentAssertions from 8.8.0 to 8.9.0 (#417)
- Bump coverlet.collector from 8.0.0 to 8.0.1 (#419)
- Bump softprops/action-gh-release from 2.6.0 to 2.6.1 (#416)
### Fixed
- Add squad number mismatch guard in `PutAsync` and update README (#418)
- Strengthen test assertions for Problem Details responses (#418)
- Fix broken 201 assertion in controller test for `Post_Players_NonExisting` (#396)
- Add missing edge case tests for `UpdateAsync`, `DeleteAsync`, and
`DateOfBirth` boundary validation (#396)
### Security
- Sanitize player data before logging to prevent log forging
- Upgrade AutoMapper from 14.x to 16.1.1 to resolve high-severity security vulnerability GHSA-rvv3-g6hj-g44x (#414)
---
## [1.1.0 - Bernabeu] - 2026-02-09
### Changed
- Upgrade to .NET 10 LTS from .NET 8 (#368)
- Update Microsoft.AspNetCore.OpenApi to 10.0.0
- Update Microsoft.EntityFrameworkCore.Sqlite to 10.0.0
- Update Microsoft.EntityFrameworkCore.Design to 10.0.0
- Update Microsoft.VisualStudio.Web.CodeGeneration.Design to 10.0.0
- Update Docker images to .NET 10 SDK and runtime (now based on Ubuntu 24.04 LTS instead of Debian 12)
- Update Dockerfile user creation commands for Ubuntu compatibility (`groupadd`/`useradd` instead of `adduser`)
- Update CI/CD pipelines to use .NET 10 SDK
- Token efficiency strategy for Copilot/AI agents with optimized instruction loading and improved token counting script (#364)
- Bump Swashbuckle.AspNetCore from 10.1.0 to 10.1.2
- Bump docker/login-action from 3.6.0 to 3.7.0
- Bump softprops/action-gh-release from 2.2.0 to 2.5.0
- Bump actions/checkout from 6.0.1 to 6.0.2
---
## [1.0.0 - Azteca] - 2026-01-22
Initial release. See [README.md](README.md) for complete feature list and documentation.
---
<!-- Template for new releases:
## [X.Y.Z - STADIUM_NAME] - YYYY-MM-DD
### Added
- New features
### Changed
- Changes in existing functionality
### Deprecated
- Soon-to-be removed features
### Removed
- Removed features
### Fixed
- Bug fixes
### Security
- Security vulnerability fixes
-->
---
[unreleased]: https://github.com/nanotaboada/Dotnet.Samples.AspNetCore.WebApi/compare/v2.1.2-frankfurt...HEAD
[2.1.2 - Frankfurt]: https://github.com/nanotaboada/Dotnet.Samples.AspNetCore.WebApi/compare/v2.1.1-ekaterinburg...v2.1.2-frankfurt
[2.1.1 - Ekaterinburg]: https://github.com/nanotaboada/Dotnet.Samples.AspNetCore.WebApi/compare/v2.1.0-dusseldorf...v2.1.1-ekaterinburg
[2.1.0 - Dusseldorf]: https://github.com/nanotaboada/Dotnet.Samples.AspNetCore.WebApi/compare/v2.0.0-centenario...v2.1.0-dusseldorf
[2.0.0 - Centenario]: https://github.com/nanotaboada/Dotnet.Samples.AspNetCore.WebApi/compare/v1.1.0-bernabeu...v2.0.0-centenario
[1.1.0 - Bernabeu]: https://github.com/nanotaboada/Dotnet.Samples.AspNetCore.WebApi/compare/v1.0.0-azteca...v1.1.0-bernabeu
[1.0.0 - Azteca]: https://github.com/nanotaboada/Dotnet.Samples.AspNetCore.WebApi/releases/tag/v1.0.0-azteca
+250
View File
@@ -0,0 +1,250 @@
# CLAUDE.md
## Overview
REST API for managing football players built with ASP.NET Core 10. Implements CRUD operations with a layered architecture, EF Core persistence (SQLite by default, PostgreSQL opt-in via `DATABASE_PROVIDER`), FluentValidation, AutoMapper, and in-memory caching. Primarily a learning and reference project — clarity and educational value take precedence over brevity.
## Tech Stack
| Category | Technology |
|-----------------|-----------------------------------------------------------|
| Language | C# (.NET 10 LTS) |
| Framework | ASP.NET Core (MVC controllers) |
| ORM | Entity Framework Core 10 |
| Database | SQLite (default) · PostgreSQL 17 (opt-in) |
| Mapping | AutoMapper |
| Validation | FluentValidation |
| Caching | `IMemoryCache` (10-min sliding + 1-hour absolute expiry) |
| Logging | Serilog (structured, console + file) |
| Testing | xUnit + Moq + FluentAssertions |
| Formatting | CSharpier |
| Containerization| Docker |
## Structure
```tree
src/Dotnet.Samples.AspNetCore.WebApi/
├── Controllers/ — HTTP handlers; minimal logic, delegate to services [HTTP layer]
├── Services/ — Business logic + IMemoryCache caching [business layer]
├── Repositories/ — Generic Repository<T> + specific implementations [data layer]
├── Models/ — Player entity + request/response DTOs
├── Validators/ — FluentValidation validators (one per request model)
├── Mappings/ — AutoMapper profiles (PlayerMappingProfile)
├── Enums/ — Position abbreviations and other domain enumerations
├── Extensions/ — IServiceCollection extension methods (service registration)
├── Configurations/ — Options classes bound from appsettings.json
├── Middlewares/ — Custom ASP.NET Core middleware
├── Data/ — DbContext; seed data via HasData() in OnModelCreating
├── Migrations/ — EF Core migrations; Npgsql/ subdirectory for PostgreSQL provider
├── Utilities/ — Internal helpers: HttpContext, Swagger, PlayerData seed source
└── Storage/ — SQLite database file (runtime-generated, gitignored)
test/Dotnet.Samples.AspNetCore.WebApi.Tests/
├── Integration/ — Repository and WebApplication integration tests
├── Unit/ — Unit tests (controllers, services, validators)
└── Utilities/ — Shared test helpers: PlayerFakes, PlayerMocks, PlayerStubs
```
**Layer rule**: `Controller → Service → Repository → Database`. Controllers must not access repositories directly. Business logic must not live in controllers.
**Cross-cutting**: `Program.cs` wires health checks (`GET /health`), rate limiting, CORS (dev only), and Swagger UI (dev only). Serilog is configured at host level. All validators are registered via `AddValidatorsFromAssemblyContaining<PlayerRequestModelValidator>()`.
## Coding Guidelines
- **Naming**: PascalCase (public members), camelCase (private fields with `_` prefix)
- **DI**: Primary constructors everywhere
- **Async**: All I/O operations use `async`/`await`; no `ConfigureAwait(false)` (unnecessary in ASP.NET Core)
- **Reads**: Use `AsNoTracking()` for all EF Core read queries
- **Errors**: RFC 7807 Problem Details via `TypedResults.Problem(new HttpValidationProblemDetails(...))` for validation failures (422) and `TypedResults.Problem(statusCode: ...)` for other errors
- **Logging**: Structured logging via `ILogger<T>`; never `Console.Write`
- **Avoid**: synchronous EF Core APIs, controller business logic, static service/repository classes
### Test naming conventions
Two naming patterns, strictly by layer:
| Layer | Location | Pattern | Example |
|----------------------|-----------------------|---------------------------------------------------------------|------------------------------------------------------------------|
| Controller (unit) | `test/.../Unit/` | `{HttpMethod}_{Resource}_{Condition}_Returns{Outcome}` | `Get_Players_Existing_ReturnsPlayers` |
| Service / Validator | `test/.../Unit/` | `{MethodName}_{StateUnderTest}_{ExpectedBehavior}` | `RetrieveAsync_CacheMiss_QueriesRepositoryAndCachesResult` |
| HTTP integration | `test/.../Integration/` | `{HttpMethod}_{Resource}_{Condition}_Returns{Outcome}` | `Get_Players_Existing_Returns200Ok` |
Each pattern has exactly three underscore-delimited segments where `{HttpMethod}_{Resource}` counts as the first segment. Do not add a fourth segment.
### FluentValidation rule sets
Validators use CRUD-named rule sets to make intent explicit. Use `RuleSet("Create", ...)` and `RuleSet("Update", ...)` — never anonymous / default rules.
```csharp
// "Create" rule set — POST /players
// Includes BeUniqueSquadNumber to prevent duplicate squad numbers on insert.
RuleSet("Create", () => {
RuleFor(p => p.SquadNumber)
.MustAsync(BeUniqueSquadNumber).WithMessage("SquadNumber must be unique.");
// ... other rules
});
// "Update" rule set — PUT /players/squadNumber/{n}
// BeUniqueSquadNumber intentionally omitted: the player already exists in DB.
RuleSet("Update", () => {
// ... same structural rules, no uniqueness check
});
```
Controllers must call the appropriate rule set explicitly:
```csharp
// POST
await validator.ValidateAsync(player, opts => opts.IncludeRuleSets("Create"));
// PUT
await validator.ValidateAsync(player, opts => opts.IncludeRuleSets("Update"));
```
### Mocking validators in controller tests
`ValidateAsync(T, Action<ValidationStrategy<T>>)` is a FluentValidation extension method. Internally it calls `ValidateAsync(IValidationContext, CancellationToken)`. Moq must target the **interface overload**, not the generic one:
```csharp
// ✅ Correct — matches the overload actually called at runtime
_validatorMock
.Setup(v => v.ValidateAsync(It.IsAny<IValidationContext>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ValidationResult());
// ❌ Wrong — targets a different overload; mock is never hit → NullReferenceException
_validatorMock
.Setup(v => v.ValidateAsync(It.IsAny<PlayerRequestModel>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ValidationResult());
```
Add `using FluentValidation;` to any test file that calls the rule set overload.
### Test utilities
`test/.../Utilities/` contains shared helpers used across all unit tests:
| Class | Purpose |
|-----------------|-------------------------------------------------------------------------|
| `PlayerFakes` | Deterministic in-memory objects: `MakeNew()`, `MakeRequestModelForCreate()`, `MakeRequestModelForUpdate(n)`, `MakeFromStarting11(n)` |
| `PlayerMocks` | Pre-configured `Mock<T>` setups for common scenarios |
| `PlayerStubs` | Simple stub implementations where Moq would be overkill |
Always prefer `PlayerFakes` factory methods over constructing test data inline.
## Commands
### Quick Start
```bash
dotnet restore
dotnet build
dotnet run --project src/Dotnet.Samples.AspNetCore.WebApi # https://localhost:9000
dotnet watch run --project src/Dotnet.Samples.AspNetCore.WebApi # hot reload
dotnet test --settings .runsettings # with coverage
docker compose up
```
### Pre-commit Checks
1. Update `CHANGELOG.md` `[Unreleased]` section (Added / Changed / Fixed / Removed)
2. `dotnet build --configuration Release` — must succeed
3. `dotnet test --settings .runsettings` — all tests must pass
4. `dotnet csharpier .` — format; fix any reported issues
5. Commit message follows Conventional Commits format (enforced by commitlint)
6. If this commit introduces or changes an architectural decision, update CLAUDE.md and create or amend the relevant ADR in `docs/adr/`.
### Commits
Format: `type(scope): description (#issue)` — max 80 chars
Types: `feat` `fix` `chore` `docs` `test` `refactor` `ci` `perf`
Example: `feat(api): add player search endpoint (#123)`
## Agent Mode
### Proceed freely
- Route handlers and controllers
- Service layer logic and caching
- Repository implementations
- Unit and integration tests
- Documentation and CHANGELOG updates
- Bug fixes and refactoring within existing patterns
### Ask before changing
- Database schema (entity fields, migrations)
- Dependencies (`*.csproj`, `global.json`)
- CI/CD configuration (`.github/workflows/`)
- Docker setup
- Application configuration (`appsettings.json`)
- API contracts (breaking DTO changes)
- Caching strategy or TTL values
- FluentValidation rule set structure (adding or removing rule sets affects controller callers and tests)
### Never modify
- Production configurations or deployment secrets
- `.runsettings` coverage thresholds
- Port configuration (9000)
- Migration namespace constants in `ProviderSpecificMigrationsAssembly` — renaming breaks runtime provider filtering for one or both providers
- CD pipeline tag format (`vX.Y.Z-stadium`) or the stadium name sequence — names are assigned sequentially A→Z from the list in `CHANGELOG.md`; the next name is always the next unused letter
### Creating Issues
This project uses Spec-Driven Development (SDD): discuss in Plan mode first, create a GitHub Issue as the spec artifact, then implement. Always offer to draft an issue before writing code.
**Feature request** (`enhancement` label): Problem · Proposed Solution · Suggested Approach (optional) · Acceptance Criteria · References
**Bug report** (`bug` label): Description · Steps to Reproduce · Expected/Actual Behavior · Environment · Additional Context · Possible Solution (optional)
### Key workflows
**Add an endpoint**: Add DTO in `Models/` → update `PlayerMappingProfile` in `Mappings/` → add repository method(s) in `Repositories/` → add service method in `Services/` → add controller action in `Controllers/` → add/update validator rule set in `Validators/` → add tests in `test/.../Unit/` → run pre-commit checks.
**Modify schema**: Update `Player` entity → update DTOs → update AutoMapper profile → update `HasData()` seed data in `OnModelCreating` if needed → add migrations for both providers → update tests → run `dotnet test`.
```bash
# SQLite migration (default)
dotnet ef migrations add <Name> --project src/Dotnet.Samples.AspNetCore.WebApi
# PostgreSQL migration
DATABASE_PROVIDER=postgres DATABASE_URL="Host=localhost;..." \
dotnet ef migrations add <Name> --project src/Dotnet.Samples.AspNetCore.WebApi --output-dir Migrations/Npgsql
```
**Switch database provider**:
- Set `DATABASE_PROVIDER=postgres` to use PostgreSQL, or leave unset for SQLite (default).
- `DATABASE_URL` (required for PostgreSQL) follows the Npgsql convention: `Host=...;Database=...;Username=...;Password=...`.
- For SQLite, `STORAGE_PATH` overrides the default file path (`AppContext.BaseDirectory/storage/players-sqlite3.db`).
- `ProviderSpecificMigrationsAssembly` filters migration discovery to the active provider's namespace at runtime — no code changes needed to switch.
- Migrations run automatically at startup via `MigrateAsync()`; no manual `dotnet ef database update` is required.
## Invariants (never change without explicit discussion)
- **Port**: 9000 — configured via Docker (`Dockerfile` ENV and `compose.yaml` ports)
- **API contract**: endpoints, HTTP status codes, and response shapes are fixed; do not change them without explicit discussion
- **Commit format**: `type(scope): description (#issue)` — max 80 chars
- **Conventional Commits types**: `feat` `fix` `chore` `docs` `test` `refactor` `ci` `perf`
- **CHANGELOG.md** `[Unreleased]` section must be updated before every commit
## Architecture Decision Records
Significant architectural decisions are documented in `docs/adr/` (ADR-0001–0017).
Load `#file:docs/adr/README.md` when:
- The user asks about architectural choices or "why we use X"
- Proposing changes to core architecture or dependencies
- Historical context for past decisions is needed
Each ADR is self-contained. When a proposal would change an accepted decision, create a new ADR rather than editing the existing one.
**After completing work**: Suggest a branch name (e.g. `feat/add-player-search`) and a commit message following Conventional Commits including co-author line:
```text
feat(scope): description (#issue)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
```
## Claude Code
- Run `/pre-commit` to execute the full pre-commit checklist for this project.
+128
View File
@@ -0,0 +1,128 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, religion, or sexual identity
and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
* Focusing on what is best not just for us as individuals, but for the
overall community
Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or
advances of any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email
address, without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official e-mail address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
<nanotaboada@protonmail.com>.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series
of actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or
permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within
the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.0, available at
<https://www.contributor-covenant.org/version/2/0/code_of_conduct.html>.
Community Impact Guidelines were inspired by [Mozilla's code of conduct
enforcement ladder](https://github.com/mozilla/diversity).
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see the FAQ at
<https://www.contributor-covenant.org/faq>. Translations are available at
<https://www.contributor-covenant.org/translations>.
+64
View File
@@ -0,0 +1,64 @@
# Contributing
Thank you for improving this project! We value small, precise changes that solve real problems.
We value **incremental, detail‑first contributions** over big rewrites or abstractions.
## 1. Philosophy
> "Nobody should start to undertake a large project. You start with a small _trivial_ project, and you should never expect it to get large. If you do, you'll just overdesign and generally think it is more important than it likely is at that stage. Or worse, you might be scared away by the sheer size of the work you envision. So start small, and think about the details. Don't think about some big picture and fancy design. If it doesn't solve some fairly immediate need, it's almost certainly over-designed. And don't expect people to jump in and help you. That's not how these things work. You need to get something half-way _useful_ first, and then others will say "hey, that _almost_ works for me", and they'll get involved in the project." — [Linus Torvalds](https://web.archive.org/web/20050404020308/http://www.linuxtimes.net/modules.php?name=News&file=article&sid=145)
## 2. Code & Commit Conventions
- **Conventional Commits**
Follow <https://www.conventionalcommits.org/en/v1.0.0/>:
- `feat: …` for new features
- `fix: …` for bug fixes
- `chore: …` for maintenance
- **Logical Commits**
Group changes by purpose. It’s okay to have multiple commits in a PR, but if they’re mere checkpoints, squash them into a single logical commit.
- **Lint & Tests**
Run existing linters/formatters and ensure all tests pass.
## 3. Pull Request Workflow
- **One logical change per PR.**
- **Rebase or squash** before opening to keep history concise.
- **Title & Description**
- Title uses Conventional Commits style.
- Description explains _what_ and _why_—keep context minimal.
## 4. Issue Reporting
- Search existing issues first.
- Provide a minimal reproducible example and clear steps.
## 5. Automation & Checks
We enforce quality via CI on every push and PR:
- **Commitlint** for commit‑message style
- **Linters/Formatters**
- **Unit tests**
Failures must be fixed before review.
## 6. Architecture Decision Records
When proposing changes that affect:
- Project structure or overall architecture
- Technology choices or dependencies
- API contracts or data model design
- Non-functional requirements (performance, security, scalability)
- Development workflows or processes
Please create an ADR in the `docs/adr/` directory following the existing template. See [`docs/adr/README.md`](docs/adr/README.md) for the template and index.
## 7. Code of Conduct & Support
- Please see `CODE_OF_CONDUCT.md` for behavioral expectations and reporting.
- For quick questions or discussions, open an issue with the `discussion` label or mention a maintainer.
Thanks again for helping keep this project small, simple, and impactful!
+70
View File
@@ -0,0 +1,70 @@
# ------------------------------------------------------------------------------
# Stage 1: Builder
# This stage builds the application and its dependencies.
# ------------------------------------------------------------------------------
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS builder
WORKDIR /src
# Restore dependencies
COPY src/Dotnet.Samples.AspNetCore.WebApi/*.csproj ./Dotnet.Samples.AspNetCore.WebApi/
RUN dotnet restore ./Dotnet.Samples.AspNetCore.WebApi
COPY src/Dotnet.Samples.AspNetCore.WebApi/ ./Dotnet.Samples.AspNetCore.WebApi/
WORKDIR /src/Dotnet.Samples.AspNetCore.WebApi
# Build solution and publish release
RUN dotnet publish -c Release -o /app/publish
# ------------------------------------------------------------------------------
# Stage 2: Runtime
# This stage creates the final, minimal image to run the application.
# ------------------------------------------------------------------------------
FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS runtime
WORKDIR /app
# Install curl for health check
RUN apk add --no-cache curl
# Metadata labels for the image. These are useful for registries and inspection.
LABEL org.opencontainers.image.title="🧪 Web API made with .NET 10 (LTS) and ASP.NET Core"
LABEL org.opencontainers.image.description="Proof of Concept for a Web API made with .NET 10 (LTS) and ASP.NET Core"
LABEL org.opencontainers.image.licenses="MIT"
LABEL org.opencontainers.image.source="https://github.com/nanotaboada/Dotnet.Samples.AspNetCore.WebApi"
# Set environment variables
ENV ASPNETCORE_URLS=http://+:9000
ENV ASPNETCORE_ENVIRONMENT=Production
# Alpine images default to invariant globalization mode. Explicitly opt in since
# this API uses ISO-8601 dates and ASCII data — ICU is not required.
ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=true
# Copy published app from builder
COPY --from=builder /app/publish/ .
# Copy metadata docs for container registries (e.g.: GitHub Container Registry)
COPY --chmod=444 README.md ./
# https://rules.sonarsource.com/docker/RSPEC-6504/
# Copy entrypoint and healthcheck scripts
COPY --chmod=555 scripts/entrypoint.sh ./entrypoint.sh
COPY --chmod=555 scripts/healthcheck.sh ./healthcheck.sh
# Add non-root user and make volume mount point writable
RUN addgroup -S aspnetcore && \
adduser -S -G aspnetcore aspnetcore && \
mkdir -p /storage && \
chown aspnetcore:aspnetcore /storage
USER aspnetcore
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD ["./healthcheck.sh"]
EXPOSE 9000
ENTRYPOINT ["./entrypoint.sh"]
CMD ["dotnet", "Dotnet.Samples.AspNetCore.WebApi.dll"]
+36
View File
@@ -0,0 +1,36 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{6E3A447C-C7E3-4E1C-B70D-CE1C4E6B13E1}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dotnet.Samples.AspNetCore.WebApi", "src\Dotnet.Samples.AspNetCore.WebApi\Dotnet.Samples.AspNetCore.WebApi.csproj", "{1ED8A3DB-5092-4D79-ABA2-403797A6898C}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{0A2F456B-EF02-40FA-A529-E4DA81E545AB}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dotnet.Samples.AspNetCore.WebApi.Tests", "test\Dotnet.Samples.AspNetCore.WebApi.Tests\Dotnet.Samples.AspNetCore.WebApi.Tests.csproj", "{F45E3AF3-F6A2-4494-AEF2-2A228D5E8F1E}"
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
{1ED8A3DB-5092-4D79-ABA2-403797A6898C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{1ED8A3DB-5092-4D79-ABA2-403797A6898C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{1ED8A3DB-5092-4D79-ABA2-403797A6898C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{1ED8A3DB-5092-4D79-ABA2-403797A6898C}.Release|Any CPU.Build.0 = Release|Any CPU
{F45E3AF3-F6A2-4494-AEF2-2A228D5E8F1E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{F45E3AF3-F6A2-4494-AEF2-2A228D5E8F1E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F45E3AF3-F6A2-4494-AEF2-2A228D5E8F1E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F45E3AF3-F6A2-4494-AEF2-2A228D5E8F1E}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{1ED8A3DB-5092-4D79-ABA2-403797A6898C} = {6E3A447C-C7E3-4E1C-B70D-CE1C4E6B13E1}
{F45E3AF3-F6A2-4494-AEF2-2A228D5E8F1E} = {0A2F456B-EF02-40FA-A529-E4DA81E545AB}
EndGlobalSection
EndGlobal
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2023 Nano Taboada
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+355
View File
@@ -0,0 +1,355 @@
# 🧪 RESTful API with .NET and ASP.NET Core
[![.NET CI](https://github.com/nanotaboada/Dotnet.Samples.AspNetCore.WebApi/actions/workflows/dotnet-ci.yml/badge.svg)](https://github.com/nanotaboada/Dotnet.Samples.AspNetCore.WebApi/actions/workflows/dotnet-ci.yml)
[![.NET CD](https://github.com/nanotaboada/Dotnet.Samples.AspNetCore.WebApi/actions/workflows/dotnet-cd.yml/badge.svg)](https://github.com/nanotaboada/Dotnet.Samples.AspNetCore.WebApi/actions/workflows/dotnet-cd.yml)
[![CodeQL](https://github.com/nanotaboada/Dotnet.Samples.AspNetCore.WebApi/actions/workflows/github-code-scanning/codeql/badge.svg)](https://github.com/nanotaboada/Dotnet.Samples.AspNetCore.WebApi/actions/workflows/github-code-scanning/codeql)
[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=nanotaboada_Dotnet.Samples.AspNetCore.WebApi&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=nanotaboada_Dotnet.Samples.AspNetCore.WebApi)
[![codecov](https://codecov.io/gh/nanotaboada/Dotnet.Samples.AspNetCore.WebApi/branch/master/graph/badge.svg?token=hgJc1rStJ9)](https://codecov.io/gh/nanotaboada/Dotnet.Samples.AspNetCore.WebApi)
[![CodeFactor](https://www.codefactor.io/repository/github/nanotaboada/Dotnet.Samples.AspNetCore.WebApi/badge)](https://www.codefactor.io/repository/github/nanotaboada/Dotnet.Samples.AspNetCore.WebApi)
[![License: MIT](https://img.shields.io/badge/License-MIT-3DA639.svg)](https://opensource.org/licenses/MIT)
![Dependabot](https://img.shields.io/badge/Dependabot-contributing-025E8C?logo=dependabot&logoColor=white&labelColor=181818)
![Copilot](https://img.shields.io/badge/Copilot-contributing-8662C5?logo=githubcopilot&logoColor=white&labelColor=181818)
![Claude](https://img.shields.io/badge/Claude-contributing-D97757?logo=claude&logoColor=white&labelColor=181818)
![CodeRabbit](https://img.shields.io/badge/CodeRabbit-reviewing-FF570A?logo=coderabbit&logoColor=white&labelColor=181818)
Proof of Concept for a RESTful API built with .NET 10 (LTS) and ASP.NET Core. Manage football player data with SQLite, Entity Framework Core, Swagger documentation, and in-memory caching.
## Features
- 🏗️ **Clean layered architecture** - Repository pattern, dependency injection, and async operations throughout
- 📚 **Interactive API exploration** - Swagger UI documentation with health monitoring endpoints
- ⚡ **Performance optimizations** - In-memory caching, rate limiting, and efficient database queries
- ✅ **Input Validation** - FluentValidation rule sets with CRUD-scoped constraints and RFC 7807 error responses
- 🐳 **Full containerization** - Multi-stage Docker builds with Docker Compose orchestration
- 🔄 **Complete CI/CD pipeline** - Automated testing, code quality checks, Docker publishing, and GitHub releases
## Tech Stack
| Category | Technology |
| -------- | ---------- |
| **Framework** | [.NET 10](https://github.com/dotnet/core) (LTS) |
| **Web Framework** | [ASP.NET Core 10.0](https://github.com/dotnet/aspnetcore) |
| **API Documentation** | [Swashbuckle](https://github.com/domaindrivendev/Swashbuckle.AspNetCore) (OpenAPI 3.0) |
| **Validation** | [FluentValidation 12](https://github.com/FluentValidation/FluentValidation) |
| **Mapping** | [AutoMapper 14](https://github.com/AutoMapper/AutoMapper) |
| **Database** | [SQLite 3](https://github.com/sqlite/sqlite) (default) · [PostgreSQL 17](https://github.com/postgres/postgres) (opt-in) |
| **ORM** | [Entity Framework Core 10.0](https://github.com/dotnet/efcore) |
| **Logging** | [Serilog 9](https://github.com/serilog/serilog) |
| **Testing** | [xUnit](https://github.com/xunit/xunit), [Moq](https://github.com/devlooped/moq), [FluentAssertions](https://github.com/fluentassertions/fluentassertions) |
| **Containerization** | [Docker](https://github.com/docker) & [Docker Compose](https://github.com/docker/compose) |
## Architecture
Layered architecture with dependency injection via constructors and interface-based contracts.
```mermaid
%%{init: {
"theme": "default",
"themeVariables": {
"fontFamily": "Fira Code, Consolas, monospace",
"textColor": "#555",
"lineColor": "#555",
"clusterBkg": "#f5f5f5",
"clusterBorder": "#ddd"
}
}}%%
graph RL
Tests[Tests]
subgraph Layer1[" "]
Program[Program]
Serilog[Serilog]
Swashbuckle[Swashbuckle]
end
subgraph Layer2[" "]
Controllers[Controllers]
Validators[Validators]
FluentValidation[FluentValidation]
AspNetCore[ASP.NET Core]
end
subgraph Layer3[" "]
Services[Services]
Mappings[Mappings]
AutoMapper[AutoMapper]
MemoryCache[MemoryCache]
end
subgraph Layer4[" "]
Repositories[Repositories]
Data[Data]
EFCore[EF Core]
end
Models[Models]
%% Strong dependencies
%% Layer 1
Controllers --> Program
Serilog --> Program
Swashbuckle --> Program
%% Layer 2
Services --> Controllers
Validators --> Controllers
FluentValidation --> Validators
AspNetCore --> Controllers
%% Layer 3
Repositories --> Services
MemoryCache --> Services
Mappings --> Services
AutoMapper --> Mappings
Models --> Mappings
%% Layer 4
Models --> Repositories
Models --> Data
Data --> Repositories
EFCore --> Data
EFCore -.-> Repositories
%% Soft dependencies
Services -.-> Tests
Controllers -.-> Tests
%% Node styling with stroke-width
classDef core fill:#b3d9ff,stroke:#6db1ff,stroke-width:2px,color:#555,font-family:monospace;
classDef deps fill:#ffcccc,stroke:#ff8f8f,stroke-width:2px,color:#555,font-family:monospace;
classDef test fill:#ccffcc,stroke:#53c45e,stroke-width:2px,color:#555,font-family:monospace;
classDef feat fill:#ffffcc,stroke:#fdce15,stroke-width:2px,color:#555,font-family:monospace;
class Data,Models,Repositories,Services,Controllers,Program,Validators,Mappings core;
class AutoMapper,FluentValidation,Serilog,Swashbuckle deps;
class Tests test;
class AspNetCore,EFCore,MemoryCache feat;
```
> *Arrows follow the injection direction (A → B means A is injected into B). Solid = runtime dependency, dotted = structural. Blue = core domain, red = third-party, green = tests. Controllers call Services; Services call Repositories — bypassing layers is not permitted.*
>
> *Significant design decisions are documented as ADRs in [`docs/adr/`](docs/adr/README.md).*
## API Reference
Interactive API documentation is available via Swagger UI at `https://localhost:9000/swagger/index.html` when the server is running.
> 💡 Swagger documentation is only available in development mode for security reasons.
| Method | Endpoint | Description | Status |
| ------ | -------- | ----------- | ------ |
| `GET` | `/players` | List all players | `200 OK` |
| `GET` | `/players/{id:Guid}` | Get player by ID *(requires authentication)* | `200 OK` |
| `GET` | `/players/squadNumber/{squadNumber:int}` | Get player by squad number | `200 OK` |
| `POST` | `/players` | Create new player | `201 Created` |
| `PUT` | `/players/squadNumber/{squadNumber:int}` | Update player by squad number | `204 No Content` |
| `DELETE` | `/players/squadNumber/{squadNumber:int}` | Remove player by squad number | `204 No Content` |
| `GET` | `/health` | Health check | `200 OK` |
Error codes: `400 Bad Request` (validation failed) · `404 Not Found` (player not found) · `409 Conflict` (duplicate squad number on `POST`)
For complete endpoint documentation with request/response schemas, explore the [interactive Swagger UI](https://localhost:9000/swagger/index.html).
## Database
The database engine is selected via the `DATABASE_PROVIDER` environment variable.
| Value | Engine | Requires |
| ----- | ------ | -------- |
| `sqlite` (default) | SQLite — file-based, zero infrastructure | Nothing — clone and run |
| `postgres` | PostgreSQL 17 — server-based, production-parity | Docker |
### SQLite mode (default)
```bash
# Local run — no setup required
dotnet run --project src/Dotnet.Samples.AspNetCore.WebApi
# Docker Compose
docker compose up
```
### PostgreSQL mode (opt-in)
```bash
# Docker Compose with the postgres profile
DATABASE_PROVIDER=postgres docker compose --profile postgres up
```
When using PostgreSQL you can override the connection string and password:
```bash
DATABASE_URL=Host=localhost;Database=players;Username=postgres;Password=P0579r35_p455W0rd!
POSTGRES_PASSWORD=P0579r35_p455W0rd!
```
Copy `.env.example` to `.env` and edit as needed — `.env` is git-ignored.
## Prerequisites
Before you begin, ensure you have the following installed:
- .NET 10 SDK (LTS) or higher
- Docker Desktop (optional, for containerized deployment)
- dotnet-ef CLI tool (optional, for creating new migrations)
```bash
dotnet tool install --global dotnet-ef
```
## Quick Start
### Clone the repository
```bash
git clone https://github.com/nanotaboada/Dotnet.Samples.AspNetCore.WebApi.git
cd Dotnet.Samples.AspNetCore.WebApi
```
### Run the application
```bash
dotnet watch run --project src/Dotnet.Samples.AspNetCore.WebApi/Dotnet.Samples.AspNetCore.WebApi.csproj
```
The server will start on `https://localhost:9000`.
### Access the application
- API: `https://localhost:9000`
- Swagger Documentation: `https://localhost:9000/swagger/index.html`
- Health Check: `https://localhost:9000/health`
## Containers
This project includes full Docker support with multi-stage builds and Docker Compose for easy deployment.
### Build the Docker image
```bash
docker compose build
```
### Start the application
```bash
docker compose up
```
> 💡 On first run, the app applies EF Core migrations and seeds the database automatically into a persistent volume. On subsequent runs, that volume is reused and the data is preserved.
### Stop the application
```bash
docker compose down
```
### Reset the database
To remove the volume and let the app re-create and re-seed the database on next startup:
```bash
docker compose down -v
```
The containerized application runs on port 9000 and includes health checks that monitor the `/health` endpoint.
### Pull Docker images
Each release publishes multiple tags for flexibility:
```bash
# By semantic version (recommended for production)
docker pull ghcr.io/nanotaboada/dotnet-samples-aspnetcore-webapi:1.0.0
# By stadium name (memorable alternative)
docker pull ghcr.io/nanotaboada/dotnet-samples-aspnetcore-webapi:azteca
# Latest release
docker pull ghcr.io/nanotaboada/dotnet-samples-aspnetcore-webapi:latest
```
## Environment Variables
The application can be configured using environment variables for different scenarios:
### Local Development (`.vscode/launch.json`)
For local development and debugging:
```bash
# ASP.NET Core environment mode
ASPNETCORE_ENVIRONMENT=Development
# Server URLs
ASPNETCORE_URLS=https://localhost:9000
# Show detailed error messages
ASPNETCORE_DETAILEDERRORS=1
# Graceful shutdown timeout
ASPNETCORE_SHUTDOWNTIMEOUTSECONDS=3
```
### Container Deployment (`compose.yaml`)
For production deployment:
```bash
# Database storage path
# Points to the persistent Docker volume
STORAGE_PATH=/storage/players-sqlite3.db
```
> 💡 Additional environment variables (`ASPNETCORE_ENVIRONMENT=Production` and `ASPNETCORE_URLS=http://+:9000`) are set in the `Dockerfile`.
## Contributing
Contributions are welcome! Please read [CONTRIBUTING.md](CONTRIBUTING.md) for details on the code of conduct and the process for submitting pull requests.
**Key guidelines:**
- Follow [Conventional Commits](https://www.conventionalcommits.org/) for commit messages
- Ensure all tests pass (`dotnet test`)
- Keep changes small and focused
- Review [.github/copilot-instructions.md](.github/copilot-instructions.md) for architectural patterns
**Testing:**
Run the test suite with xUnit:
```bash
# Run all tests
dotnet test
# Run tests with coverage report
dotnet test --results-directory "coverage" --collect:"XPlat Code Coverage" --settings .runsettings
```
## Command Summary
| Command | Description |
| ------- | ----------- |
| `dotnet watch run --project src/...` | Start development server with hot reload |
| `dotnet build` | Build the solution |
| `dotnet test` | Run all tests |
| `dotnet test --collect:"XPlat Code Coverage"` | Run tests with coverage report |
| `dotnet csharpier .` | Format source code |
| `dotnet ef migrations add <Name>` | Create a new migration |
| `dotnet ef database update` | Apply migrations manually |
| `docker compose build` | Build Docker image |
| `docker compose up` | Start Docker container |
| `docker compose down` | Stop Docker container |
| `docker compose down -v` | Stop and remove Docker volume |
| **AI Commands** | |
| `/pre-commit` | Runs linting, tests, and quality checks before committing |
| `/pre-release` | Runs pre-release validation workflow |
## Legal
This project is provided for educational and demonstration purposes and may be used in production at your own discretion. All trademarks, service marks, product names, company names, and logos referenced herein are the property of their respective owners and are used solely for identification or illustrative purposes.
+73
View File
@@ -0,0 +1,73 @@
# Releases
Releases follow the pattern `v{SEMVER}-{STADIUM}` (e.g., `v1.0.0-azteca`). Codenames are drawn alphabetically from the [stadium list](CHANGELOG.md#stadium-release-names) of famous football stadiums that hosted FIFA World Cup matches.
## Workflow
### 1. Create a Release Branch
Branch protection prevents direct pushes to `master`, so all release prep goes through a PR:
```bash
git checkout master && git pull
git checkout -b release/v1.0.0-azteca
```
### 2. Update CHANGELOG.md
Move items from `[Unreleased]` to a new release section in [CHANGELOG.md](CHANGELOG.md):
```bash
# Move items from [Unreleased] to new release section
# Example: [1.0.0 - azteca] - 2026-01-22
git add CHANGELOG.md
git commit -m "docs: prepare changelog for v1.0.0-azteca release"
git push origin release/v1.0.0-azteca
# Open a PR, get it reviewed, and merge into master
```
### Pre-release Checklist
Before creating the tag, verify all of the following:
- [ ] `CHANGELOG.md` `[Unreleased]` section is moved to a new versioned release entry
- [ ] `dotnet build --configuration Release` passes
- [ ] `dotnet test --settings .runsettings` passes
- [ ] Stadium name is valid and follows alphabetical order (see [stadium list](CHANGELOG.md#stadium-release-names))
- [ ] All CI checks on `master` are green
### 3. Create and Push Tag
After the PR is merged, create and push the version tag from `master`:
```bash
git checkout master && git pull
git tag -a v1.0.0-azteca -m "Release 1.0.0 - Azteca"
git push origin v1.0.0-azteca
```
### 4. Automated CD Workflow
Pushing the tag triggers the CD workflow which automatically:
1. Validates the stadium name
2. Builds and tests the project in Release configuration
3. Publishes Docker images to GitHub Container Registry with three tags
4. Creates a GitHub Release with auto-generated changelog from commits
> 💡 Always update CHANGELOG.md before creating the tag. See [CHANGELOG.md](CHANGELOG.md) for the complete stadium list (A-Z) and release history.
## Docker Pull
Each release publishes multiple tags for flexibility:
```bash
# By semantic version (recommended for production)
docker pull ghcr.io/nanotaboada/dotnet-samples-aspnetcore-webapi:1.0.0
# By stadium name (memorable alternative)
docker pull ghcr.io/nanotaboada/dotnet-samples-aspnetcore-webapi:azteca
# Latest release
docker pull ghcr.io/nanotaboada/dotnet-samples-aspnetcore-webapi:latest
```
+75
View File
@@ -0,0 +1,75 @@
# ASP.NET Core 10.0
# Build and test ASP.NET Core projects targeting .NET 10 on Linux.
# https://learn.microsoft.com/en-us/azure/devops/pipelines/ecosystems/dotnet-core
# Pipeline triggers:
# - Disabled: CI is fully covered by GitHub Actions (.github/workflows/dotnet-ci.yml)
# - Kept for educational/reference purposes only
trigger: none
pr: none
# Agent pool configuration (equivalent to GitHub Actions 'runs-on: ubuntu-latest')
pool:
vmImage: "ubuntu-latest"
# Environment variables (equivalent to GitHub Actions 'env:')
variables:
buildConfiguration: "Release"
DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1 # Skip .NET welcome message
DOTNET_NOLOGO: true # Suppress .NET logo in output
NUGET_PACKAGES: $(Pipeline.Workspace)/.nuget/packages
# Pipeline steps (equivalent to GitHub Actions 'steps:')
steps:
# Checkout repository (equivalent to actions/checkout)
# Azure Pipelines does this implicitly, but making it explicit for clarity
- checkout: self
displayName: "Checkout repository"
# Cache NuGet packages (equivalent to actions/setup-dotnet cache feature)
# Uses packages.lock.json hash as cache key for faster restores
- task: Cache@2
displayName: "Cache NuGet packages"
inputs:
key: 'nuget | "$(Agent.OS)" | **/packages.lock.json'
restoreKeys: |
nuget | "$(Agent.OS)"
path: $(NUGET_PACKAGES)
# Install .NET 10 SDK (equivalent to actions/setup-dotnet)
# performMultiLevelLookup: allows finding other SDK versions if needed
- task: UseDotNet@2
displayName: "Set up .NET 10"
inputs:
version: "10.x"
performMultiLevelLookup: true
# Restore NuGet packages (equivalent to 'dotnet restore')
# feedsToUse: 'select' uses feeds from NuGet.config
- task: DotNetCoreCLI@2
displayName: "Restore dependencies"
inputs:
command: "restore"
projects: "**/*.csproj"
feedsToUse: "select"
# Build the solution (equivalent to 'dotnet build')
# --no-restore: skip restore since we just did it (faster build)
- task: DotNetCoreCLI@2
displayName: "Build projects"
inputs:
command: "build"
projects: "**/*.sln"
arguments: "--configuration $(buildConfiguration) --no-restore"
# Run unit tests (equivalent to 'dotnet test')
# --no-build: use already-built assemblies (faster)
# publishTestResults: automatically publish test results to Azure DevOps (built-in feature)
- task: DotNetCoreCLI@2
displayName: "Run tests"
inputs:
command: "test"
projects: "**/*Tests/*.csproj"
arguments: "--configuration $(buildConfiguration) --no-build"
publishTestResults: true
+68
View File
@@ -0,0 +1,68 @@
# Codecov Repository YAML
# https://docs.codecov.com/docs/codecov-yaml
coverage:
# https://docs.codecov.com/docs/commit-status
status:
project:
default:
target: 80%
threshold: 100%
if_not_found: success
if_ci_failed: success
patch:
default:
target: 0%
threshold: 100%
if_not_found: success
# https://docs.codecov.com/docs/components#component-options
component_management:
default_rules:
statuses:
- type: project
target: auto
branches:
- "!main"
individual_components:
- component_id: controllers
name: Controllers
paths:
- "src/Dotnet.Samples.AspNetCore.WebApi/Controllers/"
- component_id: services
name: Services
paths:
- "src/Dotnet.Samples.AspNetCore.WebApi/Services/"
comment:
layout: "header, diff, flags, components"
behavior: default
require_changes: false
# https://docs.codecov.com/docs/ignoring-paths
ignore:
- .*\.sln
- .*\.csproj
- .*\.json
- .*\.yml
- .*\.png
- "**/*.md"
- .*\/test\/.*
- .*\/scripts\/.*
- .*\/Program\.cs
- "**/LICENSE"
- "**/README.md"
- .*\/Configurations\/.*
- .*\/Data\/.*
- .*\/Enums\/.*
- .*\/Extensions\/.*
- .*\/Mappings\/.*
- .*\/Middlewares\/.*
- .*\/Migrations\/.*
- .*\/Models\/.*
- .*\/Properties\/.*
- .*\/Repositories\/.*
- .*\/Utilities\/.*
- .*\/Validators\/.*
+14
View File
@@ -0,0 +1,14 @@
import conventional from "@commitlint/config-conventional";
export default {
...conventional,
rules: {
"header-max-length": [2, "always", 80],
"body-max-line-length": [2, "always", 80],
},
ignores: [
// bypass Dependabot-style commits
(message) => /^chore\(deps(-dev)?\): bump /.test(message),
(message) => /Signed-off-by: dependabot\[bot\]/.test(message),
],
};
+45
View File
@@ -0,0 +1,45 @@
name: dotnet-samples-aspnetcore-webapi
services:
api:
image: dotnet-samples-aspnetcore-webapi
container_name: aspnetcore-app
build:
context: .
dockerfile: Dockerfile
ports:
- "9000:9000"
volumes:
- storage:/storage/
environment:
- DATABASE_PROVIDER=${DATABASE_PROVIDER:-sqlite}
- DATABASE_URL=${DATABASE_URL:-Host=postgres;Database=players;Username=postgres;Password=postgres}
- STORAGE_PATH=/storage/players-sqlite3.db
depends_on:
postgres:
condition: service_healthy
required: false
restart: unless-stopped
postgres:
image: postgres:17-alpine
container_name: aspnetcore-postgres
profiles: [postgres]
environment:
POSTGRES_DB: players
POSTGRES_USER: postgres
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
volumes:
storage:
name: dotnet-samples-aspnetcore-webapi_storage
postgres-data:
name: dotnet-samples-aspnetcore-webapi_postgres-data
@@ -0,0 +1,32 @@
# 0001. Adopt Traditional Layered Architecture
Date: 2026-04-02
## Status
Accepted (Under Reconsideration — see [Issue #266](https://github.com/nanotaboada/Dotnet.Samples.AspNetCore.WebApi/issues/266))
## Context
The project needed a structural pattern to organise the codebase for a REST API managing football player data. The primary goal is educational clarity — the project is a learning reference, so the pattern must be immediately understandable to developers familiar with standard layered architecture.
The two main candidates considered were traditional layered architecture (Controllers → Services → Repositories → Data) and Clean Architecture (with explicit domain, application, and infrastructure rings). Clean Architecture offers better separation of concerns and testability at the cost of significantly more boilerplate and conceptual overhead for a small PoC.
## Decision
We will organise the codebase into four layers: HTTP (`Controllers`, `Validators`), Business (`Services`, `Mappings`), Data (`Repositories`, `Data`), and a cross-cutting `Models` layer. Each layer depends only on the layer below it; controllers must not access repositories directly, and business logic must not live in controllers.
## Consequences
### Positive
- The pattern is widely understood and requires no prior knowledge of advanced architectural styles.
- The folder structure maps directly to responsibilities, making the codebase easy to navigate.
- Onboarding friction is low — contributors can locate any piece of logic by layer name alone.
### Negative
- Domain entities (`Player`) are shared across all layers, mixing persistence concerns with domain logic.
- Dependencies flow in multiple directions at the model level, limiting flexibility.
- Tight coupling between layers makes large-scale refactoring harder as the project grows.
### Neutral
- This decision is expected to be superseded when Issue #266 (Clean Architecture migration) is implemented. This ADR will remain in the repository as a record of the original decision and its context.
@@ -0,0 +1,33 @@
# 0002. Use MVC Controllers over Minimal API
Date: 2026-04-02
## Status
Accepted
## Context
ASP.NET Core offers two approaches for building HTTP endpoints: traditional MVC controllers (attribute-routed classes inheriting from `ControllerBase`) and Minimal API (lambda-based route handlers registered directly in `Program.cs`). Microsoft has been investing in Minimal API as the preferred path for new greenfield services since .NET 6.
For this project, the primary concern is educational clarity. The codebase serves as a learning reference for developers exploring REST API patterns in .NET, and it is part of a cross-language comparison set where consistent structural conventions across stacks matter.
## Decision
We will use MVC controllers with attribute routing. Each controller is a class that encapsulates related HTTP handlers, receives its dependencies via constructor injection, and delegates all business logic to the service layer.
## Consequences
### Positive
- Controllers provide explicit, visible grouping of related endpoints in a single class.
- Constructor injection and interface-based dependencies make controllers straightforward to unit test with mocks.
- The pattern is familiar to developers coming from any MVC background (Spring MVC, Django CBVs, Rails controllers), lowering the barrier for the target audience.
- Attribute routing (`[HttpGet]`, `[HttpPost]`, etc.) keeps route definitions co-located with their handlers.
### Negative
- Controllers introduce more boilerplate than Minimal API: class declarations, action method signatures, and `[FromRoute]`/`[FromBody]` attributes.
- Minimal API is the direction Microsoft is actively investing in for new projects; staying on MVC controllers means diverging from that trajectory over time.
- The framework overhead of MVC (model binding pipeline, action filters, etc.) is larger than Minimal API, though negligible at this project's scale.
### Neutral
- This decision may be revisited if the project migrates to Clean Architecture (Issue #266), at which point Minimal API endpoints or vertical slice handlers could be a better fit for the new structure.
@@ -0,0 +1,33 @@
# 0003. Use SQLite for Data Storage
Date: 2026-04-02
## Status
Superseded by [ADR-0014](0014-configurable-database-provider.md)
## Context
The project requires a relational database to persist football player data. The main candidates were SQLite, PostgreSQL, and SQL Server. The project is a proof-of-concept and learning reference, not a production service. Deployment simplicity and zero-configuration setup are higher priorities than scalability or advanced database features.
The cross-language comparison set (Go/Gin, Java/Spring Boot, Python/FastAPI, Rust/Rocket, TypeScript/Node.js) all use SQLite for the same reasons, so consistency across the set also favours it.
## Decision
We will use SQLite as the database engine, accessed through Entity Framework Core. The database file is created at `storage/players-sqlite3.db` at runtime: EF Core applies pending migrations (schema + seed data via `HasData()`) automatically at startup via `MigrateAsync()` before the first request is served. Docker deployments mount the file into a named volume so data survives container restarts.
## Consequences
### Positive
- Zero-config: no server process, no connection string credentials, no Docker service dependency for local development.
- EF Core abstracts the SQL dialect, so migrating to another database requires changing only the provider registration.
- `MigrateAsync()` at startup ensures the schema is always up to date, making onboarding instant without committing binary database files.
### Negative
- SQLite does not support concurrent writes, making it unsuitable for multi-instance deployments or high-throughput scenarios.
- Some EF Core features (e.g., certain migration operations) behave differently with the SQLite provider.
- Not representative of a production database choice, which may mislead learners about real-world persistence decisions.
### Neutral
- Issue #249 tracks adding PostgreSQL support for environments that require a production-grade database. When implemented, SQLite will remain the default for local development and this ADR will be supplemented by a new ADR documenting the PostgreSQL decision.
@@ -0,0 +1,32 @@
# 0004. Use UUID as Database Primary Key
Date: 2026-04-02
## Status
Accepted
## Context
Every `Player` record requires a stable, unique identity for the database. The two common choices are a sequential integer (`int` / `IDENTITY`) and a UUID (`Guid`). Sequential integers are compact, human-readable, and index-friendly, but they leak information: an external caller who knows ID `42` can infer there are at least 42 records and probe adjacent IDs. UUIDs are opaque and decoupled from the database's row insertion order.
Crucially, the internal primary key is never surfaced in the API (see ADR 0005 for why `squadNumber` is the public-facing identifier). This means the choice of key type has no direct impact on API consumers.
## Decision
We will use `Guid` (`UUID v4`) as the primary key for the `Player` entity. The `Player` model declares `public Guid Id { get; set; } = Guid.NewGuid()`, which generates the value at the application layer before EF Core calls `SaveChanges`. `PlayerDbContext` additionally configures `entity.Property(player => player.Id).ValueGeneratedOnAdd()`, which signals to EF Core that the column is auto-generated — this is consistent with the field initializer approach and ensures migrations reflect the intended behaviour. The `Id` property is never included in API request or response models.
## Consequences
### Positive
- Opaque IDs prevent callers from guessing or enumerating records by probing sequential values.
- The field initializer (`Guid.NewGuid()`) generates the UUID at the application layer before the record reaches the database, decoupling identity generation from the persistence engine.
- Aligns with .NET defaults: `Guid.NewGuid()` requires no additional libraries or configuration.
### Negative
- `Guid` occupies 16 bytes vs 4 bytes for `int`, increasing index and storage size.
- Random UUID v4 values are not monotonically increasing, which can cause B-tree index fragmentation on write-heavy workloads. This is not a concern at this project's scale with SQLite, but would matter at production load on PostgreSQL or SQL Server.
- UUIDs are not human-readable, making raw database inspection or log correlation harder.
### Neutral
- Because `Id` is never exposed through the API, consumers are fully isolated from this decision. A future migration to sequential IDs or UUID v7 (time-ordered) would be a pure database/model change with no API contract impact.
@@ -0,0 +1,34 @@
# 0005. Use Squad Number as API Mutation Key
Date: 2026-04-02
## Status
Accepted
## Context
The API exposes endpoints for retrieving, updating, and deleting individual players. Each of these operations requires a route parameter that identifies the target player. Two candidates exist: the internal UUID primary key (`Id`) and the domain-meaningful squad number (`SquadNumber`).
Exposing the UUID would couple API consumers to an internal database concern. UUIDs carry no domain meaning — a caller who knows that player "Lionel Messi" wears number 10 cannot construct the URL from that knowledge alone; they would first need to discover the UUID from a prior `GET /players` call.
`SquadNumber` is the natural identifier for a football player within a team context. It is the value coaches, fans, and data consumers use to refer to players. It is also the field that other API operations (`POST` uniqueness check, `PUT` route matching) already reason about.
## Decision
We will use `squadNumber` as the route parameter for mutation endpoints and primary lookup routes: `GET /players/squadNumber/{n}`, `PUT /players/squadNumber/{n}`, and `DELETE /players/squadNumber/{n}`. The internal `Id` (UUID) is never included in any request model or response model. A secondary `GET /players/{id:Guid}` route exists for internal or administrative lookups by UUID but is not part of the primary API contract.
## Consequences
### Positive
- URLs are human-readable and predictable from domain knowledge alone (`/players/squadNumber/10` unambiguously refers to the number-10 player).
- API consumers are fully decoupled from the internal persistence model.
- Consistency: the same identifier used in the domain (`SquadNumber`) is used in the API surface, reducing the mental translation between domain concepts and HTTP calls.
### Negative
- Squad numbers are unique only within a single team and only for the duration of a season. They can be reassigned when a player leaves or retires. This project models a single team's squad, so cross-team ambiguity is not a concern, but the limitation is real.
- Uniqueness must be enforced at the application layer. The `BeUniqueSquadNumber` async validator rule in the `Create` rule set guards against duplicate squad numbers on insert; the `Update` rule set intentionally omits this check because the player already exists.
- `squadNumber` is not a stable long-term identifier — if squad number reassignment were ever supported, consumers holding a bookmarked URL would silently reach a different player.
### Neutral
- The `GET /players/{id:Guid}` endpoint exists as a secondary route for internal or administrative lookups by UUID. It is intentionally excluded from the primary API contract and is not the recommended path for general consumers.
@@ -0,0 +1,32 @@
# 0006. Use RFC 7807 Problem Details for Errors
Date: 2026-04-02
## Status
Accepted
## Context
HTTP APIs need a consistent format for communicating errors to consumers. Without a standard, each endpoint might return a different error shape — plain strings, custom JSON objects, or raw status codes with empty bodies — making client-side error handling fragile and unpredictable.
RFC 7807 (Problem Details for HTTP APIs) defines a standard JSON structure for error responses with well-known fields (`type`, `title`, `status`, `detail`, `instance`). ASP.NET Core 7+ includes built-in support for this format via `TypedResults.Problem` and `TypedResults.ValidationProblem`.
## Decision
We will use RFC 7807 Problem Details for all error responses. Validation failures will use `TypedResults.ValidationProblem`, which extends the standard shape with a `errors` field containing field-level messages. All other errors (not found, conflict, internal server error) will use `TypedResults.Problem` with an appropriate HTTP status code and a human-readable `detail` field.
## Consequences
### Positive
- A single, predictable error shape across all endpoints simplifies client-side error handling — consumers check `status` and `detail` without branching on response format.
- The format is an IETF standard, meaning it is recognisable to developers and interoperable with API tooling, gateways, and monitoring systems that understand Problem Details.
- Built-in ASP.NET Core support means no custom serialisation code is needed.
- Validation errors include field-level detail via the `errors` dictionary, giving consumers enough context to display meaningful feedback.
### Negative
- The response payload is more verbose than a plain string message, which adds minor overhead for simple error cases.
- Consumers must understand the Problem Details schema to interpret `type` URIs and distinguish error categories beyond HTTP status codes.
### Neutral
- The `type` field conventionally holds a URI that identifies the problem type. Most responses rely on the ASP.NET Core default, but individual controllers may override it with problem-specific URIs where appropriate — for example, `PlayerController` sets `Type = "https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/409"` for conflict responses. In a production API, all `type` URIs would point to project-owned documentation pages.
@@ -0,0 +1,35 @@
# 0007. Use FluentValidation over Data Annotations
Date: 2026-04-02
## Status
Accepted
## Context
ASP.NET Core provides two primary mechanisms for validating request models: Data Annotations (attributes applied directly to model properties, e.g. `[Required]`, `[Range]`) and FluentValidation (a separate library that defines validation rules in dedicated validator classes).
Data Annotations are built into the framework and require no additional dependencies, but they are limited to declarative, property-level rules. Complex cross-property validation, async database checks (e.g., verifying that a squad number is unique), and operation-specific rules (different rules for create vs. update) are difficult or impossible to express with annotations alone.
The player creation flow requires an async uniqueness check against the database (`BeUniqueSquadNumber`). The update flow must skip that same check because the player already exists. This kind of operation-contextual validation is a first-class concern in FluentValidation via named rule sets.
## Decision
We will use FluentValidation for all request model validation. Validators are defined in dedicated classes under `Validators/` and registered with the DI container via `AddValidatorsFromAssemblyContaining<PlayerRequestModelValidator>()`. Validation rules are grouped into named rule sets (`Create` and `Update`) to make operation-specific behaviour explicit. Controllers invoke the appropriate rule set by name.
## Consequences
### Positive
- Validation logic lives in its own class, separate from the model and the controller, respecting the Single Responsibility Principle.
- Named rule sets (`Create`, `Update`) make it explicit which rules apply to which HTTP operations, eliminating hidden branching inside the validator.
- Async rules (e.g., `MustAsync(BeUniqueSquadNumber)`) integrate naturally, enabling database-backed validation without awkward workarounds.
- Validators are plain classes and trivial to unit test in isolation.
### Negative
- FluentValidation is an additional NuGet dependency not bundled with the framework.
- The `ValidateAsync` overload called internally is the `IValidationContext` overload, not the generic one — an implementation detail that must be respected when mocking validators in controller tests (see Copilot instructions for the correct Moq setup).
- Teams unfamiliar with FluentValidation face a learning curve around rule sets and the async API.
### Neutral
- FluentValidation does not replace model binding. Invalid JSON structure or type mismatches are still caught by the ASP.NET Core model binder before FluentValidation runs.
@@ -0,0 +1,32 @@
# 0008. Use AutoMapper for DTO Mapping
Date: 2026-04-02
## Status
Accepted
## Context
The API separates its public contract (request/response DTOs) from its internal persistence model (`Player` entity). Every controller action that reads or writes data must map between these representations. Without a mapping library, this means writing explicit property-assignment code (`responseModel.Name = entity.Name`, etc.) in every service method — boilerplate that grows linearly with the number of fields and operations.
AutoMapper provides convention-based mapping: if property names match between source and destination, the mapping is configured once in a profile and applied everywhere. Custom mappings (renaming, transforming, ignoring fields) are expressed declaratively in the profile class.
## Decision
We will use AutoMapper for all DTO-to-entity and entity-to-DTO mappings. Mappings are defined in `PlayerMappingProfile` under `Mappings/` and registered via `AddAutoMapper(typeof(PlayerMappingProfile))`. `PlayerRequestModel` does not expose an `Id` property, so callers structurally cannot set or override the internal primary key — no explicit `.ForMember(...Ignore())` is required on the `PlayerRequestModel → Player` mapping. The `Player → PlayerResponseModel` mapping suppresses the AutoMapper validation warning for the unmapped `Id` source member via `.ForSourceMember(source => source.Id, options => options.DoNotValidate())`.
## Consequences
### Positive
- Eliminates repetitive property-assignment boilerplate in service methods.
- Mapping configuration is centralised in one profile class, making it easy to audit what is and is not mapped.
- Protection of the internal `Id` is structural — the absence of `Id` on `PlayerRequestModel` is the guarantee, which is simpler and more robust than relying on an explicit ignore rule.
### Negative
- AutoMapper uses reflection at startup to validate mapping configurations, adding a small startup overhead.
- "Magic" convention-based mapping can be opaque: when a property is silently unmapped due to a name mismatch, the bug only surfaces at runtime (though `AssertConfigurationIsValid()` in tests can catch this).
- Adding AutoMapper as a dependency means contributors must understand profile configuration to modify or extend mappings.
### Neutral
- AutoMapper's `ProjectTo<T>()` LINQ extension for EF Core queryable projections is available but not currently used. Direct entity retrieval followed by in-memory mapping is sufficient at this project's scale.
@@ -0,0 +1,34 @@
# 0009. Implement In-Memory Caching
Date: 2026-04-02
## Status
Accepted
## Context
The player list endpoint (`GET /players`) queries the database on every request. For a read-heavy API where the data changes infrequently, this is unnecessary load. Caching the result reduces database round-trips and demonstrates a common performance pattern for learners.
The two main caching approaches in .NET are `IMemoryCache` (process-local, in-memory) and distributed caching (Redis, SQL Server — shared across multiple instances via `IDistributedCache`). Distributed caching is appropriate for multi-instance deployments where cache consistency across nodes matters.
This project runs as a single instance (local development or a single Docker container) with no horizontal scaling requirement.
## Decision
We will use `IMemoryCache` managed in the service layer (`PlayerService`). Cache entries use a 10-minute sliding expiration combined with a one-hour absolute expiration: each access within the sliding window resets the TTL, but the entry is always evicted after one hour regardless of access frequency. On a cache miss, the service queries the repository, stores the result under a well-known key with these options, and returns it. Write operations (create, update, delete) invalidate the cache entry so the next read reflects the current state.
## Consequences
### Positive
- Zero additional infrastructure: `IMemoryCache` is built into `Microsoft.Extensions.Caching.Memory` with no external service dependency.
- The service layer caching pattern is easy to follow and demonstrates the cache-aside pattern clearly for learners.
- Reduces database queries for the most frequent read operation without any changes to the repository or controller layers.
### Negative
- Cache state is lost on every application restart, so the first request after a restart always hits the database.
- `IMemoryCache` is not shared across multiple instances. If this application were scaled horizontally, each instance would maintain its own independent cache, potentially serving stale or inconsistent data across nodes.
- Memory usage grows with the size of the cached data; unbounded caching of large datasets in a long-running process could contribute to memory pressure.
### Neutral
- If the project ever moves to a multi-instance deployment (e.g., Kubernetes), replacing `IMemoryCache` with a Redis-backed `IDistributedCache` would require changes only in the service layer and `Program.cs` registration — the repository and controller layers are unaffected.
@@ -0,0 +1,34 @@
# 0010. Use Serilog for Structured Logging
Date: 2026-04-02
## Status
Accepted
## Context
.NET provides a built-in logging abstraction (`Microsoft.Extensions.Logging`) with pluggable providers. Out of the box it supports console and debug output, but structured log output — where log events are emitted as queryable key-value pairs rather than plain strings — requires a third-party sink.
Structured logging is important even for a learning project because it demonstrates a production practice: log consumers (Elasticsearch, Seq, Datadog) ingest structured events and can filter, aggregate, and alert on specific fields. Emitting plain strings that embed values in a message template forfeits that capability.
Serilog is the most widely used structured logging library in the .NET ecosystem. It integrates with `ILogger<T>` via a host-level configuration, meaning application code never imports Serilog directly — it depends only on the framework abstraction.
## Decision
We will use Serilog, configured at host level in `Program.cs` via `UseSerilog()`. Two sinks are configured: console (for local development visibility) and file (for persistent log output under `logs/`). Message template parameters are passed as structured properties (`{@Player}`, `{SquadNumber}`), not interpolated strings, so that sinks that support structured output can index them as discrete fields.
## Consequences
### Positive
- Application code depends on `ILogger<T>` (the framework abstraction), not on Serilog directly. Swapping the logging backend requires only a `Program.cs` change.
- Structured log events support downstream querying and alerting in log management systems, demonstrating a production-grade logging practice.
- Serilog's enrichers (machine name, thread ID, environment) can be added declaratively in configuration without touching application code.
- The file sink provides a persistent log archive useful for debugging Docker container runs after the container exits.
### Negative
- Serilog is an additional dependency. Its host-level integration (`UseSerilog`) replaces the default ASP.NET Core logging pipeline, which can surprise contributors who expect the default provider behaviour.
- Misconfigured message templates (e.g., using string interpolation instead of structured parameters) silently degrade structured output without compile-time warnings.
### Neutral
- The project previously used `Microsoft.Extensions.Logging` directly (removed in PR #192). The migration to Serilog was driven by the need for file sink support and consistent structured output across the cross-language comparison set.
@@ -0,0 +1,33 @@
# 0011. Use Docker for Containerization
Date: 2026-04-02
## Status
Accepted
## Context
The project needs to be runnable without requiring contributors to install the .NET SDK, configure environment variables, or manage database files manually. A containerised delivery format solves the "works on my machine" problem and demonstrates a standard deployment practice.
Docker is the de facto containerisation standard in the .NET ecosystem and across the cross-language comparison set that this project belongs to. Docker Compose provides a single-command local orchestration layer that handles image building, port mapping, and volume mounting without requiring knowledge of raw `docker run` flags.
## Decision
We will provide a multi-stage `Dockerfile` (build stage + runtime stage) and a `compose.yaml` for local orchestration. The application runs on port 9000 in the container, matching the local development port. A named Docker volume persists the SQLite database file across container restarts. The first run copies a pre-seeded database into the volume; subsequent runs reuse the existing volume.
## Consequences
### Positive
- `docker compose up` is the only command needed to run the full application from a fresh clone, with no SDK or database setup required.
- Multi-stage builds produce a minimal runtime image: only the published application artifacts are included, not the SDK or intermediate build outputs.
- The containerised environment closely mirrors what the CD pipeline builds and publishes to GitHub Container Registry, reducing environment-specific surprises.
- Port 9000 is consistent across all environments (local, Docker, CI), eliminating port-related configuration drift.
### Negative
- Docker Desktop is required on developer machines. On macOS and Windows, Docker Desktop is a large install with licensing implications for commercial use.
- The Docker abstraction layer adds a level of indirection that can make debugging harder for contributors unfamiliar with container networking or volume mounts.
- SQLite inside a Docker volume is not suitable for production use — it is an appropriate trade-off only because this project is explicitly a development and learning reference.
### Neutral
- Images are published to GitHub Container Registry (`ghcr.io`) as part of the CD pipeline, tagged by semantic version, stadium name, and `latest`. See the Releases section of `README.md` for the full tagging convention.
@@ -0,0 +1,35 @@
# 0012. Use Stadium-Themed Semantic Versioning
Date: 2026-04-02
## Status
Accepted
## Context
The project follows Semantic Versioning (`MAJOR.MINOR.PATCH`) for release numbers. Purely numeric version strings are accurate but forgettable — contributors and users rarely remember whether "2.1.0" came before or after "2.0.3" without consulting the changelog.
The project is football-themed (it manages football player data), and is part of a cross-language comparison set. A naming convention that reflects the project's domain makes releases memorable and reinforces the project's identity.
Several well-known projects use codename conventions: Ubuntu (alphabetical adjective-animal pairs), Android (alphabetical desserts until Android 10), macOS (California landmarks). The pattern is established and understood.
## Decision
We will append a football stadium codename to every release tag, following the format `vMAJOR.MINOR.PATCH-stadium` (e.g., `v2.1.0-dusseldorf`). Stadium names are drawn from a fixed, alphabetically ordered list of venues that hosted FIFA World Cup matches, documented in `CHANGELOG.md`. Names are assigned sequentially A→Z; the next release always uses the next unused letter. The tag format is enforced by the CD pipeline, which validates the stadium name before publishing.
## Consequences
### Positive
- Release names are memorable: "the dusseldorf release" is easier to recall and discuss than "2.1.0".
- Alphabetical ordering provides an implicit sequence — contributors can determine release order from the name alone without consulting version numbers.
- The convention is consistent and deterministic: there is no ambiguity about which name comes next.
- Reinforces the football domain theme throughout the project's lifecycle.
### Negative
- The convention is non-standard and may confuse first-time contributors who expect plain semantic version tags.
- The stadium list is finite (26 letters). If the project ever reaches 26 major releases, the list must be extended or the convention revisited.
- The CD pipeline validation adds a small amount of CI complexity to enforce the format.
### Neutral
- The stadium list is published in `CHANGELOG.md` under "Stadium Release Names" for reference. The current position in the sequence is always the last released tag — the next unused letter determines the next stadium name.
+49
View File
@@ -0,0 +1,49 @@
# 0013. Testing Strategy
Date: 2026-04-10
## Status
Accepted
## Context
The project is a learning reference for a REST API built with ASP.NET Core. Its primary goal is educational clarity — it must be immediately understandable to developers studying the stack, not just functional in production.
For a CRUD API of this size, a single suite of HTTP-layer integration tests (using `WebApplicationFactory<Program>` backed by an in-memory SQLite database) would cover the majority of meaningful risk. Such tests exercise the full request pipeline — routing, middleware, validation, serialization, and database interaction — with minimal setup.
The question was whether to stop there or to also add unit tests for each layer (controller, service, validator, repository) separately.
## Decision
The project implements the full test pyramid:
- **Unit tests** for each layer in isolation (controller, service, validator, repository), with dependencies replaced by Moq mocks or in-memory fakes.
- **Repository integration tests** that run EF Core queries against in-memory SQLite via the full migration chain, validating query correctness and migration health as a side effect.
- **HTTP integration tests** (`PlayerWebApplicationTests`) that send real HTTP requests through `WebApplicationFactory<Program>`, exercising the entire pipeline end-to-end.
Some overlap between layers is accepted deliberately.
## Rationale
The redundancy is the point. Having all test types in one place allows a reader to see exactly what each layer tests, what it isolates, and what it leaves to the layer above or below. The test suite is as much documentation as it is a safety net.
The repository integration tests in particular serve two purposes beyond what the HTTP tests provide: they validate individual EF Core queries in isolation (making failures easier to diagnose) and they run `MigrateAsync()` as a side effect, which acts as a canary for migration chain health.
## Consequences
### Positive
- Every layer of the architecture has dedicated tests, making failure diagnosis straightforward.
- The test suite demonstrates the full range of testing patterns available in the .NET ecosystem.
- Migration health is continuously validated as a side effect of the repository tests.
### Negative
- Test count is higher than strictly necessary for confidence.
- Maintenance cost is proportionally higher — changes to shared fixtures or fakes may require updates across multiple test classes.
### Neutral
- The HTTP integration tests (`PlayerWebApplicationTests`) are the minimum viable test suite. All other test classes add depth, not breadth.
- The `409 Conflict` branch on `POST /players` is unreachable via the HTTP pipeline: the `BeUniqueSquadNumber` rule in the `"Create"` validation rule set catches duplicates first and returns `400`. This path is covered by the controller unit test, where validation is mocked to pass.
@@ -0,0 +1,87 @@
# 0014. Configurable Database Provider
Date: 2026-05-02
## Status
Accepted — supersedes [ADR-0003](0003-use-sqlite-for-data-storage.md)
## Context
The project used SQLite as its only database engine (ADR-0003). This meant all environments — local development, Docker Compose, and any deployment — ran the same SQLite file-based database. For the majority of contributors this is ideal: zero infrastructure required.
However, a fixed SQLite-only setup has limits:
- SQLite does not support concurrent writes, so multi-instance deployments are not possible.
- Developers who want to validate production-parity behaviour (e.g. PostgreSQL-specific query plans, type semantics, or constraint handling) have no path to do so without modifying the project.
- A fixed SQLite-in-dev / PostgreSQL-in-prod split (a common alternative) introduces a different problem: subtle behavioral differences between environments that are hard to catch locally.
The goal is to make the database engine fully configurable with a single environment variable, so the same stack is used consistently across every environment a developer chooses to run.
## Decision
We will introduce a `DATABASE_PROVIDER` environment variable that selects the database engine at startup:
- **`DATABASE_PROVIDER=sqlite`** (default): SQLite everywhere. Zero infrastructure required. Works on any machine without Docker. Clone and run.
- **`DATABASE_PROVIDER=postgres`**: PostgreSQL everywhere. Requires Docker. Opt-in for developers who want a server-based engine or full production parity.
The default is `sqlite` to keep the barrier to entry as low as possible.
### Provider selection at startup
`ServiceCollectionExtensions.AddDbContextPool` reads `DATABASE_PROVIDER` and wires the appropriate EF Core provider:
```csharp
switch (provider.ToLowerInvariant())
{
case "postgres":
options.UseNpgsql(Environment.GetEnvironmentVariable("DATABASE_URL"));
break;
default:
options.UseSqlite($"Data Source={dataSource}");
break;
}
```
### Provider-specific EF Core migrations
SQLite and PostgreSQL require different column type mappings (`TEXT`/`INTEGER` vs `uuid`/`boolean`/`timestamp with time zone`). A single migration set cannot satisfy both providers. To resolve this:
- SQLite migrations remain in `Migrations/` (namespace `...Migrations`).
- PostgreSQL migrations are placed in `Migrations/Npgsql/` (namespace `...Migrations.Npgsql`).
- A custom `ProviderSpecificMigrationsAssembly` (implementing EF Core's `IMigrationsAssembly`) filters the discovered migrations to only those in the active provider's namespace. It is registered via `options.ReplaceService<IMigrationsAssembly, ProviderSpecificMigrationsAssembly>()`.
`MigrateAsync()` at startup continues to work for both providers; each sees only its own migration set.
### Docker Compose profiles
The `postgres` service is declared under the `postgres` Compose profile so it only starts when explicitly requested:
```bash
# SQLite (default — no extra Docker service)
docker compose up
# PostgreSQL (opt-in)
DATABASE_PROVIDER=postgres docker compose --profile postgres up
```
## Consequences
### Positive
- Developers choose their engine once and use it consistently across all environments.
- SQLite remains the zero-friction default — clone, run, done.
- PostgreSQL is a first-class option for production-parity testing without requiring project-wide changes.
- The Compose profile approach prevents accidental PostgreSQL containers from starting in SQLite mode.
- `dotnet run` continues to work unchanged with SQLite.
### Negative
- Two parallel migration sets must be maintained. Adding a schema change requires migrations in both `Migrations/` and `Migrations/Npgsql/`.
- `ProviderSpecificMigrationsAssembly` uses an EF Core internal API (`MigrationsAssembly` from `Microsoft.EntityFrameworkCore.Migrations.Internal`), annotated with `#pragma warning disable EF1001`. This may require updates on major EF Core version upgrades.
- PostgreSQL support is not tested in CI (no Testcontainers integration yet — tracked in issue #353).
### Neutral
- The `DATABASE_URL` connection string format follows the Npgsql convention (`Host=...;Database=...;Username=...;Password=...`).
- `.env.example` documents all three new environment variables (`DATABASE_PROVIDER`, `DATABASE_URL`, `POSTGRES_PASSWORD`). `.env` is git-ignored.
@@ -0,0 +1,49 @@
# 0015. Use Full-Replace PUT as the Partial Update Strategy
Date: 2026-06-10
## Status
Accepted
## Context
HTTP defines two methods for updating an existing resource:
- **PUT** — full replacement; the client sends the complete resource
representation, and the server replaces the stored state entirely
- **PATCH** — partial update; the client sends only the changed fields
Both are standard and well-understood. The choice affects API surface
complexity, client implementation requirements, and server-side
validation logic.
## Decision
We use PUT for all player update operations
(`PUT /players/squadNumber/{n}`). The request body must contain the
full player representation; the server replaces the stored resource
entirely. No PATCH endpoint is provided at this time. A PATCH
implementation is tracked in the project backlog and remains under
active consideration.
## Consequences
### Positive
- Simpler server-side implementation: a single validation path,
no partial-update merge logic
- PUT semantics are idempotent and well-understood by API consumers
- Consistent with all sibling repos in the cross-language comparison set
### Negative
- Clients must send the full resource representation even for
single-field changes
- Fine-grained partial updates require a GET followed by a full PUT
- PATCH tracked in backlog — if implemented, this ADR will be superseded
### Neutral
- Standard REST semantics; no ambiguity about the update contract
for current consumers
@@ -0,0 +1,76 @@
# 0016. Adopt AI-Assisted Development Workflow
Date: 2026-06-10
## Status
Accepted
## Context
Software development has historically relied on three successive layers
of knowledge:
1. **Official documentation** — authoritative but static; describes the
intended API but not how real projects apply it
2. **Community collaboration** — Stack Overflow, GitHub Discussions, blog
posts; describes how practitioners actually solve problems, but requires
the developer to synthesize and apply that knowledge themselves
3. **AI synthesis** — models trained on both layers above, capable of
applying idiomatic, stack-specific knowledge directly to a given
codebase
Agentic AI tools represent a qualitative shift: instead of consulting
knowledge and applying it manually, the developer delegates implementation
to an agent that has absorbed the collective idioms of a stack — "the
Microsoft way", "the ASP.NET Core way" — and can apply them consistently.
For a cross-language REST API comparison project, this matters
particularly: each implementation should reflect how an experienced
practitioner in that stack would structure the same problem, not a
generic approach that happens to compile.
Prior to Claude Code, AI assistance was used ad-hoc — pasting code into
web interfaces (ChatGPT, DeepSeek) or via IDE-integrated assistants
(GitHub Copilot). Both approaches lack persistent codebase context and
the ability to act autonomously across a project.
## Decision
We adopt Claude Code as the primary development workflow tool for this
project.
A `CLAUDE.md` file at the repository root serves as the workflow
specification: it documents architecture, coding conventions, invariants,
and explicit boundaries for autonomous operation — what the agent may do
freely, what requires human approval, and what must never be changed.
CodeRabbit provides an additional automated code review layer
independent of the primary workflow.
## Consequences
### Positive
- Stack-specific idioms are enforced by the agent's collective knowledge
rather than individual developer discipline
- `CLAUDE.md` is living architectural documentation: it must stay
accurate for the workflow to function, creating a natural incentive to
keep it current
- Explicit autonomy boundaries make human oversight intentional rather
than incidental
### Negative
- Token economics: long-running work may exceed context limits, requiring
active session management and continuation prompts to resume work
across sessions
- The global `~/.claude/CLAUDE.md` and per-repo `CLAUDE.md` must stay
aligned; drift between them produces inconsistent agent behavior
### Neutral
- Development workflow moves to the terminal/CLI rather than an IDE;
this has no impact on the codebase itself
- `CLAUDE.md` is specific to Claude Code; a different tool would require
a different workflow specification format
+61
View File
@@ -0,0 +1,61 @@
# 0017. Adopt Spec-Driven Development (SDD)
Date: 2026-06-10
## Status
Accepted
## Context
In an agentic development workflow, it is easy to begin implementation
before requirements are fully understood. An AI assistant will produce
working code from a vague prompt — but working and correct are not the
same thing. Without a forcing function that requires requirements to be
stated explicitly before implementation begins, scope creep, rework, and
misaligned implementations are likely outcomes.
GitHub Issues provide a natural spec artifact: persistent, linkable,
and — critically — they survive session boundaries. In a workflow where
context is lost between sessions, an Issue that captures the agreed
approach and acceptance criteria before implementation begins serves as
the durable contract between intent and implementation.
## Decision
All new features and non-trivial changes follow a three-step workflow:
1. **Discuss** — describe the requirement in Claude Code Plan mode;
explore alternatives and consequences before committing to an approach
2. **Specify** — create a GitHub Issue as the spec artifact, capturing
the agreed approach, acceptance criteria, and any constraints
3. **Implement** — work against the Issue; commits reference it via
the `(#issue)` suffix in the Conventional Commits format
Trivial changes (documentation updates, formatting fixes, dependency
bumps) may skip the Issue step at the developer's discretion.
## Consequences
### Positive
- Requirements are stated explicitly before implementation; the agent
works against a written spec rather than an interpreted prompt
- Issues survive session boundaries — a new session can resume from
the Issue rather than reconstructing intent from scratch
- The implementation trail (Issue → branch → PR → commit) is traceable
without relying on session memory
### Negative
- Adds upfront overhead for changes that feel small; the Issue step can
seem bureaucratic for straightforward work
- The line between "spec-worthy" and "trivial" is judgment-dependent
and not enforced by tooling
### Neutral
- GitHub Issues double as the project backlog; SDD and backlog
management share the same artifact
- Plan mode discussion is ephemeral — not persisted outside the session;
the Issue is the only durable record of the pre-implementation reasoning
+67
View File
@@ -0,0 +1,67 @@
# Architecture Decision Records
This directory contains Architecture Decision Records (ADRs) for this project. ADRs document significant architectural decisions — what was decided, why, and what trade-offs were accepted.
## Index
| ADR | Title | Status |
|-----|-------|--------|
| [0001](0001-adopt-traditional-layered-architecture.md) | Adopt Traditional Layered Architecture | Accepted (Under Reconsideration) |
| [0002](0002-use-mvc-controllers-over-minimal-api.md) | Use MVC Controllers over Minimal API | Accepted |
| [0003](0003-use-sqlite-for-data-storage.md) | Use SQLite for Data Storage | Superseded by ADR-0014 |
| [0004](0004-use-uuid-as-database-primary-key.md) | Use UUID as Database Primary Key | Accepted |
| [0005](0005-use-squad-number-as-api-mutation-key.md) | Use Squad Number as API Mutation Key | Accepted |
| [0006](0006-use-rfc-7807-problem-details-for-errors.md) | Use RFC 7807 Problem Details for Errors | Accepted |
| [0007](0007-use-fluentvalidation-over-data-annotations.md) | Use FluentValidation over Data Annotations | Accepted |
| [0008](0008-use-automapper-for-dto-mapping.md) | Use AutoMapper for DTO Mapping | Accepted |
| [0009](0009-implement-in-memory-caching.md) | Implement In-Memory Caching | Accepted |
| [0010](0010-use-serilog-for-structured-logging.md) | Use Serilog for Structured Logging | Accepted |
| [0011](0011-use-docker-for-containerization.md) | Use Docker for Containerization | Accepted |
| [0012](0012-use-stadium-themed-semantic-versioning.md) | Use Stadium-Themed Semantic Versioning | Accepted |
| [0013](0013-testing-strategy.md) | Testing Strategy | Accepted |
| [0014](0014-configurable-database-provider.md) | Configurable Database Provider | Accepted |
| [0015](0015-use-full-replace-put-no-patch.md) | Use Full-Replace PUT as the Partial Update Strategy | Accepted |
| [0016](0016-ai-assisted-development-workflow.md) | Adopt AI-Assisted Development Workflow | Accepted |
| [0017](0017-spec-driven-development.md) | Adopt Spec-Driven Development (SDD) | Accepted |
## When to Create an ADR
Create an ADR when a decision affects:
- Project structure or overall architecture
- Technology choices or dependencies
- API contracts or data model design
- Non-functional requirements (performance, security, scalability)
- Development workflows or processes
## Template
```markdown
# [NUMBER]. [TITLE]
Date: YYYY-MM-DD
## Status
[Proposed | Accepted | Deprecated | Superseded by ADR-XXXX]
## Context
What is the issue we are facing? What forces are at play (technical, political,
social, project constraints)? Present facts neutrally without bias.
## Decision
We will [DECISION IN ACTIVE VOICE WITH FULL SENTENCES].
## Consequences
### Positive
- Consequence 1
### Negative
- Trade-off 1
### Neutral
- Other effect 1
```
+6
View File
@@ -0,0 +1,6 @@
{
"sdk": {
"version": "10.0.100",
"rollForward": "latestFeature"
}
}
+35
View File
@@ -0,0 +1,35 @@
#!/bin/sh
set -e
# Helper function for formatted logging
log() {
local message="$1"
echo "[ENTRYPOINT] $(date '+%Y/%m/%d - %H:%M:%S') | $message"
return 0
}
log "✔ Starting container..."
DATABASE_PROVIDER=$(printf '%s' "${DATABASE_PROVIDER:-sqlite}" | tr '[:upper:]' '[:lower:]')
if [ "$DATABASE_PROVIDER" = "postgres" ]; then
log "✔ Using PostgreSQL database."
else
VOLUME_STORAGE_PATH="${STORAGE_PATH:-/storage/players-sqlite3.db}"
if [ ! -f "$VOLUME_STORAGE_PATH" ]; then
log "⚠️ No existing database file found in volume."
log "🗄️ EF Core migrations will initialize the database on first start."
else
log "✔ Existing database file found at $VOLUME_STORAGE_PATH."
fi
fi
log "✔ Ready!"
log "🚀 Launching app..."
# Derive the API URL from ASPNETCORE_URLS (first entry if semicolon-separated),
# replacing the wildcard host (+, 0.0.0.0) with localhost for display purposes.
_raw_url=$(printf '%s' "${ASPNETCORE_URLS:-http://+:9000}" | cut -d';' -f1)
API_URL=$(printf '%s' "$_raw_url" | sed 's|+|localhost|g; s|0\.0\.0\.0|localhost|g')
log "🔌 API endpoints | $API_URL"
exec "$@"
+5
View File
@@ -0,0 +1,5 @@
#!/bin/sh
set -e
# Minimal curl-based health check with timeout and error reporting
curl --fail --silent --show-error --connect-timeout 1 --max-time 2 http://localhost:9000/health
@@ -0,0 +1,46 @@
using System.Linq;
using Microsoft.AspNetCore.Authorization;
using Microsoft.OpenApi;
using Swashbuckle.AspNetCore.SwaggerGen;
namespace Dotnet.Samples.AspNetCore.WebApi.Configurations
{
/// <summary>
/// Adds the Bearer security requirement only to operations that have [Authorize] attributes.
/// </summary>
public class AuthorizeCheckOperationFilter : IOperationFilter
{
/// <summary>
/// Applies the Bearer security requirement to Swagger operations with [Authorize] attributes.
/// </summary>
/// <param name="operation">The OpenAPI operation to modify.</param>
/// <param name="context">The operation filter context containing method metadata.</param>
public void Apply(OpenApiOperation operation, OperationFilterContext context)
{
// Check if [Authorize] is applied at the method or class level
var hasAuthorize =
context.MethodInfo.GetCustomAttributes(true).OfType<AuthorizeAttribute>().Any()
|| context
.MethodInfo.DeclaringType?.GetCustomAttributes(true)
.OfType<AuthorizeAttribute>()
.Any() == true;
// If there's no [Authorize] attribute, skip adding the security requirement
if (!hasAuthorize)
return;
// Add security requirement (shows the lock icon)
// In Microsoft.OpenApi 2.x, use OpenApiSecuritySchemeReference instead of OpenApiSecurityScheme with nested Reference
if (operation is OpenApiOperation openApiOperation)
{
openApiOperation.Security ??= new List<OpenApiSecurityRequirement>();
openApiOperation.Security.Add(
new OpenApiSecurityRequirement
{
{ new OpenApiSecuritySchemeReference("Bearer", null), new List<string>() }
}
);
}
}
}
}
@@ -0,0 +1,25 @@
namespace Dotnet.Samples.AspNetCore.WebApi.Configurations;
/// <summary>
/// Configuration options for the Fixed Window Rate Limiter.
/// </summary>
public class RateLimiterConfiguration
{
/// <summary>
/// Gets or sets the maximum number of permits that can be leased per window.
/// Default value is 60 requests.
/// </summary>
public int PermitLimit { get; set; } = 60;
/// <summary>
/// Gets or sets the time window in seconds for rate limiting.
/// Default value is 60 seconds (1 minute).
/// </summary>
public int WindowSeconds { get; set; } = 60;
/// <summary>
/// Gets or sets the maximum number of requests that can be queued when the permit limit is exceeded.
/// A value of 0 means no queuing (default).
/// </summary>
public int QueueLimit { get; set; } = 0;
}
@@ -0,0 +1,295 @@
using System.Net.Mime;
using Dotnet.Samples.AspNetCore.WebApi.Models;
using Dotnet.Samples.AspNetCore.WebApi.Services;
using FluentValidation;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Dotnet.Samples.AspNetCore.WebApi.Controllers;
[ApiController]
[Route("players")]
[Produces("application/json")]
public class PlayerController(
IPlayerService playerService,
ILogger<PlayerController> logger,
IValidator<PlayerRequestModel> validator
) : ControllerBase
{
private const string NotFoundTitle = "Not Found";
/* -------------------------------------------------------------------------
* HTTP POST
* ---------------------------------------------------------------------- */
/// <summary>
/// Creates a new Player
/// </summary>
/// <param name="player">The PlayerRequestModel</param>
/// <response code="201">Created</response>
/// <response code="409">Conflict</response>
/// <response code="422">Unprocessable Entity</response>
[HttpPost(Name = "Create")]
[Consumes(MediaTypeNames.Application.Json)]
[ProducesResponseType<PlayerResponseModel>(StatusCodes.Status201Created)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status409Conflict)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status422UnprocessableEntity)]
public async Task<IResult> PostAsync([FromBody] PlayerRequestModel player)
{
// Use the "Create" rule set, which includes BeUniqueSquadNumber.
var validation = await validator.ValidateAsync(
player,
options => options.IncludeRuleSets("Create")
);
if (!validation.IsValid)
{
var errors = validation
.Errors.GroupBy(e => e.PropertyName)
.ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());
logger.LogWarning("POST /players validation failed: {@Errors}", errors);
return TypedResults.Problem(
new HttpValidationProblemDetails(errors)
{
Status = StatusCodes.Status422UnprocessableEntity,
Detail = "See the errors field for details.",
Instance = HttpContext?.Request?.Path.ToString(),
}
);
}
if (await playerService.RetrieveBySquadNumberAsync(player.SquadNumber) != null)
{
logger.LogWarning(
"POST /players failed: Player with Squad Number {SquadNumber} already exists",
player.SquadNumber
);
return TypedResults.Conflict(
new ProblemDetails
{
Type = "https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/409",
Title = "Conflict",
Status = StatusCodes.Status409Conflict,
Detail = $"Player with Squad Number '{player.SquadNumber}' already exists.",
Instance = HttpContext?.Request?.Path.ToString(),
}
);
}
var result = await playerService.CreateAsync(player);
logger.LogInformation("POST /players created: {@Player}", result);
return TypedResults.CreatedAtRoute(
routeName: "RetrieveBySquadNumber",
routeValues: new { squadNumber = result.Dorsal },
value: result
);
}
/* -------------------------------------------------------------------------
* HTTP GET
* ---------------------------------------------------------------------- */
/// <summary>
/// Retrieves all Players
/// </summary>
/// <response code="200">OK</response>
[HttpGet(Name = "Retrieve")]
[ProducesResponseType<PlayerResponseModel>(StatusCodes.Status200OK)]
public async Task<IResult> GetAsync()
{
var players = await playerService.RetrieveAsync();
logger.LogInformation("GET /players retrieved {Count} player(s)", players.Count);
return TypedResults.Ok(players);
}
/// <summary>
/// Retrieves a Player by its internal Id (GUID)
/// </summary>
/// <param name="id">The internal Id (GUID) of the Player</param>
/// <response code="200">OK</response>
/// <response code="404">Not Found</response>
[Authorize]
[HttpGet("{id:Guid}", Name = "RetrieveById")]
[ProducesResponseType<PlayerResponseModel>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
public async Task<IResult> GetByIdAsync([FromRoute] Guid id)
{
var player = await playerService.RetrieveByIdAsync(id);
if (player != null)
{
logger.LogInformation("GET /players/{Id} retrieved: {@Player}", id, player);
return TypedResults.Ok(player);
}
else
{
logger.LogWarning("GET /players/{Id} not found", id);
return TypedResults.Problem(
statusCode: StatusCodes.Status404NotFound,
title: NotFoundTitle,
detail: $"Player with Id '{id}' was not found.",
instance: HttpContext?.Request?.Path.ToString()
);
}
}
/// <summary>
/// Retrieves a Player by its Squad Number
/// </summary>
/// <param name="squadNumber">The Squad Number of the Player</param>
/// <response code="200">OK</response>
/// <response code="404">Not Found</response>
[HttpGet("squadNumber/{squadNumber:int}", Name = "RetrieveBySquadNumber")]
[ProducesResponseType<PlayerResponseModel>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
public async Task<IResult> GetBySquadNumberAsync([FromRoute] int squadNumber)
{
var player = await playerService.RetrieveBySquadNumberAsync(squadNumber);
if (player != null)
{
logger.LogInformation(
"GET /players/squadNumber/{SquadNumber} retrieved: {@Player}",
squadNumber,
player
);
return TypedResults.Ok(player);
}
else
{
logger.LogWarning("GET /players/squadNumber/{SquadNumber} not found", squadNumber);
return TypedResults.Problem(
statusCode: StatusCodes.Status404NotFound,
title: NotFoundTitle,
detail: $"Player with Squad Number '{squadNumber}' was not found.",
instance: HttpContext?.Request?.Path.ToString()
);
}
}
/* -------------------------------------------------------------------------
* HTTP PUT
* ---------------------------------------------------------------------- */
/// <summary>
/// Updates (entirely) a Player by its Squad Number
/// </summary>
///
/// <param name="player">The PlayerRequestModel</param>
/// <param name="squadNumber">The Squad Number of the Player</param>
/// <response code="204">No Content</response>
/// <response code="400">Bad Request (route/body squad number mismatch)</response>
/// <response code="404">Not Found</response>
/// <response code="422">Unprocessable Entity (field validation failure)</response>
[HttpPut("squadNumber/{squadNumber:int}", Name = "Update")]
[Consumes(MediaTypeNames.Application.Json)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status400BadRequest)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status422UnprocessableEntity)]
public async Task<IResult> PutAsync(
[FromRoute] int squadNumber,
[FromBody] PlayerRequestModel player
)
{
// Use the "Update" rule set, which omits BeUniqueSquadNumber.
// The player being updated already exists in the database, so a
// uniqueness check on its own squad number would always fail.
var validation = await validator.ValidateAsync(
player,
options => options.IncludeRuleSets("Update")
);
if (!validation.IsValid)
{
var errors = validation
.Errors.GroupBy(e => e.PropertyName)
.ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());
logger.LogWarning(
"PUT /players/squadNumber/{SquadNumber} validation failed: {@Errors}",
squadNumber,
errors
);
return TypedResults.Problem(
new HttpValidationProblemDetails(errors)
{
Status = StatusCodes.Status422UnprocessableEntity,
Detail = "See the errors field for details.",
Instance = HttpContext?.Request?.Path.ToString(),
}
);
}
if (player.SquadNumber != squadNumber)
{
logger.LogWarning(
"PutAsync squad number mismatch: route {SquadNumber} != body {PlayerSquadNumber}",
squadNumber,
player.SquadNumber
);
return TypedResults.Problem(
statusCode: StatusCodes.Status400BadRequest,
title: "Bad Request",
detail: "Squad number in the route does not match squad number in the request body.",
instance: HttpContext?.Request?.Path.ToString()
);
}
if (await playerService.RetrieveBySquadNumberAsync(squadNumber) == null)
{
logger.LogWarning("PUT /players/squadNumber/{SquadNumber} not found", squadNumber);
return TypedResults.Problem(
statusCode: StatusCodes.Status404NotFound,
title: NotFoundTitle,
detail: $"Player with Squad Number '{squadNumber}' was not found.",
instance: HttpContext?.Request?.Path.ToString()
);
}
await playerService.UpdateAsync(player);
// Sanitize user-provided player data before logging to prevent log forging
var sanitizedPlayerString = player
.ToString()
?.Replace(Environment.NewLine, string.Empty)
.Replace("\r", string.Empty)
.Replace("\n", string.Empty);
logger.LogInformation(
"PUT /players/squadNumber/{SquadNumber} updated: {Player}",
squadNumber,
sanitizedPlayerString
);
return TypedResults.NoContent();
}
/* -------------------------------------------------------------------------
* HTTP DELETE
* ---------------------------------------------------------------------- */
/// <summary>
/// Deletes a Player by its Squad Number
/// </summary>
/// <param name="squadNumber">The Squad Number of the Player</param>
/// <response code="204">No Content</response>
/// <response code="404">Not Found</response>
[HttpDelete("squadNumber/{squadNumber:int}", Name = "Delete")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
public async Task<IResult> DeleteAsync([FromRoute] int squadNumber)
{
if (await playerService.RetrieveBySquadNumberAsync(squadNumber) == null)
{
logger.LogWarning("DELETE /players/squadNumber/{SquadNumber} not found", squadNumber);
return TypedResults.Problem(
statusCode: StatusCodes.Status404NotFound,
title: "Not Found",
detail: $"Player with Squad Number '{squadNumber}' was not found.",
instance: HttpContext?.Request?.Path.ToString()
);
}
else
{
await playerService.DeleteAsync(squadNumber);
logger.LogInformation("DELETE /players/squadNumber/{SquadNumber} deleted", squadNumber);
return TypedResults.NoContent();
}
}
}
@@ -0,0 +1,41 @@
using Dotnet.Samples.AspNetCore.WebApi.Models;
using Dotnet.Samples.AspNetCore.WebApi.Utilities;
using Microsoft.EntityFrameworkCore;
namespace Dotnet.Samples.AspNetCore.WebApi.Data;
/// <summary>
/// Represents the EF Core database context for a Player entity.
/// Inherits from <see cref="DbContext"/> and provides a bridge between the entity and the database.
/// </summary>
public class PlayerDbContext(DbContextOptions<PlayerDbContext> options) : DbContext(options)
{
/// <summary>
/// Gets or sets the <see cref="DbSet{TEntity}"/> representing a collection of Player entities.
/// <see cref="DbSet{TEntity}"/> corresponds to a table in the database, allowing CRUD operations and LINQ queries.
/// </summary>
public DbSet<Player> Players => Set<Player>();
/// <summary>
/// Configures the model for the Player entity.
/// This method is called by the runtime to configure the model for the context.
/// </summary>
/// <param name="modelBuilder">The model builder.</param>
/// <remarks>
/// This method is used to configure the model and relationships using the Fluent API.
/// It is called when the model for a derived context is being created.
/// </remarks>
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<Player>(entity =>
{
entity.HasKey(player => player.Id);
entity.Property(player => player.Id).ValueGeneratedOnAdd();
entity.HasIndex(player => player.SquadNumber).IsUnique();
entity.HasData(PlayerData.MakeStarting11WithId());
entity.HasData(PlayerData.GetSubstitutesWithId());
});
}
}
@@ -0,0 +1,38 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
</PropertyGroup>
<ItemGroup Label="Development dependencies">
<PackageReference Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="10.0.2" PrivateAssets="all" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9" PrivateAssets="all" />
</ItemGroup>
<ItemGroup Label="Runtime dependencies">
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.9" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.9" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
<PackageReference Include="AutoMapper" Version="[16.1.1,17.0.0)" />
<PackageReference Include="FluentValidation" Version="12.1.1" />
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="12.1.1" />
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageReference Include="Serilog.Settings.Configuration" Version="10.0.1" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.2.3" />
</ItemGroup>
<PropertyGroup>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);1591</NoWarn>
</PropertyGroup>
<PropertyGroup>
<SatelliteResourceLanguages>en-US</SatelliteResourceLanguages>
</PropertyGroup>
</Project>
@@ -0,0 +1,42 @@
using System.Reflection;
namespace Dotnet.Samples.AspNetCore.WebApi.Enums;
/// <summary>
/// Enumeration class
/// https://learn.microsoft.com/en-us/dotnet/architecture/microservices/microservice-ddd-cqrs-patterns/enumeration-classes-over-enum-types
/// </summary>
public abstract class Enumeration
{
public int Id { get; private set; }
public string Text { get; private set; }
protected Enumeration(int id, string name) => (Id, Text) = (id, name);
public override string ToString() => Text;
public static IEnumerable<T> GetAll<T>()
where T : Enumeration =>
typeof(T)
.GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.DeclaredOnly)
.Select(f => f.GetValue(null))
.Cast<T>();
public override bool Equals(object? obj)
{
if (obj is not Enumeration enumeration)
{
return false;
}
var areSameType = GetType().Equals(obj.GetType());
var areSameValue = Id.Equals(enumeration.Id);
return areSameType && areSameValue;
}
public override int GetHashCode() => Id.GetHashCode();
public int CompareTo(object other) => Id.CompareTo(((Enumeration)other).Id);
}
@@ -0,0 +1,63 @@
namespace Dotnet.Samples.AspNetCore.WebApi.Enums;
public class Position : Enumeration
{
public string Abbr { get; private set; }
public static readonly Position Goalkeeper = new(1, "Goalkeeper", "GK");
public static readonly Position RightBack = new(2, "Right-Back", "RB");
public static readonly Position LeftBack = new(3, "Left-Back", "LB");
public static readonly Position CentreBack = new(4, "Centre-Back", "CB");
public static readonly Position DefensiveMidfield = new(5, "Defensive Midfield", "DM");
public static readonly Position CentralMidfield = new(6, "Central Midfield", "CM");
public static readonly Position RightWinger = new(7, "Right Winger", "RW");
public static readonly Position AttackingMidfield = new(8, "Attacking Midfield", "AM");
public static readonly Position CentreForward = new(9, "Centre-Forward", "CF");
public static readonly Position SecondStriker = new(10, "Second Striker", "SS");
public static readonly Position LeftWinger = new(11, "Left Winger", "LW");
private Position(int id, string name, string abbr)
: base(id, name) => Abbr = abbr;
/// <summary>
/// Returns a Position object based on the abbreviation.
/// </summary>
/// <remarks>
/// This method searches through all the Position objects and returns the one
/// that matches the provided abbreviation. If no match is found, it returns null.
/// </remarks>
/// <param name="abbr">The abbreviation of the Position.</param>
/// <returns>
/// A Position object if found; otherwise, null.
/// </returns>
public static Position? FromAbbr(string abbr) =>
GetAll<Position>().FirstOrDefault(position => position.Abbr == abbr);
/// <summary>
/// Returns a Position object based on the ID.
/// </summary>
/// <remarks>
/// This method searches through all the Position objects and returns the one
/// that matches the provided ID. If no match is found, it returns null.
/// </remarks>
/// <param name="id">The ID of the Position.</param>
/// <returns>
/// A Position object if found; otherwise, null.
/// </returns>
public static Position? FromId(int id) =>
GetAll<Position>().FirstOrDefault(position => position.Id == id);
/// <summary>
/// Checks if the provided abbreviation is valid.
/// </summary>
/// <remarks>
/// This method checks if the provided abbreviation is not null or empty and
/// if it corresponds to a valid Position object.
/// </remarks>
/// <param name="abbr">The abbreviation to check.</param>
/// <returns>
/// True if the abbreviation is valid; otherwise, false.
/// </returns>
public static bool IsValidAbbr(string? abbr) =>
!string.IsNullOrWhiteSpace(abbr) && FromAbbr(abbr!) is not null;
}
@@ -0,0 +1,21 @@
using Dotnet.Samples.AspNetCore.WebApi.Middlewares;
namespace Dotnet.Samples.AspNetCore.WebApi.Extensions;
/// <summary>
/// Extension methods for configuring middleware in the application pipeline.
/// </summary>
public static class MiddlewareExtensions
{
/// <summary>
/// Adds global exception handling middleware to the application pipeline.
/// This middleware catches unhandled exceptions and returns RFC 7807 compliant error responses.
/// </summary>
/// <param name="app">The web application used to configure the HTTP pipeline, and routes.</param>
/// <returns>The WebApplication object for method chaining.</returns>
public static WebApplication UseExceptionHandling(this WebApplication app)
{
app.UseMiddleware<ExceptionMiddleware>();
return app;
}
}
@@ -0,0 +1,255 @@
using System.Threading.RateLimiting;
using Dotnet.Samples.AspNetCore.WebApi.Configurations;
using Dotnet.Samples.AspNetCore.WebApi.Data;
using Dotnet.Samples.AspNetCore.WebApi.Mappings;
using Dotnet.Samples.AspNetCore.WebApi.Migrations;
using Dotnet.Samples.AspNetCore.WebApi.Repositories;
using Dotnet.Samples.AspNetCore.WebApi.Services;
using Dotnet.Samples.AspNetCore.WebApi.Utilities;
using Dotnet.Samples.AspNetCore.WebApi.Validators;
using FluentValidation;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.OpenApi;
using Serilog;
namespace Dotnet.Samples.AspNetCore.WebApi.Extensions;
/// <summary>
/// Extension methods for IServiceCollection to encapsulate service configuration.
/// </summary>
public static partial class ServiceCollectionExtensions
{
/// <summary>
/// Adds PlayerDbContext, selecting the database provider based on the
/// <c>DATABASE_PROVIDER</c> environment variable (<c>sqlite</c> by default, <c>postgres</c>
/// to opt in to PostgreSQL).
/// </summary>
/// <param name="services">The IServiceCollection instance.</param>
/// <param name="environment">The web host environment.</param>
/// <returns>The IServiceCollection for method chaining.</returns>
public static IServiceCollection AddPlayerDbContext(
this IServiceCollection services,
IWebHostEnvironment environment
)
{
services.AddDbContextPool<PlayerDbContext>(options =>
{
var provider = (Environment.GetEnvironmentVariable("DATABASE_PROVIDER") ?? "")
.Trim()
.ToLowerInvariant();
switch (provider)
{
case "postgres":
var connectionString = Environment.GetEnvironmentVariable("DATABASE_URL");
if (string.IsNullOrWhiteSpace(connectionString))
throw new InvalidOperationException(
"DATABASE_URL is required when DATABASE_PROVIDER=postgres."
);
options.UseNpgsql(connectionString, npgsql => npgsql.EnableRetryOnFailure());
// Hand-crafted designer files cannot replicate Npgsql-injected runtime
// annotations (Relational:MaxIdentifierLength, UseIdentityByDefaultColumn),
// causing a false-positive PendingModelChangesWarning. Suppressed here;
// BuildTargetModel is still populated so InsertData SQL generation works.
options.ConfigureWarnings(w =>
w.Ignore(RelationalEventId.PendingModelChangesWarning)
);
break;
case "sqlite":
case "":
var storagePath = Environment.GetEnvironmentVariable("STORAGE_PATH");
var dataSource = !string.IsNullOrWhiteSpace(storagePath)
? storagePath
: Path.Combine(AppContext.BaseDirectory, "storage", "players-sqlite3.db");
var storageDir = Path.GetDirectoryName(dataSource);
if (!string.IsNullOrWhiteSpace(storageDir))
{
Directory.CreateDirectory(storageDir);
}
options.UseSqlite($"Data Source={dataSource}");
break;
default:
throw new InvalidOperationException(
$"Unsupported DATABASE_PROVIDER value: '{provider}'. "
+ "Valid values are 'sqlite' (default) and 'postgres'."
);
}
if (environment.IsDevelopment())
{
options.EnableSensitiveDataLogging();
options.LogTo(Log.Logger.Information, LogLevel.Information);
}
options.ReplaceService<IMigrationsAssembly, ProviderSpecificMigrationsAssembly>();
});
return services;
}
/// <summary>
/// Adds a default CORS policy that allows any origin, method, and header,
/// restricted to the Development environment.
/// <br />
/// <see href="https://learn.microsoft.com/en-us/aspnet/core/security/cors"/>
/// </summary>
/// <remarks>
/// The permissive wildcard policy (AllowAnyOrigin, AllowAnyMethod, AllowAnyHeader)
/// is intentional for local development, where Swagger UI and local frontends
/// need unrestricted cross-origin access. No CORS policy is registered in
/// Production or other environments, where the API is assumed to be consumed
/// server-to-server or to sit behind a reverse proxy on the same origin, making
/// CORS irrelevant. If a production frontend on a different domain is ever added,
/// replace this with a restrictive named policy that enumerates specific allowed
/// origins instead of using a wildcard.
/// </remarks>
/// <param name="services">The IServiceCollection instance.</param>
/// <param name="environment">The web host environment.</param>
/// <returns>The IServiceCollection for method chaining.</returns>
public static IServiceCollection AddCorsDefaultPolicy(
this IServiceCollection services,
IWebHostEnvironment environment
)
{
if (environment.IsDevelopment())
{
services.AddCors(options =>
{
options.AddDefaultPolicy(corsBuilder =>
{
corsBuilder.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader();
});
});
}
// No CORS configured in Production or other environments
return services;
}
/// <summary>
/// Adds FluentValidation validators for Player models.
/// <br />
/// <see href="https://docs.fluentvalidation.net/en/latest/aspnet.html"/>
/// </summary>
/// <param name="services">The IServiceCollection instance.</param>
/// <returns>The IServiceCollection for method chaining.</returns>
public static IServiceCollection AddValidators(this IServiceCollection services)
{
services.AddValidatorsFromAssemblyContaining<PlayerRequestModelValidator>();
return services;
}
/// <summary>
/// Sets up Swagger documentation generation and UI for the API.
/// <br />
/// <see href="https://learn.microsoft.com/en-us/aspnet/core/tutorials/getting-started-with-swashbuckle" />
/// </summary>
/// <param name="services">The IServiceCollection instance.</param>
/// <param name="configuration">The application configuration.</param>
/// <returns>The IServiceCollection for method chaining.</returns>
public static IServiceCollection AddSwaggerConfiguration(
this IServiceCollection services,
IConfiguration configuration
)
{
services.AddSwaggerGen(options =>
{
var openApiInfo = configuration.GetSection("OpenApiInfo").Get<OpenApiInfo>();
options.SwaggerDoc("v1", openApiInfo);
options.IncludeXmlComments(SwaggerUtilities.ConfigureXmlCommentsFilePath());
options.AddSecurityDefinition("Bearer", SwaggerUtilities.ConfigureSecurityDefinition());
options.OperationFilter<AuthorizeCheckOperationFilter>();
});
return services;
}
/// <summary>
/// Registers the PlayerService with the DI container.
/// <br />
/// <see href="https://learn.microsoft.com/en-us/aspnet/core/fundamentals/dependency-injection"/>
/// </summary>
/// <param name="services">The IServiceCollection instance.</param>
/// <returns>The IServiceCollection for method chaining.</returns>
public static IServiceCollection RegisterPlayerService(this IServiceCollection services)
{
services.AddScoped<IPlayerService, PlayerService>();
return services;
}
/// <summary>
/// Adds AutoMapper configuration for Player mappings.
/// <br />
/// <see href="https://docs.automapper.io/en/latest/Dependency-injection.html#asp-net-core"/>
/// </summary>
/// <param name="services">The IServiceCollection instance.</param>
/// <returns>The IServiceCollection for method chaining.</returns>
public static IServiceCollection AddMappings(this IServiceCollection services)
{
services.AddAutoMapper(config => config.AddProfile<PlayerMappingProfile>());
return services;
}
/// <summary>
/// Registers the PlayerRepository service with the DI container.
/// <br />
/// <see href="https://learn.microsoft.com/en-us/aspnet/core/fundamentals/dependency-injection"/>
/// </summary>
/// <param name="services">The IServiceCollection instance.</param>
/// <returns>The IServiceCollection for method chaining.</returns>
public static IServiceCollection RegisterPlayerRepository(this IServiceCollection services)
{
services.AddScoped<IPlayerRepository, PlayerRepository>();
return services;
}
/// <summary>
/// Adds rate limiting configuration with IP-based partitioning.
/// <br />
/// <see href="https://learn.microsoft.com/en-us/aspnet/core/performance/rate-limit"/>
/// </summary>
/// <param name="services">The IServiceCollection instance.</param>
/// <param name="configuration">The application configuration instance.</param>
/// <returns>The IServiceCollection for method chaining.</returns>
public static IServiceCollection AddFixedWindowRateLimiter(
this IServiceCollection services,
IConfiguration configuration
)
{
var settings =
configuration.GetSection("RateLimiter").Get<RateLimiterConfiguration>()
?? new RateLimiterConfiguration();
services.AddRateLimiter(options =>
{
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(
httpContext =>
{
var partitionKey = HttpContextUtilities.ExtractIpAddress(httpContext);
return RateLimitPartition.GetFixedWindowLimiter(
partitionKey: partitionKey,
factory: _ => new FixedWindowRateLimiterOptions
{
PermitLimit = settings.PermitLimit,
Window = TimeSpan.FromSeconds(settings.WindowSeconds),
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
QueueLimit = settings.QueueLimit
}
);
}
);
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
});
return services;
}
}
@@ -0,0 +1,61 @@
using AutoMapper;
using Dotnet.Samples.AspNetCore.WebApi.Enums;
using Dotnet.Samples.AspNetCore.WebApi.Models;
namespace Dotnet.Samples.AspNetCore.WebApi.Mappings;
/// <summary>
/// Mapping profile for Player.
/// </summary>
/// <remarks>
/// This class defines the mapping configuration between PlayerRequestModel and Player,
/// and between Player and PlayerResponseModel.
/// </remarks>
public class PlayerMappingProfile : Profile
{
public PlayerMappingProfile()
{
// PlayerRequestModel → Player
CreateMap<PlayerRequestModel, Player>()
.ForMember(
destination => destination.Position,
options =>
options.MapFrom(source =>
Position.FromAbbr(source.AbbrPosition ?? string.Empty)
)
)
.ForMember(destination => destination.Starting11, options => options.Ignore());
// Player → PlayerResponseModel
CreateMap<Player, PlayerResponseModel>()
.ForSourceMember(source => source.Id, options => options.DoNotValidate())
.ForMember(
destination => destination.FullName,
options =>
options.MapFrom(source =>
$"{source.FirstName} {(string.IsNullOrWhiteSpace(source.MiddleName) ? "" : source.MiddleName + " ")}{source.LastName}".Trim()
)
)
.ForMember(
destination => destination.Birth,
options =>
options.MapFrom(source =>
source.DateOfBirth.HasValue
? $"{source.DateOfBirth.Value:MMMM d, yyyy}"
: null
)
)
.ForMember(
destination => destination.Dorsal,
options => options.MapFrom(source => source.SquadNumber)
)
.ForMember(
destination => destination.Club,
options => options.MapFrom(source => source.Team)
)
.ForMember(
destination => destination.Starting11,
options => options.MapFrom(source => source.Starting11 ? "Yes" : "No")
);
}
}
@@ -0,0 +1,121 @@
using System.Text.Json;
using FluentValidation;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Dotnet.Samples.AspNetCore.WebApi.Middlewares;
/// <summary>
/// Middleware for global exception handling with RFC 7807 Problem Details format.
/// </summary>
public class ExceptionMiddleware(
RequestDelegate next,
ILogger<ExceptionMiddleware> logger,
IHostEnvironment environment
)
{
private const string ProblemDetailsContentType = "application/problem+json";
private static readonly JsonSerializerOptions JsonOptions =
new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
/// <summary>
/// Invokes the middleware to handle exceptions globally.
/// </summary>
public async Task InvokeAsync(HttpContext context)
{
try
{
await next(context);
}
catch (Exception exception)
{
await HandleExceptionAsync(context, exception);
}
}
/// <summary>
/// Handles the exception and returns an RFC 7807 compliant error response.
/// </summary>
private async Task HandleExceptionAsync(HttpContext context, Exception exception)
{
var (status, title) = MapExceptionToStatusCode(exception);
var problemDetails = new ProblemDetails
{
Type = $"https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/{status}",
Title = title,
Status = status,
Detail = GetExceptionDetail(exception),
Instance = context.Request.Path
};
// Add trace ID for request correlation
problemDetails.Extensions["traceId"] = context.TraceIdentifier;
// codeql[cs/log-forging] Serilog structured logging automatically escapes control characters
logger.LogError(
exception,
"Unhandled exception occurred. TraceId: {TraceId}, Path: {Path}, StatusCode: {StatusCode}",
context.TraceIdentifier,
context.Request.Path,
status
);
// Only modify response if headers haven't been sent yet
if (!context.Response.HasStarted)
{
context.Response.StatusCode = status;
context.Response.ContentType = ProblemDetailsContentType;
await context.Response.WriteAsync(
JsonSerializer.Serialize(problemDetails, JsonOptions)
);
}
else
{
logger.LogWarning(
"Unable to write error response for TraceId: {TraceId}. Response has already started.",
context.TraceIdentifier
);
}
}
/// <summary>
/// Maps exception types to appropriate HTTP status codes and titles.
/// </summary>
private static (int StatusCode, string Title) MapExceptionToStatusCode(Exception exception)
{
return exception switch
{
ValidationException => (StatusCodes.Status422UnprocessableEntity, "Validation Error"),
ArgumentException
or ArgumentNullException
=> (StatusCodes.Status400BadRequest, "Bad Request"),
InvalidOperationException => (StatusCodes.Status400BadRequest, "Invalid Operation"),
DbUpdateConcurrencyException => (StatusCodes.Status409Conflict, "Concurrency Conflict"),
OperationCanceledException => (StatusCodes.Status408RequestTimeout, "Request Timeout"),
_ => (StatusCodes.Status500InternalServerError, "Internal Server Error")
};
}
/// <summary>
/// Gets the exception detail based on the environment.
/// In Development: includes full exception details and stack trace.
/// In Production: returns a generic message without sensitive information.
/// </summary>
private string GetExceptionDetail(Exception exception)
{
if (environment.IsDevelopment())
{
return $"{exception.Message}\n\nStack Trace:\n{exception.StackTrace}";
}
return exception switch
{
ValidationException => exception.Message,
ArgumentException => exception.Message,
_ => "An unexpected error occurred while processing your request."
};
}
}
@@ -0,0 +1,69 @@
// <auto-generated />
using System;
using Dotnet.Samples.AspNetCore.WebApi.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace Dotnet.Samples.AspNetCore.WebApi.Migrations
{
[DbContext(typeof(PlayerDbContext))]
[Migration("20260409141647_InitialCreate")]
partial class InitialCreate
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.5");
modelBuilder.Entity("Dotnet.Samples.AspNetCore.WebApi.Models.Player", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("AbbrPosition")
.HasColumnType("TEXT");
b.Property<DateTime?>("DateOfBirth")
.HasColumnType("TEXT");
b.Property<string>("FirstName")
.HasColumnType("TEXT");
b.Property<string>("LastName")
.HasColumnType("TEXT");
b.Property<string>("League")
.HasColumnType("TEXT");
b.Property<string>("MiddleName")
.HasColumnType("TEXT");
b.Property<string>("Position")
.HasColumnType("TEXT");
b.Property<int>("SquadNumber")
.HasColumnType("INTEGER");
b.Property<bool>("Starting11")
.HasColumnType("INTEGER");
b.Property<string>("Team")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("SquadNumber")
.IsUnique();
b.ToTable("Players");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,50 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Dotnet.Samples.AspNetCore.WebApi.Migrations
{
/// <inheritdoc />
public partial class InitialCreate : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Players",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
FirstName = table.Column<string>(type: "TEXT", nullable: true),
MiddleName = table.Column<string>(type: "TEXT", nullable: true),
LastName = table.Column<string>(type: "TEXT", nullable: true),
DateOfBirth = table.Column<DateTime>(type: "TEXT", nullable: true),
SquadNumber = table.Column<int>(type: "INTEGER", nullable: false),
Position = table.Column<string>(type: "TEXT", nullable: true),
AbbrPosition = table.Column<string>(type: "TEXT", nullable: true),
Team = table.Column<string>(type: "TEXT", nullable: true),
League = table.Column<string>(type: "TEXT", nullable: true),
Starting11 = table.Column<bool>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Players", x => x.Id);
}
);
migrationBuilder.CreateIndex(
name: "IX_Players_SquadNumber",
table: "Players",
column: "SquadNumber",
unique: true
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(name: "Players");
}
}
}
@@ -0,0 +1,222 @@
// <auto-generated />
using System;
using Dotnet.Samples.AspNetCore.WebApi.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace Dotnet.Samples.AspNetCore.WebApi.Migrations
{
[DbContext(typeof(PlayerDbContext))]
[Migration("20260409141707_SeedStarting11")]
partial class SeedStarting11
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.5");
modelBuilder.Entity("Dotnet.Samples.AspNetCore.WebApi.Models.Player", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("AbbrPosition")
.HasColumnType("TEXT");
b.Property<DateTime?>("DateOfBirth")
.HasColumnType("TEXT");
b.Property<string>("FirstName")
.HasColumnType("TEXT");
b.Property<string>("LastName")
.HasColumnType("TEXT");
b.Property<string>("League")
.HasColumnType("TEXT");
b.Property<string>("MiddleName")
.HasColumnType("TEXT");
b.Property<string>("Position")
.HasColumnType("TEXT");
b.Property<int>("SquadNumber")
.HasColumnType("INTEGER");
b.Property<bool>("Starting11")
.HasColumnType("INTEGER");
b.Property<string>("Team")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("SquadNumber")
.IsUnique();
b.ToTable("Players");
b.HasData(
new
{
Id = new Guid("01772c59-43f0-5d85-b913-c78e4e281452"),
AbbrPosition = "GK",
DateOfBirth = new DateTime(1992, 9, 1, 0, 0, 0, 0, DateTimeKind.Utc),
FirstName = "Damián",
LastName = "Martínez",
League = "Premier League",
MiddleName = "Emiliano",
Position = "Goalkeeper",
SquadNumber = 23,
Starting11 = true,
Team = "Aston Villa FC"
},
new
{
Id = new Guid("da31293b-4c7e-5e0f-a168-469ee29ecbc4"),
AbbrPosition = "RB",
DateOfBirth = new DateTime(1998, 4, 5, 0, 0, 0, 0, DateTimeKind.Utc),
FirstName = "Nahuel",
LastName = "Molina",
League = "La Liga",
Position = "Right-Back",
SquadNumber = 26,
Starting11 = true,
Team = "Altético Madrid"
},
new
{
Id = new Guid("c096c69e-762b-5281-9290-bb9c167a24a0"),
AbbrPosition = "CB",
DateOfBirth = new DateTime(1998, 4, 26, 0, 0, 0, 0, DateTimeKind.Utc),
FirstName = "Cristian",
LastName = "Romero",
League = "Premier League",
MiddleName = "Gabriel",
Position = "Centre-Back",
SquadNumber = 13,
Starting11 = true,
Team = "Tottenham Hotspur"
},
new
{
Id = new Guid("d5f7dd7a-1dcb-5960-ba27-e34865b63358"),
AbbrPosition = "CB",
DateOfBirth = new DateTime(1988, 2, 11, 0, 0, 0, 0, DateTimeKind.Utc),
FirstName = "Nicolás",
LastName = "Otamendi",
League = "Liga Portugal",
MiddleName = "Hernán Gonzalo",
Position = "Centre-Back",
SquadNumber = 19,
Starting11 = true,
Team = "SL Benfica"
},
new
{
Id = new Guid("2f6f90a0-9b9d-5023-96d2-a2aaf03143a6"),
AbbrPosition = "LB",
DateOfBirth = new DateTime(1992, 8, 30, 0, 0, 0, 0, DateTimeKind.Utc),
FirstName = "Nicolás",
LastName = "Tagliafico",
League = "Ligue 1",
MiddleName = "Alejandro",
Position = "Left-Back",
SquadNumber = 3,
Starting11 = true,
Team = "Olympique Lyon"
},
new
{
Id = new Guid("b5b46e79-929e-5ed2-949d-0d167109c022"),
AbbrPosition = "RW",
DateOfBirth = new DateTime(1988, 2, 13, 0, 0, 0, 0, DateTimeKind.Utc),
FirstName = "Ángel",
LastName = "Di María",
League = "Liga Portugal",
MiddleName = "Fabián",
Position = "Right Winger",
SquadNumber = 11,
Starting11 = true,
Team = "SL Benfica"
},
new
{
Id = new Guid("0293b282-1da8-562e-998e-83849b417a42"),
AbbrPosition = "CM",
DateOfBirth = new DateTime(1994, 5, 23, 0, 0, 0, 0, DateTimeKind.Utc),
FirstName = "Rodrigo",
LastName = "de Paul",
League = "La Liga",
MiddleName = "Javier",
Position = "Central Midfield",
SquadNumber = 7,
Starting11 = true,
Team = "Altético Madrid"
},
new
{
Id = new Guid("d3ba552a-dac3-588a-b961-1ea7224017fd"),
AbbrPosition = "CM",
DateOfBirth = new DateTime(2001, 1, 16, 0, 0, 0, 0, DateTimeKind.Utc),
FirstName = "Enzo",
LastName = "Fernández",
League = "Liga Portugal",
MiddleName = "Jeremías",
Position = "Central Midfield",
SquadNumber = 24,
Starting11 = true,
Team = "SL Benfica"
},
new
{
Id = new Guid("9613cae9-16ab-5b54-937e-3135123b9e0d"),
AbbrPosition = "CM",
DateOfBirth = new DateTime(1998, 12, 23, 0, 0, 0, 0, DateTimeKind.Utc),
FirstName = "Alexis",
LastName = "Mac Allister",
League = "Premier League",
Position = "Central Midfield",
SquadNumber = 20,
Starting11 = true,
Team = "Brighton & Hove Albion"
},
new
{
Id = new Guid("acc433bf-d505-51fe-831e-45eb44c4d43c"),
AbbrPosition = "RW",
DateOfBirth = new DateTime(1987, 6, 23, 0, 0, 0, 0, DateTimeKind.Utc),
FirstName = "Lionel",
LastName = "Messi",
League = "Ligue 1",
MiddleName = "Andrés",
Position = "Right Winger",
SquadNumber = 10,
Starting11 = true,
Team = "Paris Saint-Germain"
},
new
{
Id = new Guid("38bae91d-8519-55a2-b30a-b9fe38849bfb"),
AbbrPosition = "CF",
DateOfBirth = new DateTime(2000, 1, 30, 0, 0, 0, 0, DateTimeKind.Utc),
FirstName = "Julián",
LastName = "Álvarez",
League = "Premier League",
Position = "Centre-Forward",
SquadNumber = 9,
Starting11 = true,
Team = "Manchester City"
});
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,251 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
namespace Dotnet.Samples.AspNetCore.WebApi.Migrations
{
/// <inheritdoc />
public partial class SeedStarting11 : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.InsertData(
table: "Players",
columns: new[]
{
"Id",
"AbbrPosition",
"DateOfBirth",
"FirstName",
"LastName",
"League",
"MiddleName",
"Position",
"SquadNumber",
"Starting11",
"Team"
},
values: new object[,]
{
{
new Guid("01772c59-43f0-5d85-b913-c78e4e281452"),
"GK",
new DateTime(1992, 9, 1, 0, 0, 0, 0, DateTimeKind.Utc),
"Damián",
"Martínez",
"Premier League",
"Emiliano",
"Goalkeeper",
23,
true,
"Aston Villa FC"
},
{
new Guid("0293b282-1da8-562e-998e-83849b417a42"),
"CM",
new DateTime(1994, 5, 23, 0, 0, 0, 0, DateTimeKind.Utc),
"Rodrigo",
"de Paul",
"La Liga",
"Javier",
"Central Midfield",
7,
true,
"Altético Madrid"
},
{
new Guid("2f6f90a0-9b9d-5023-96d2-a2aaf03143a6"),
"LB",
new DateTime(1992, 8, 30, 0, 0, 0, 0, DateTimeKind.Utc),
"Nicolás",
"Tagliafico",
"Ligue 1",
"Alejandro",
"Left-Back",
3,
true,
"Olympique Lyon"
},
{
new Guid("38bae91d-8519-55a2-b30a-b9fe38849bfb"),
"CF",
new DateTime(2000, 1, 30, 0, 0, 0, 0, DateTimeKind.Utc),
"Julián",
"Álvarez",
"Premier League",
null,
"Centre-Forward",
9,
true,
"Manchester City"
},
{
new Guid("9613cae9-16ab-5b54-937e-3135123b9e0d"),
"CM",
new DateTime(1998, 12, 23, 0, 0, 0, 0, DateTimeKind.Utc),
"Alexis",
"Mac Allister",
"Premier League",
null,
"Central Midfield",
20,
true,
"Brighton & Hove Albion"
},
{
new Guid("acc433bf-d505-51fe-831e-45eb44c4d43c"),
"RW",
new DateTime(1987, 6, 23, 0, 0, 0, 0, DateTimeKind.Utc),
"Lionel",
"Messi",
"Ligue 1",
"Andrés",
"Right Winger",
10,
true,
"Paris Saint-Germain"
},
{
new Guid("b5b46e79-929e-5ed2-949d-0d167109c022"),
"RW",
new DateTime(1988, 2, 13, 0, 0, 0, 0, DateTimeKind.Utc),
"Ángel",
"Di María",
"Liga Portugal",
"Fabián",
"Right Winger",
11,
true,
"SL Benfica"
},
{
new Guid("c096c69e-762b-5281-9290-bb9c167a24a0"),
"CB",
new DateTime(1998, 4, 26, 0, 0, 0, 0, DateTimeKind.Utc),
"Cristian",
"Romero",
"Premier League",
"Gabriel",
"Centre-Back",
13,
true,
"Tottenham Hotspur"
},
{
new Guid("d3ba552a-dac3-588a-b961-1ea7224017fd"),
"CM",
new DateTime(2001, 1, 16, 0, 0, 0, 0, DateTimeKind.Utc),
"Enzo",
"Fernández",
"Liga Portugal",
"Jeremías",
"Central Midfield",
24,
true,
"SL Benfica"
},
{
new Guid("d5f7dd7a-1dcb-5960-ba27-e34865b63358"),
"CB",
new DateTime(1988, 2, 11, 0, 0, 0, 0, DateTimeKind.Utc),
"Nicolás",
"Otamendi",
"Liga Portugal",
"Hernán Gonzalo",
"Centre-Back",
19,
true,
"SL Benfica"
},
{
new Guid("da31293b-4c7e-5e0f-a168-469ee29ecbc4"),
"RB",
new DateTime(1998, 4, 5, 0, 0, 0, 0, DateTimeKind.Utc),
"Nahuel",
"Molina",
"La Liga",
null,
"Right-Back",
26,
true,
"Altético Madrid"
}
}
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DeleteData(
table: "Players",
keyColumn: "Id",
keyValue: new Guid("01772c59-43f0-5d85-b913-c78e4e281452")
);
migrationBuilder.DeleteData(
table: "Players",
keyColumn: "Id",
keyValue: new Guid("0293b282-1da8-562e-998e-83849b417a42")
);
migrationBuilder.DeleteData(
table: "Players",
keyColumn: "Id",
keyValue: new Guid("2f6f90a0-9b9d-5023-96d2-a2aaf03143a6")
);
migrationBuilder.DeleteData(
table: "Players",
keyColumn: "Id",
keyValue: new Guid("38bae91d-8519-55a2-b30a-b9fe38849bfb")
);
migrationBuilder.DeleteData(
table: "Players",
keyColumn: "Id",
keyValue: new Guid("9613cae9-16ab-5b54-937e-3135123b9e0d")
);
migrationBuilder.DeleteData(
table: "Players",
keyColumn: "Id",
keyValue: new Guid("acc433bf-d505-51fe-831e-45eb44c4d43c")
);
migrationBuilder.DeleteData(
table: "Players",
keyColumn: "Id",
keyValue: new Guid("b5b46e79-929e-5ed2-949d-0d167109c022")
);
migrationBuilder.DeleteData(
table: "Players",
keyColumn: "Id",
keyValue: new Guid("c096c69e-762b-5281-9290-bb9c167a24a0")
);
migrationBuilder.DeleteData(
table: "Players",
keyColumn: "Id",
keyValue: new Guid("d3ba552a-dac3-588a-b961-1ea7224017fd")
);
migrationBuilder.DeleteData(
table: "Players",
keyColumn: "Id",
keyValue: new Guid("d5f7dd7a-1dcb-5960-ba27-e34865b63358")
);
migrationBuilder.DeleteData(
table: "Players",
keyColumn: "Id",
keyValue: new Guid("da31293b-4c7e-5e0f-a168-469ee29ecbc4")
);
}
}
}
@@ -0,0 +1,429 @@
// <auto-generated />
using System;
using Dotnet.Samples.AspNetCore.WebApi.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace Dotnet.Samples.AspNetCore.WebApi.Migrations
{
[DbContext(typeof(PlayerDbContext))]
[Migration("20260409141721_SeedSubstitutes")]
partial class SeedSubstitutes
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.5");
modelBuilder.Entity("Dotnet.Samples.AspNetCore.WebApi.Models.Player", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("AbbrPosition")
.HasColumnType("TEXT");
b.Property<DateTime?>("DateOfBirth")
.HasColumnType("TEXT");
b.Property<string>("FirstName")
.HasColumnType("TEXT");
b.Property<string>("LastName")
.HasColumnType("TEXT");
b.Property<string>("League")
.HasColumnType("TEXT");
b.Property<string>("MiddleName")
.HasColumnType("TEXT");
b.Property<string>("Position")
.HasColumnType("TEXT");
b.Property<int>("SquadNumber")
.HasColumnType("INTEGER");
b.Property<bool>("Starting11")
.HasColumnType("INTEGER");
b.Property<string>("Team")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("SquadNumber")
.IsUnique();
b.ToTable("Players");
b.HasData(
new
{
Id = new Guid("01772c59-43f0-5d85-b913-c78e4e281452"),
AbbrPosition = "GK",
DateOfBirth = new DateTime(1992, 9, 1, 0, 0, 0, 0, DateTimeKind.Utc),
FirstName = "Damián",
LastName = "Martínez",
League = "Premier League",
MiddleName = "Emiliano",
Position = "Goalkeeper",
SquadNumber = 23,
Starting11 = true,
Team = "Aston Villa FC"
},
new
{
Id = new Guid("da31293b-4c7e-5e0f-a168-469ee29ecbc4"),
AbbrPosition = "RB",
DateOfBirth = new DateTime(1998, 4, 5, 0, 0, 0, 0, DateTimeKind.Utc),
FirstName = "Nahuel",
LastName = "Molina",
League = "La Liga",
Position = "Right-Back",
SquadNumber = 26,
Starting11 = true,
Team = "Altético Madrid"
},
new
{
Id = new Guid("c096c69e-762b-5281-9290-bb9c167a24a0"),
AbbrPosition = "CB",
DateOfBirth = new DateTime(1998, 4, 26, 0, 0, 0, 0, DateTimeKind.Utc),
FirstName = "Cristian",
LastName = "Romero",
League = "Premier League",
MiddleName = "Gabriel",
Position = "Centre-Back",
SquadNumber = 13,
Starting11 = true,
Team = "Tottenham Hotspur"
},
new
{
Id = new Guid("d5f7dd7a-1dcb-5960-ba27-e34865b63358"),
AbbrPosition = "CB",
DateOfBirth = new DateTime(1988, 2, 11, 0, 0, 0, 0, DateTimeKind.Utc),
FirstName = "Nicolás",
LastName = "Otamendi",
League = "Liga Portugal",
MiddleName = "Hernán Gonzalo",
Position = "Centre-Back",
SquadNumber = 19,
Starting11 = true,
Team = "SL Benfica"
},
new
{
Id = new Guid("2f6f90a0-9b9d-5023-96d2-a2aaf03143a6"),
AbbrPosition = "LB",
DateOfBirth = new DateTime(1992, 8, 30, 0, 0, 0, 0, DateTimeKind.Utc),
FirstName = "Nicolás",
LastName = "Tagliafico",
League = "Ligue 1",
MiddleName = "Alejandro",
Position = "Left-Back",
SquadNumber = 3,
Starting11 = true,
Team = "Olympique Lyon"
},
new
{
Id = new Guid("b5b46e79-929e-5ed2-949d-0d167109c022"),
AbbrPosition = "RW",
DateOfBirth = new DateTime(1988, 2, 13, 0, 0, 0, 0, DateTimeKind.Utc),
FirstName = "Ángel",
LastName = "Di María",
League = "Liga Portugal",
MiddleName = "Fabián",
Position = "Right Winger",
SquadNumber = 11,
Starting11 = true,
Team = "SL Benfica"
},
new
{
Id = new Guid("0293b282-1da8-562e-998e-83849b417a42"),
AbbrPosition = "CM",
DateOfBirth = new DateTime(1994, 5, 23, 0, 0, 0, 0, DateTimeKind.Utc),
FirstName = "Rodrigo",
LastName = "de Paul",
League = "La Liga",
MiddleName = "Javier",
Position = "Central Midfield",
SquadNumber = 7,
Starting11 = true,
Team = "Altético Madrid"
},
new
{
Id = new Guid("d3ba552a-dac3-588a-b961-1ea7224017fd"),
AbbrPosition = "CM",
DateOfBirth = new DateTime(2001, 1, 16, 0, 0, 0, 0, DateTimeKind.Utc),
FirstName = "Enzo",
LastName = "Fernández",
League = "Liga Portugal",
MiddleName = "Jeremías",
Position = "Central Midfield",
SquadNumber = 24,
Starting11 = true,
Team = "SL Benfica"
},
new
{
Id = new Guid("9613cae9-16ab-5b54-937e-3135123b9e0d"),
AbbrPosition = "CM",
DateOfBirth = new DateTime(1998, 12, 23, 0, 0, 0, 0, DateTimeKind.Utc),
FirstName = "Alexis",
LastName = "Mac Allister",
League = "Premier League",
Position = "Central Midfield",
SquadNumber = 20,
Starting11 = true,
Team = "Brighton & Hove Albion"
},
new
{
Id = new Guid("acc433bf-d505-51fe-831e-45eb44c4d43c"),
AbbrPosition = "RW",
DateOfBirth = new DateTime(1987, 6, 23, 0, 0, 0, 0, DateTimeKind.Utc),
FirstName = "Lionel",
LastName = "Messi",
League = "Ligue 1",
MiddleName = "Andrés",
Position = "Right Winger",
SquadNumber = 10,
Starting11 = true,
Team = "Paris Saint-Germain"
},
new
{
Id = new Guid("38bae91d-8519-55a2-b30a-b9fe38849bfb"),
AbbrPosition = "CF",
DateOfBirth = new DateTime(2000, 1, 30, 0, 0, 0, 0, DateTimeKind.Utc),
FirstName = "Julián",
LastName = "Álvarez",
League = "Premier League",
Position = "Centre-Forward",
SquadNumber = 9,
Starting11 = true,
Team = "Manchester City"
},
new
{
Id = new Guid("5a9cd988-95e6-54c1-bc34-9aa08acca8d0"),
AbbrPosition = "GK",
DateOfBirth = new DateTime(1986, 10, 16, 0, 0, 0, 0, DateTimeKind.Utc),
FirstName = "Franco",
LastName = "Armani",
League = "Copa de la Liga",
MiddleName = "Daniel",
Position = "Goalkeeper",
SquadNumber = 1,
Starting11 = false,
Team = "River Plate"
},
new
{
Id = new Guid("c62f2ac1-41e8-5d34-b073-2ba0913d0e31"),
AbbrPosition = "GK",
DateOfBirth = new DateTime(1992, 5, 20, 0, 0, 0, 0, DateTimeKind.Utc),
FirstName = "Gerónimo",
LastName = "Rulli",
League = "Eredivisie",
Position = "Goalkeeper",
SquadNumber = 12,
Starting11 = false,
Team = "Ajax Amsterdam"
},
new
{
Id = new Guid("5fdb10e8-38c0-5084-9a3f-b369a960b9c2"),
AbbrPosition = "RB",
DateOfBirth = new DateTime(1998, 1, 12, 0, 0, 0, 0, DateTimeKind.Utc),
FirstName = "Juan",
LastName = "Foyth",
League = "La Liga",
MiddleName = "Marcos",
Position = "Right-Back",
SquadNumber = 2,
Starting11 = false,
Team = "Villarreal"
},
new
{
Id = new Guid("bbd441f7-fcfb-5834-8468-2a9004b64c8c"),
AbbrPosition = "RB",
DateOfBirth = new DateTime(1997, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc),
FirstName = "Gonzalo",
LastName = "Montiel",
League = "Premier League",
MiddleName = "Ariel",
Position = "Right-Back",
SquadNumber = 4,
Starting11 = false,
Team = "Nottingham Forest"
},
new
{
Id = new Guid("d8bfea25-f189-5d5e-b3a5-ed89329b9f7c"),
AbbrPosition = "CB",
DateOfBirth = new DateTime(1991, 6, 27, 0, 0, 0, 0, DateTimeKind.Utc),
FirstName = "Germán",
LastName = "Pezzella",
League = "La Liga",
MiddleName = "Alejo",
Position = "Centre-Back",
SquadNumber = 6,
Starting11 = false,
Team = "Real Betis Balompié"
},
new
{
Id = new Guid("dca343a8-12e5-53d6-89a8-916b120a5ee4"),
AbbrPosition = "LB",
DateOfBirth = new DateTime(1991, 10, 28, 0, 0, 0, 0, DateTimeKind.Utc),
FirstName = "Marcos",
LastName = "Acuña",
League = "La Liga",
MiddleName = "Javier",
Position = "Left-Back",
SquadNumber = 8,
Starting11 = false,
Team = "Sevilla FC"
},
new
{
Id = new Guid("98306555-a466-5d18-804e-dc82175e697b"),
AbbrPosition = "CB",
DateOfBirth = new DateTime(1998, 1, 18, 0, 0, 0, 0, DateTimeKind.Utc),
FirstName = "Lisandro",
LastName = "Martínez",
League = "Premier League",
Position = "Centre-Back",
SquadNumber = 25,
Starting11 = false,
Team = "Manchester United"
},
new
{
Id = new Guid("9d140400-196f-55d8-86e1-e0b96a375c83"),
AbbrPosition = "DM",
DateOfBirth = new DateTime(1994, 6, 29, 0, 0, 0, 0, DateTimeKind.Utc),
FirstName = "Leandro",
LastName = "Paredes",
League = "Serie A",
MiddleName = "Daniel",
Position = "Defensive Midfield",
SquadNumber = 5,
Starting11 = false,
Team = "AS Roma"
},
new
{
Id = new Guid("d3b0e8e8-2c34-531a-b608-b24fed0ef986"),
AbbrPosition = "CM",
DateOfBirth = new DateTime(1998, 10, 5, 0, 0, 0, 0, DateTimeKind.Utc),
FirstName = "Exequiel",
LastName = "Palacios",
League = "Bundesliga",
MiddleName = "Alejandro",
Position = "Central Midfield",
SquadNumber = 14,
Starting11 = false,
Team = "Bayer 04 Leverkusen"
},
new
{
Id = new Guid("7cc8d527-56a2-58bd-9528-2618fc139d30"),
AbbrPosition = "LW",
DateOfBirth = new DateTime(1988, 2, 15, 0, 0, 0, 0, DateTimeKind.Utc),
FirstName = "Alejandro",
LastName = "Gómez",
League = "Serie A",
MiddleName = "Darío",
Position = "Left Winger",
SquadNumber = 17,
Starting11 = false,
Team = "AC Monza"
},
new
{
Id = new Guid("191c82af-0c51-526a-b903-c3600b61b506"),
AbbrPosition = "DM",
DateOfBirth = new DateTime(1994, 4, 12, 0, 0, 0, 0, DateTimeKind.Utc),
FirstName = "Guido",
LastName = "Rodríguez",
League = "La Liga",
Position = "Defensive Midfield",
SquadNumber = 18,
Starting11 = false,
Team = "Real Betis Balompié"
},
new
{
Id = new Guid("b1306b7b-a3a4-5f7c-90fd-dd5bdbed57ba"),
AbbrPosition = "RW",
DateOfBirth = new DateTime(1995, 3, 9, 0, 0, 0, 0, DateTimeKind.Utc),
FirstName = "Ángel",
LastName = "Correa",
League = "La Liga",
MiddleName = "Martín",
Position = "Right Winger",
SquadNumber = 15,
Starting11 = false,
Team = "Atlético Madrid"
},
new
{
Id = new Guid("ecec27e8-487b-5622-b116-0855020477ed"),
AbbrPosition = "AM",
DateOfBirth = new DateTime(2001, 4, 26, 0, 0, 0, 0, DateTimeKind.Utc),
FirstName = "Thiago",
LastName = "Almada",
League = "Major League Soccer",
MiddleName = "Ezequiel",
Position = "Attacking Midfield",
SquadNumber = 16,
Starting11 = false,
Team = "Atlanta United FC"
},
new
{
Id = new Guid("7941cd7c-4df1-5952-97e8-1e7f5d08e8aa"),
AbbrPosition = "SS",
DateOfBirth = new DateTime(1993, 11, 15, 0, 0, 0, 0, DateTimeKind.Utc),
FirstName = "Paulo",
LastName = "Dybala",
League = "Serie A",
MiddleName = "Exequiel",
Position = "Second Striker",
SquadNumber = 21,
Starting11 = false,
Team = "AS Roma"
},
new
{
Id = new Guid("79c96f29-c59f-5f98-96b8-3a5946246624"),
AbbrPosition = "CF",
DateOfBirth = new DateTime(1997, 8, 22, 0, 0, 0, 0, DateTimeKind.Utc),
FirstName = "Lautaro",
LastName = "Martínez",
League = "Serie A",
MiddleName = "Javier",
Position = "Centre-Forward",
SquadNumber = 22,
Starting11 = false,
Team = "Inter Milan"
});
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,327 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
namespace Dotnet.Samples.AspNetCore.WebApi.Migrations
{
/// <inheritdoc />
public partial class SeedSubstitutes : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.InsertData(
table: "Players",
columns: new[]
{
"Id",
"AbbrPosition",
"DateOfBirth",
"FirstName",
"LastName",
"League",
"MiddleName",
"Position",
"SquadNumber",
"Starting11",
"Team"
},
values: new object[,]
{
{
new Guid("191c82af-0c51-526a-b903-c3600b61b506"),
"DM",
new DateTime(1994, 4, 12, 0, 0, 0, 0, DateTimeKind.Utc),
"Guido",
"Rodríguez",
"La Liga",
null,
"Defensive Midfield",
18,
false,
"Real Betis Balompié"
},
{
new Guid("5a9cd988-95e6-54c1-bc34-9aa08acca8d0"),
"GK",
new DateTime(1986, 10, 16, 0, 0, 0, 0, DateTimeKind.Utc),
"Franco",
"Armani",
"Copa de la Liga",
"Daniel",
"Goalkeeper",
1,
false,
"River Plate"
},
{
new Guid("5fdb10e8-38c0-5084-9a3f-b369a960b9c2"),
"RB",
new DateTime(1998, 1, 12, 0, 0, 0, 0, DateTimeKind.Utc),
"Juan",
"Foyth",
"La Liga",
"Marcos",
"Right-Back",
2,
false,
"Villarreal"
},
{
new Guid("7941cd7c-4df1-5952-97e8-1e7f5d08e8aa"),
"SS",
new DateTime(1993, 11, 15, 0, 0, 0, 0, DateTimeKind.Utc),
"Paulo",
"Dybala",
"Serie A",
"Exequiel",
"Second Striker",
21,
false,
"AS Roma"
},
{
new Guid("79c96f29-c59f-5f98-96b8-3a5946246624"),
"CF",
new DateTime(1997, 8, 22, 0, 0, 0, 0, DateTimeKind.Utc),
"Lautaro",
"Martínez",
"Serie A",
"Javier",
"Centre-Forward",
22,
false,
"Inter Milan"
},
{
new Guid("7cc8d527-56a2-58bd-9528-2618fc139d30"),
"LW",
new DateTime(1988, 2, 15, 0, 0, 0, 0, DateTimeKind.Utc),
"Alejandro",
"Gómez",
"Serie A",
"Darío",
"Left Winger",
17,
false,
"AC Monza"
},
{
new Guid("98306555-a466-5d18-804e-dc82175e697b"),
"CB",
new DateTime(1998, 1, 18, 0, 0, 0, 0, DateTimeKind.Utc),
"Lisandro",
"Martínez",
"Premier League",
null,
"Centre-Back",
25,
false,
"Manchester United"
},
{
new Guid("9d140400-196f-55d8-86e1-e0b96a375c83"),
"DM",
new DateTime(1994, 6, 29, 0, 0, 0, 0, DateTimeKind.Utc),
"Leandro",
"Paredes",
"Serie A",
"Daniel",
"Defensive Midfield",
5,
false,
"AS Roma"
},
{
new Guid("b1306b7b-a3a4-5f7c-90fd-dd5bdbed57ba"),
"RW",
new DateTime(1995, 3, 9, 0, 0, 0, 0, DateTimeKind.Utc),
"Ángel",
"Correa",
"La Liga",
"Martín",
"Right Winger",
15,
false,
"Atlético Madrid"
},
{
new Guid("bbd441f7-fcfb-5834-8468-2a9004b64c8c"),
"RB",
new DateTime(1997, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc),
"Gonzalo",
"Montiel",
"Premier League",
"Ariel",
"Right-Back",
4,
false,
"Nottingham Forest"
},
{
new Guid("c62f2ac1-41e8-5d34-b073-2ba0913d0e31"),
"GK",
new DateTime(1992, 5, 20, 0, 0, 0, 0, DateTimeKind.Utc),
"Gerónimo",
"Rulli",
"Eredivisie",
null,
"Goalkeeper",
12,
false,
"Ajax Amsterdam"
},
{
new Guid("d3b0e8e8-2c34-531a-b608-b24fed0ef986"),
"CM",
new DateTime(1998, 10, 5, 0, 0, 0, 0, DateTimeKind.Utc),
"Exequiel",
"Palacios",
"Bundesliga",
"Alejandro",
"Central Midfield",
14,
false,
"Bayer 04 Leverkusen"
},
{
new Guid("d8bfea25-f189-5d5e-b3a5-ed89329b9f7c"),
"CB",
new DateTime(1991, 6, 27, 0, 0, 0, 0, DateTimeKind.Utc),
"Germán",
"Pezzella",
"La Liga",
"Alejo",
"Centre-Back",
6,
false,
"Real Betis Balompié"
},
{
new Guid("dca343a8-12e5-53d6-89a8-916b120a5ee4"),
"LB",
new DateTime(1991, 10, 28, 0, 0, 0, 0, DateTimeKind.Utc),
"Marcos",
"Acuña",
"La Liga",
"Javier",
"Left-Back",
8,
false,
"Sevilla FC"
},
{
new Guid("ecec27e8-487b-5622-b116-0855020477ed"),
"AM",
new DateTime(2001, 4, 26, 0, 0, 0, 0, DateTimeKind.Utc),
"Thiago",
"Almada",
"Major League Soccer",
"Ezequiel",
"Attacking Midfield",
16,
false,
"Atlanta United FC"
}
}
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DeleteData(
table: "Players",
keyColumn: "Id",
keyValue: new Guid("191c82af-0c51-526a-b903-c3600b61b506")
);
migrationBuilder.DeleteData(
table: "Players",
keyColumn: "Id",
keyValue: new Guid("5a9cd988-95e6-54c1-bc34-9aa08acca8d0")
);
migrationBuilder.DeleteData(
table: "Players",
keyColumn: "Id",
keyValue: new Guid("5fdb10e8-38c0-5084-9a3f-b369a960b9c2")
);
migrationBuilder.DeleteData(
table: "Players",
keyColumn: "Id",
keyValue: new Guid("7941cd7c-4df1-5952-97e8-1e7f5d08e8aa")
);
migrationBuilder.DeleteData(
table: "Players",
keyColumn: "Id",
keyValue: new Guid("79c96f29-c59f-5f98-96b8-3a5946246624")
);
migrationBuilder.DeleteData(
table: "Players",
keyColumn: "Id",
keyValue: new Guid("7cc8d527-56a2-58bd-9528-2618fc139d30")
);
migrationBuilder.DeleteData(
table: "Players",
keyColumn: "Id",
keyValue: new Guid("98306555-a466-5d18-804e-dc82175e697b")
);
migrationBuilder.DeleteData(
table: "Players",
keyColumn: "Id",
keyValue: new Guid("9d140400-196f-55d8-86e1-e0b96a375c83")
);
migrationBuilder.DeleteData(
table: "Players",
keyColumn: "Id",
keyValue: new Guid("b1306b7b-a3a4-5f7c-90fd-dd5bdbed57ba")
);
migrationBuilder.DeleteData(
table: "Players",
keyColumn: "Id",
keyValue: new Guid("bbd441f7-fcfb-5834-8468-2a9004b64c8c")
);
migrationBuilder.DeleteData(
table: "Players",
keyColumn: "Id",
keyValue: new Guid("c62f2ac1-41e8-5d34-b073-2ba0913d0e31")
);
migrationBuilder.DeleteData(
table: "Players",
keyColumn: "Id",
keyValue: new Guid("d3b0e8e8-2c34-531a-b608-b24fed0ef986")
);
migrationBuilder.DeleteData(
table: "Players",
keyColumn: "Id",
keyValue: new Guid("d8bfea25-f189-5d5e-b3a5-ed89329b9f7c")
);
migrationBuilder.DeleteData(
table: "Players",
keyColumn: "Id",
keyValue: new Guid("dca343a8-12e5-53d6-89a8-916b120a5ee4")
);
migrationBuilder.DeleteData(
table: "Players",
keyColumn: "Id",
keyValue: new Guid("ecec27e8-487b-5622-b116-0855020477ed")
);
}
}
}
@@ -0,0 +1,68 @@
// <auto-generated />
using System;
using Dotnet.Samples.AspNetCore.WebApi.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Dotnet.Samples.AspNetCore.WebApi.Migrations.Npgsql
{
[DbContext(typeof(PlayerDbContext))]
[Migration("20260409151000_InitialCreate")]
partial class InitialCreate
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.7");
modelBuilder.Entity("Dotnet.Samples.AspNetCore.WebApi.Models.Player", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("AbbrPosition")
.HasColumnType("text");
b.Property<DateTime?>("DateOfBirth")
.HasColumnType("timestamp with time zone");
b.Property<string>("FirstName")
.HasColumnType("text");
b.Property<string>("LastName")
.HasColumnType("text");
b.Property<string>("League")
.HasColumnType("text");
b.Property<string>("MiddleName")
.HasColumnType("text");
b.Property<string>("Position")
.HasColumnType("text");
b.Property<int>("SquadNumber")
.HasColumnType("integer");
b.Property<bool>("Starting11")
.HasColumnType("boolean");
b.Property<string>("Team")
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("SquadNumber")
.IsUnique();
b.ToTable("Players");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,53 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Dotnet.Samples.AspNetCore.WebApi.Migrations.Npgsql
{
/// <inheritdoc />
public partial class InitialCreate : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Players",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
FirstName = table.Column<string>(type: "text", nullable: true),
MiddleName = table.Column<string>(type: "text", nullable: true),
LastName = table.Column<string>(type: "text", nullable: true),
DateOfBirth = table.Column<DateTime>(
type: "timestamp with time zone",
nullable: true
),
SquadNumber = table.Column<int>(type: "integer", nullable: false),
Position = table.Column<string>(type: "text", nullable: true),
AbbrPosition = table.Column<string>(type: "text", nullable: true),
Team = table.Column<string>(type: "text", nullable: true),
League = table.Column<string>(type: "text", nullable: true),
Starting11 = table.Column<bool>(type: "boolean", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Players", x => x.Id);
}
);
migrationBuilder.CreateIndex(
name: "IX_Players_SquadNumber",
table: "Players",
column: "SquadNumber",
unique: true
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(name: "Players");
}
}
}
@@ -0,0 +1,68 @@
// <auto-generated />
using System;
using Dotnet.Samples.AspNetCore.WebApi.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Dotnet.Samples.AspNetCore.WebApi.Migrations.Npgsql
{
[DbContext(typeof(PlayerDbContext))]
[Migration("20260409151100_SeedStarting11")]
partial class SeedStarting11
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.7");
modelBuilder.Entity("Dotnet.Samples.AspNetCore.WebApi.Models.Player", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("AbbrPosition")
.HasColumnType("text");
b.Property<DateTime?>("DateOfBirth")
.HasColumnType("timestamp with time zone");
b.Property<string>("FirstName")
.HasColumnType("text");
b.Property<string>("LastName")
.HasColumnType("text");
b.Property<string>("League")
.HasColumnType("text");
b.Property<string>("MiddleName")
.HasColumnType("text");
b.Property<string>("Position")
.HasColumnType("text");
b.Property<int>("SquadNumber")
.HasColumnType("integer");
b.Property<bool>("Starting11")
.HasColumnType("boolean");
b.Property<string>("Team")
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("SquadNumber")
.IsUnique();
b.ToTable("Players");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,251 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
namespace Dotnet.Samples.AspNetCore.WebApi.Migrations.Npgsql
{
/// <inheritdoc />
public partial class SeedStarting11 : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.InsertData(
table: "Players",
columns: new[]
{
"Id",
"AbbrPosition",
"DateOfBirth",
"FirstName",
"LastName",
"League",
"MiddleName",
"Position",
"SquadNumber",
"Starting11",
"Team"
},
values: new object[,]
{
{
new Guid("01772c59-43f0-5d85-b913-c78e4e281452"),
"GK",
new DateTime(1992, 9, 1, 0, 0, 0, 0, DateTimeKind.Utc),
"Damián",
"Martínez",
"Premier League",
"Emiliano",
"Goalkeeper",
23,
true,
"Aston Villa FC"
},
{
new Guid("0293b282-1da8-562e-998e-83849b417a42"),
"CM",
new DateTime(1994, 5, 23, 0, 0, 0, 0, DateTimeKind.Utc),
"Rodrigo",
"de Paul",
"La Liga",
"Javier",
"Central Midfield",
7,
true,
"Altético Madrid"
},
{
new Guid("2f6f90a0-9b9d-5023-96d2-a2aaf03143a6"),
"LB",
new DateTime(1992, 8, 30, 0, 0, 0, 0, DateTimeKind.Utc),
"Nicolás",
"Tagliafico",
"Ligue 1",
"Alejandro",
"Left-Back",
3,
true,
"Olympique Lyon"
},
{
new Guid("38bae91d-8519-55a2-b30a-b9fe38849bfb"),
"CF",
new DateTime(2000, 1, 30, 0, 0, 0, 0, DateTimeKind.Utc),
"Julián",
"Álvarez",
"Premier League",
null,
"Centre-Forward",
9,
true,
"Manchester City"
},
{
new Guid("9613cae9-16ab-5b54-937e-3135123b9e0d"),
"CM",
new DateTime(1998, 12, 23, 0, 0, 0, 0, DateTimeKind.Utc),
"Alexis",
"Mac Allister",
"Premier League",
null,
"Central Midfield",
20,
true,
"Brighton & Hove Albion"
},
{
new Guid("acc433bf-d505-51fe-831e-45eb44c4d43c"),
"RW",
new DateTime(1987, 6, 23, 0, 0, 0, 0, DateTimeKind.Utc),
"Lionel",
"Messi",
"Ligue 1",
"Andrés",
"Right Winger",
10,
true,
"Paris Saint-Germain"
},
{
new Guid("b5b46e79-929e-5ed2-949d-0d167109c022"),
"RW",
new DateTime(1988, 2, 13, 0, 0, 0, 0, DateTimeKind.Utc),
"Ángel",
"Di María",
"Liga Portugal",
"Fabián",
"Right Winger",
11,
true,
"SL Benfica"
},
{
new Guid("c096c69e-762b-5281-9290-bb9c167a24a0"),
"CB",
new DateTime(1998, 4, 26, 0, 0, 0, 0, DateTimeKind.Utc),
"Cristian",
"Romero",
"Premier League",
"Gabriel",
"Centre-Back",
13,
true,
"Tottenham Hotspur"
},
{
new Guid("d3ba552a-dac3-588a-b961-1ea7224017fd"),
"CM",
new DateTime(2001, 1, 16, 0, 0, 0, 0, DateTimeKind.Utc),
"Enzo",
"Fernández",
"Liga Portugal",
"Jeremías",
"Central Midfield",
24,
true,
"SL Benfica"
},
{
new Guid("d5f7dd7a-1dcb-5960-ba27-e34865b63358"),
"CB",
new DateTime(1988, 2, 11, 0, 0, 0, 0, DateTimeKind.Utc),
"Nicolás",
"Otamendi",
"Liga Portugal",
"Hernán Gonzalo",
"Centre-Back",
19,
true,
"SL Benfica"
},
{
new Guid("da31293b-4c7e-5e0f-a168-469ee29ecbc4"),
"RB",
new DateTime(1998, 4, 5, 0, 0, 0, 0, DateTimeKind.Utc),
"Nahuel",
"Molina",
"La Liga",
null,
"Right-Back",
26,
true,
"Altético Madrid"
}
}
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DeleteData(
table: "Players",
keyColumn: "Id",
keyValue: new Guid("01772c59-43f0-5d85-b913-c78e4e281452")
);
migrationBuilder.DeleteData(
table: "Players",
keyColumn: "Id",
keyValue: new Guid("0293b282-1da8-562e-998e-83849b417a42")
);
migrationBuilder.DeleteData(
table: "Players",
keyColumn: "Id",
keyValue: new Guid("2f6f90a0-9b9d-5023-96d2-a2aaf03143a6")
);
migrationBuilder.DeleteData(
table: "Players",
keyColumn: "Id",
keyValue: new Guid("38bae91d-8519-55a2-b30a-b9fe38849bfb")
);
migrationBuilder.DeleteData(
table: "Players",
keyColumn: "Id",
keyValue: new Guid("9613cae9-16ab-5b54-937e-3135123b9e0d")
);
migrationBuilder.DeleteData(
table: "Players",
keyColumn: "Id",
keyValue: new Guid("acc433bf-d505-51fe-831e-45eb44c4d43c")
);
migrationBuilder.DeleteData(
table: "Players",
keyColumn: "Id",
keyValue: new Guid("b5b46e79-929e-5ed2-949d-0d167109c022")
);
migrationBuilder.DeleteData(
table: "Players",
keyColumn: "Id",
keyValue: new Guid("c096c69e-762b-5281-9290-bb9c167a24a0")
);
migrationBuilder.DeleteData(
table: "Players",
keyColumn: "Id",
keyValue: new Guid("d3ba552a-dac3-588a-b961-1ea7224017fd")
);
migrationBuilder.DeleteData(
table: "Players",
keyColumn: "Id",
keyValue: new Guid("d5f7dd7a-1dcb-5960-ba27-e34865b63358")
);
migrationBuilder.DeleteData(
table: "Players",
keyColumn: "Id",
keyValue: new Guid("da31293b-4c7e-5e0f-a168-469ee29ecbc4")
);
}
}
}
@@ -0,0 +1,68 @@
// <auto-generated />
using System;
using Dotnet.Samples.AspNetCore.WebApi.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Dotnet.Samples.AspNetCore.WebApi.Migrations.Npgsql
{
[DbContext(typeof(PlayerDbContext))]
[Migration("20260409151200_SeedSubstitutes")]
partial class SeedSubstitutes
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.7");
modelBuilder.Entity("Dotnet.Samples.AspNetCore.WebApi.Models.Player", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("AbbrPosition")
.HasColumnType("text");
b.Property<DateTime?>("DateOfBirth")
.HasColumnType("timestamp with time zone");
b.Property<string>("FirstName")
.HasColumnType("text");
b.Property<string>("LastName")
.HasColumnType("text");
b.Property<string>("League")
.HasColumnType("text");
b.Property<string>("MiddleName")
.HasColumnType("text");
b.Property<string>("Position")
.HasColumnType("text");
b.Property<int>("SquadNumber")
.HasColumnType("integer");
b.Property<bool>("Starting11")
.HasColumnType("boolean");
b.Property<string>("Team")
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("SquadNumber")
.IsUnique();
b.ToTable("Players");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,327 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
namespace Dotnet.Samples.AspNetCore.WebApi.Migrations.Npgsql
{
/// <inheritdoc />
public partial class SeedSubstitutes : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.InsertData(
table: "Players",
columns: new[]
{
"Id",
"AbbrPosition",
"DateOfBirth",
"FirstName",
"LastName",
"League",
"MiddleName",
"Position",
"SquadNumber",
"Starting11",
"Team"
},
values: new object[,]
{
{
new Guid("191c82af-0c51-526a-b903-c3600b61b506"),
"DM",
new DateTime(1994, 4, 12, 0, 0, 0, 0, DateTimeKind.Utc),
"Guido",
"Rodríguez",
"La Liga",
null,
"Defensive Midfield",
18,
false,
"Real Betis Balompié"
},
{
new Guid("5a9cd988-95e6-54c1-bc34-9aa08acca8d0"),
"GK",
new DateTime(1986, 10, 16, 0, 0, 0, 0, DateTimeKind.Utc),
"Franco",
"Armani",
"Copa de la Liga",
"Daniel",
"Goalkeeper",
1,
false,
"River Plate"
},
{
new Guid("5fdb10e8-38c0-5084-9a3f-b369a960b9c2"),
"RB",
new DateTime(1998, 1, 12, 0, 0, 0, 0, DateTimeKind.Utc),
"Juan",
"Foyth",
"La Liga",
"Marcos",
"Right-Back",
2,
false,
"Villarreal"
},
{
new Guid("7941cd7c-4df1-5952-97e8-1e7f5d08e8aa"),
"SS",
new DateTime(1993, 11, 15, 0, 0, 0, 0, DateTimeKind.Utc),
"Paulo",
"Dybala",
"Serie A",
"Exequiel",
"Second Striker",
21,
false,
"AS Roma"
},
{
new Guid("79c96f29-c59f-5f98-96b8-3a5946246624"),
"CF",
new DateTime(1997, 8, 22, 0, 0, 0, 0, DateTimeKind.Utc),
"Lautaro",
"Martínez",
"Serie A",
"Javier",
"Centre-Forward",
22,
false,
"Inter Milan"
},
{
new Guid("7cc8d527-56a2-58bd-9528-2618fc139d30"),
"LW",
new DateTime(1988, 2, 15, 0, 0, 0, 0, DateTimeKind.Utc),
"Alejandro",
"Gómez",
"Serie A",
"Darío",
"Left Winger",
17,
false,
"AC Monza"
},
{
new Guid("98306555-a466-5d18-804e-dc82175e697b"),
"CB",
new DateTime(1998, 1, 18, 0, 0, 0, 0, DateTimeKind.Utc),
"Lisandro",
"Martínez",
"Premier League",
null,
"Centre-Back",
25,
false,
"Manchester United"
},
{
new Guid("9d140400-196f-55d8-86e1-e0b96a375c83"),
"DM",
new DateTime(1994, 6, 29, 0, 0, 0, 0, DateTimeKind.Utc),
"Leandro",
"Paredes",
"Serie A",
"Daniel",
"Defensive Midfield",
5,
false,
"AS Roma"
},
{
new Guid("b1306b7b-a3a4-5f7c-90fd-dd5bdbed57ba"),
"RW",
new DateTime(1995, 3, 9, 0, 0, 0, 0, DateTimeKind.Utc),
"Ángel",
"Correa",
"La Liga",
"Martín",
"Right Winger",
15,
false,
"Atlético Madrid"
},
{
new Guid("bbd441f7-fcfb-5834-8468-2a9004b64c8c"),
"RB",
new DateTime(1997, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc),
"Gonzalo",
"Montiel",
"Premier League",
"Ariel",
"Right-Back",
4,
false,
"Nottingham Forest"
},
{
new Guid("c62f2ac1-41e8-5d34-b073-2ba0913d0e31"),
"GK",
new DateTime(1992, 5, 20, 0, 0, 0, 0, DateTimeKind.Utc),
"Gerónimo",
"Rulli",
"Eredivisie",
null,
"Goalkeeper",
12,
false,
"Ajax Amsterdam"
},
{
new Guid("d3b0e8e8-2c34-531a-b608-b24fed0ef986"),
"CM",
new DateTime(1998, 10, 5, 0, 0, 0, 0, DateTimeKind.Utc),
"Exequiel",
"Palacios",
"Bundesliga",
"Alejandro",
"Central Midfield",
14,
false,
"Bayer 04 Leverkusen"
},
{
new Guid("d8bfea25-f189-5d5e-b3a5-ed89329b9f7c"),
"CB",
new DateTime(1991, 6, 27, 0, 0, 0, 0, DateTimeKind.Utc),
"Germán",
"Pezzella",
"La Liga",
"Alejo",
"Centre-Back",
6,
false,
"Real Betis Balompié"
},
{
new Guid("dca343a8-12e5-53d6-89a8-916b120a5ee4"),
"LB",
new DateTime(1991, 10, 28, 0, 0, 0, 0, DateTimeKind.Utc),
"Marcos",
"Acuña",
"La Liga",
"Javier",
"Left-Back",
8,
false,
"Sevilla FC"
},
{
new Guid("ecec27e8-487b-5622-b116-0855020477ed"),
"AM",
new DateTime(2001, 4, 26, 0, 0, 0, 0, DateTimeKind.Utc),
"Thiago",
"Almada",
"Major League Soccer",
"Ezequiel",
"Attacking Midfield",
16,
false,
"Atlanta United FC"
}
}
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DeleteData(
table: "Players",
keyColumn: "Id",
keyValue: new Guid("191c82af-0c51-526a-b903-c3600b61b506")
);
migrationBuilder.DeleteData(
table: "Players",
keyColumn: "Id",
keyValue: new Guid("5a9cd988-95e6-54c1-bc34-9aa08acca8d0")
);
migrationBuilder.DeleteData(
table: "Players",
keyColumn: "Id",
keyValue: new Guid("5fdb10e8-38c0-5084-9a3f-b369a960b9c2")
);
migrationBuilder.DeleteData(
table: "Players",
keyColumn: "Id",
keyValue: new Guid("7941cd7c-4df1-5952-97e8-1e7f5d08e8aa")
);
migrationBuilder.DeleteData(
table: "Players",
keyColumn: "Id",
keyValue: new Guid("79c96f29-c59f-5f98-96b8-3a5946246624")
);
migrationBuilder.DeleteData(
table: "Players",
keyColumn: "Id",
keyValue: new Guid("7cc8d527-56a2-58bd-9528-2618fc139d30")
);
migrationBuilder.DeleteData(
table: "Players",
keyColumn: "Id",
keyValue: new Guid("98306555-a466-5d18-804e-dc82175e697b")
);
migrationBuilder.DeleteData(
table: "Players",
keyColumn: "Id",
keyValue: new Guid("9d140400-196f-55d8-86e1-e0b96a375c83")
);
migrationBuilder.DeleteData(
table: "Players",
keyColumn: "Id",
keyValue: new Guid("b1306b7b-a3a4-5f7c-90fd-dd5bdbed57ba")
);
migrationBuilder.DeleteData(
table: "Players",
keyColumn: "Id",
keyValue: new Guid("bbd441f7-fcfb-5834-8468-2a9004b64c8c")
);
migrationBuilder.DeleteData(
table: "Players",
keyColumn: "Id",
keyValue: new Guid("c62f2ac1-41e8-5d34-b073-2ba0913d0e31")
);
migrationBuilder.DeleteData(
table: "Players",
keyColumn: "Id",
keyValue: new Guid("d3b0e8e8-2c34-531a-b608-b24fed0ef986")
);
migrationBuilder.DeleteData(
table: "Players",
keyColumn: "Id",
keyValue: new Guid("d8bfea25-f189-5d5e-b3a5-ed89329b9f7c")
);
migrationBuilder.DeleteData(
table: "Players",
keyColumn: "Id",
keyValue: new Guid("dca343a8-12e5-53d6-89a8-916b120a5ee4")
);
migrationBuilder.DeleteData(
table: "Players",
keyColumn: "Id",
keyValue: new Guid("ecec27e8-487b-5622-b116-0855020477ed")
);
}
}
}
@@ -0,0 +1,426 @@
// <auto-generated />
using System;
using Dotnet.Samples.AspNetCore.WebApi.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace Dotnet.Samples.AspNetCore.WebApi.Migrations
{
[DbContext(typeof(PlayerDbContext))]
partial class PlayerDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.5");
modelBuilder.Entity("Dotnet.Samples.AspNetCore.WebApi.Models.Player", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("AbbrPosition")
.HasColumnType("TEXT");
b.Property<DateTime?>("DateOfBirth")
.HasColumnType("TEXT");
b.Property<string>("FirstName")
.HasColumnType("TEXT");
b.Property<string>("LastName")
.HasColumnType("TEXT");
b.Property<string>("League")
.HasColumnType("TEXT");
b.Property<string>("MiddleName")
.HasColumnType("TEXT");
b.Property<string>("Position")
.HasColumnType("TEXT");
b.Property<int>("SquadNumber")
.HasColumnType("INTEGER");
b.Property<bool>("Starting11")
.HasColumnType("INTEGER");
b.Property<string>("Team")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("SquadNumber")
.IsUnique();
b.ToTable("Players");
b.HasData(
new
{
Id = new Guid("01772c59-43f0-5d85-b913-c78e4e281452"),
AbbrPosition = "GK",
DateOfBirth = new DateTime(1992, 9, 1, 0, 0, 0, 0, DateTimeKind.Utc),
FirstName = "Damián",
LastName = "Martínez",
League = "Premier League",
MiddleName = "Emiliano",
Position = "Goalkeeper",
SquadNumber = 23,
Starting11 = true,
Team = "Aston Villa FC"
},
new
{
Id = new Guid("da31293b-4c7e-5e0f-a168-469ee29ecbc4"),
AbbrPosition = "RB",
DateOfBirth = new DateTime(1998, 4, 5, 0, 0, 0, 0, DateTimeKind.Utc),
FirstName = "Nahuel",
LastName = "Molina",
League = "La Liga",
Position = "Right-Back",
SquadNumber = 26,
Starting11 = true,
Team = "Altético Madrid"
},
new
{
Id = new Guid("c096c69e-762b-5281-9290-bb9c167a24a0"),
AbbrPosition = "CB",
DateOfBirth = new DateTime(1998, 4, 26, 0, 0, 0, 0, DateTimeKind.Utc),
FirstName = "Cristian",
LastName = "Romero",
League = "Premier League",
MiddleName = "Gabriel",
Position = "Centre-Back",
SquadNumber = 13,
Starting11 = true,
Team = "Tottenham Hotspur"
},
new
{
Id = new Guid("d5f7dd7a-1dcb-5960-ba27-e34865b63358"),
AbbrPosition = "CB",
DateOfBirth = new DateTime(1988, 2, 11, 0, 0, 0, 0, DateTimeKind.Utc),
FirstName = "Nicolás",
LastName = "Otamendi",
League = "Liga Portugal",
MiddleName = "Hernán Gonzalo",
Position = "Centre-Back",
SquadNumber = 19,
Starting11 = true,
Team = "SL Benfica"
},
new
{
Id = new Guid("2f6f90a0-9b9d-5023-96d2-a2aaf03143a6"),
AbbrPosition = "LB",
DateOfBirth = new DateTime(1992, 8, 30, 0, 0, 0, 0, DateTimeKind.Utc),
FirstName = "Nicolás",
LastName = "Tagliafico",
League = "Ligue 1",
MiddleName = "Alejandro",
Position = "Left-Back",
SquadNumber = 3,
Starting11 = true,
Team = "Olympique Lyon"
},
new
{
Id = new Guid("b5b46e79-929e-5ed2-949d-0d167109c022"),
AbbrPosition = "RW",
DateOfBirth = new DateTime(1988, 2, 13, 0, 0, 0, 0, DateTimeKind.Utc),
FirstName = "Ángel",
LastName = "Di María",
League = "Liga Portugal",
MiddleName = "Fabián",
Position = "Right Winger",
SquadNumber = 11,
Starting11 = true,
Team = "SL Benfica"
},
new
{
Id = new Guid("0293b282-1da8-562e-998e-83849b417a42"),
AbbrPosition = "CM",
DateOfBirth = new DateTime(1994, 5, 23, 0, 0, 0, 0, DateTimeKind.Utc),
FirstName = "Rodrigo",
LastName = "de Paul",
League = "La Liga",
MiddleName = "Javier",
Position = "Central Midfield",
SquadNumber = 7,
Starting11 = true,
Team = "Altético Madrid"
},
new
{
Id = new Guid("d3ba552a-dac3-588a-b961-1ea7224017fd"),
AbbrPosition = "CM",
DateOfBirth = new DateTime(2001, 1, 16, 0, 0, 0, 0, DateTimeKind.Utc),
FirstName = "Enzo",
LastName = "Fernández",
League = "Liga Portugal",
MiddleName = "Jeremías",
Position = "Central Midfield",
SquadNumber = 24,
Starting11 = true,
Team = "SL Benfica"
},
new
{
Id = new Guid("9613cae9-16ab-5b54-937e-3135123b9e0d"),
AbbrPosition = "CM",
DateOfBirth = new DateTime(1998, 12, 23, 0, 0, 0, 0, DateTimeKind.Utc),
FirstName = "Alexis",
LastName = "Mac Allister",
League = "Premier League",
Position = "Central Midfield",
SquadNumber = 20,
Starting11 = true,
Team = "Brighton & Hove Albion"
},
new
{
Id = new Guid("acc433bf-d505-51fe-831e-45eb44c4d43c"),
AbbrPosition = "RW",
DateOfBirth = new DateTime(1987, 6, 23, 0, 0, 0, 0, DateTimeKind.Utc),
FirstName = "Lionel",
LastName = "Messi",
League = "Ligue 1",
MiddleName = "Andrés",
Position = "Right Winger",
SquadNumber = 10,
Starting11 = true,
Team = "Paris Saint-Germain"
},
new
{
Id = new Guid("38bae91d-8519-55a2-b30a-b9fe38849bfb"),
AbbrPosition = "CF",
DateOfBirth = new DateTime(2000, 1, 30, 0, 0, 0, 0, DateTimeKind.Utc),
FirstName = "Julián",
LastName = "Álvarez",
League = "Premier League",
Position = "Centre-Forward",
SquadNumber = 9,
Starting11 = true,
Team = "Manchester City"
},
new
{
Id = new Guid("5a9cd988-95e6-54c1-bc34-9aa08acca8d0"),
AbbrPosition = "GK",
DateOfBirth = new DateTime(1986, 10, 16, 0, 0, 0, 0, DateTimeKind.Utc),
FirstName = "Franco",
LastName = "Armani",
League = "Copa de la Liga",
MiddleName = "Daniel",
Position = "Goalkeeper",
SquadNumber = 1,
Starting11 = false,
Team = "River Plate"
},
new
{
Id = new Guid("c62f2ac1-41e8-5d34-b073-2ba0913d0e31"),
AbbrPosition = "GK",
DateOfBirth = new DateTime(1992, 5, 20, 0, 0, 0, 0, DateTimeKind.Utc),
FirstName = "Gerónimo",
LastName = "Rulli",
League = "Eredivisie",
Position = "Goalkeeper",
SquadNumber = 12,
Starting11 = false,
Team = "Ajax Amsterdam"
},
new
{
Id = new Guid("5fdb10e8-38c0-5084-9a3f-b369a960b9c2"),
AbbrPosition = "RB",
DateOfBirth = new DateTime(1998, 1, 12, 0, 0, 0, 0, DateTimeKind.Utc),
FirstName = "Juan",
LastName = "Foyth",
League = "La Liga",
MiddleName = "Marcos",
Position = "Right-Back",
SquadNumber = 2,
Starting11 = false,
Team = "Villarreal"
},
new
{
Id = new Guid("bbd441f7-fcfb-5834-8468-2a9004b64c8c"),
AbbrPosition = "RB",
DateOfBirth = new DateTime(1997, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc),
FirstName = "Gonzalo",
LastName = "Montiel",
League = "Premier League",
MiddleName = "Ariel",
Position = "Right-Back",
SquadNumber = 4,
Starting11 = false,
Team = "Nottingham Forest"
},
new
{
Id = new Guid("d8bfea25-f189-5d5e-b3a5-ed89329b9f7c"),
AbbrPosition = "CB",
DateOfBirth = new DateTime(1991, 6, 27, 0, 0, 0, 0, DateTimeKind.Utc),
FirstName = "Germán",
LastName = "Pezzella",
League = "La Liga",
MiddleName = "Alejo",
Position = "Centre-Back",
SquadNumber = 6,
Starting11 = false,
Team = "Real Betis Balompié"
},
new
{
Id = new Guid("dca343a8-12e5-53d6-89a8-916b120a5ee4"),
AbbrPosition = "LB",
DateOfBirth = new DateTime(1991, 10, 28, 0, 0, 0, 0, DateTimeKind.Utc),
FirstName = "Marcos",
LastName = "Acuña",
League = "La Liga",
MiddleName = "Javier",
Position = "Left-Back",
SquadNumber = 8,
Starting11 = false,
Team = "Sevilla FC"
},
new
{
Id = new Guid("98306555-a466-5d18-804e-dc82175e697b"),
AbbrPosition = "CB",
DateOfBirth = new DateTime(1998, 1, 18, 0, 0, 0, 0, DateTimeKind.Utc),
FirstName = "Lisandro",
LastName = "Martínez",
League = "Premier League",
Position = "Centre-Back",
SquadNumber = 25,
Starting11 = false,
Team = "Manchester United"
},
new
{
Id = new Guid("9d140400-196f-55d8-86e1-e0b96a375c83"),
AbbrPosition = "DM",
DateOfBirth = new DateTime(1994, 6, 29, 0, 0, 0, 0, DateTimeKind.Utc),
FirstName = "Leandro",
LastName = "Paredes",
League = "Serie A",
MiddleName = "Daniel",
Position = "Defensive Midfield",
SquadNumber = 5,
Starting11 = false,
Team = "AS Roma"
},
new
{
Id = new Guid("d3b0e8e8-2c34-531a-b608-b24fed0ef986"),
AbbrPosition = "CM",
DateOfBirth = new DateTime(1998, 10, 5, 0, 0, 0, 0, DateTimeKind.Utc),
FirstName = "Exequiel",
LastName = "Palacios",
League = "Bundesliga",
MiddleName = "Alejandro",
Position = "Central Midfield",
SquadNumber = 14,
Starting11 = false,
Team = "Bayer 04 Leverkusen"
},
new
{
Id = new Guid("7cc8d527-56a2-58bd-9528-2618fc139d30"),
AbbrPosition = "LW",
DateOfBirth = new DateTime(1988, 2, 15, 0, 0, 0, 0, DateTimeKind.Utc),
FirstName = "Alejandro",
LastName = "Gómez",
League = "Serie A",
MiddleName = "Darío",
Position = "Left Winger",
SquadNumber = 17,
Starting11 = false,
Team = "AC Monza"
},
new
{
Id = new Guid("191c82af-0c51-526a-b903-c3600b61b506"),
AbbrPosition = "DM",
DateOfBirth = new DateTime(1994, 4, 12, 0, 0, 0, 0, DateTimeKind.Utc),
FirstName = "Guido",
LastName = "Rodríguez",
League = "La Liga",
Position = "Defensive Midfield",
SquadNumber = 18,
Starting11 = false,
Team = "Real Betis Balompié"
},
new
{
Id = new Guid("b1306b7b-a3a4-5f7c-90fd-dd5bdbed57ba"),
AbbrPosition = "RW",
DateOfBirth = new DateTime(1995, 3, 9, 0, 0, 0, 0, DateTimeKind.Utc),
FirstName = "Ángel",
LastName = "Correa",
League = "La Liga",
MiddleName = "Martín",
Position = "Right Winger",
SquadNumber = 15,
Starting11 = false,
Team = "Atlético Madrid"
},
new
{
Id = new Guid("ecec27e8-487b-5622-b116-0855020477ed"),
AbbrPosition = "AM",
DateOfBirth = new DateTime(2001, 4, 26, 0, 0, 0, 0, DateTimeKind.Utc),
FirstName = "Thiago",
LastName = "Almada",
League = "Major League Soccer",
MiddleName = "Ezequiel",
Position = "Attacking Midfield",
SquadNumber = 16,
Starting11 = false,
Team = "Atlanta United FC"
},
new
{
Id = new Guid("7941cd7c-4df1-5952-97e8-1e7f5d08e8aa"),
AbbrPosition = "SS",
DateOfBirth = new DateTime(1993, 11, 15, 0, 0, 0, 0, DateTimeKind.Utc),
FirstName = "Paulo",
LastName = "Dybala",
League = "Serie A",
MiddleName = "Exequiel",
Position = "Second Striker",
SquadNumber = 21,
Starting11 = false,
Team = "AS Roma"
},
new
{
Id = new Guid("79c96f29-c59f-5f98-96b8-3a5946246624"),
AbbrPosition = "CF",
DateOfBirth = new DateTime(1997, 8, 22, 0, 0, 0, 0, DateTimeKind.Utc),
FirstName = "Lautaro",
LastName = "Martínez",
League = "Serie A",
MiddleName = "Javier",
Position = "Centre-Forward",
SquadNumber = 22,
Starting11 = false,
Team = "Inter Milan"
});
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,47 @@
using System.Collections.Generic;
using System.Reflection;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#pragma warning disable EF1001 // Internal EF Core API usage.
using Microsoft.EntityFrameworkCore.Migrations.Internal;
namespace Dotnet.Samples.AspNetCore.WebApi.Migrations;
/// <summary>
/// Filters the available migrations to only those belonging to the active database provider's
/// namespace, ensuring SQLite and PostgreSQL each apply their own migration set.
/// </summary>
#pragma warning disable CS9107 // currentContext is intentionally captured by both the subclass and the base class
public class ProviderSpecificMigrationsAssembly(
ICurrentDbContext currentContext,
IDbContextOptions options,
IMigrationsIdGenerator idGenerator,
IDiagnosticsLogger<DbLoggerCategory.Migrations> logger
) : MigrationsAssembly(currentContext, options, idGenerator, logger)
#pragma warning restore CS9107
{
private const string SqliteNamespace = "Dotnet.Samples.AspNetCore.WebApi.Migrations";
private const string NpgsqlNamespace = "Dotnet.Samples.AspNetCore.WebApi.Migrations.Npgsql";
public override IReadOnlyDictionary<string, TypeInfo> Migrations
{
get
{
var providerName = currentContext.Context.Database.ProviderName ?? string.Empty;
var targetNamespace = providerName.Contains(
"Sqlite",
StringComparison.OrdinalIgnoreCase
)
? SqliteNamespace
: NpgsqlNamespace;
return new SortedDictionary<string, TypeInfo>(
base.Migrations.Where(m => m.Value.Namespace == targetNamespace)
.ToDictionary(m => m.Key, m => m.Value)
);
}
}
}
#pragma warning restore EF1001
@@ -0,0 +1,67 @@
using System.ComponentModel.DataAnnotations;
namespace Dotnet.Samples.AspNetCore.WebApi.Models;
/// <summary>
/// Model for Player entity.
/// </summary>
/// <remarks>
/// This class represents the Player entity in the database.
/// </remarks>
public class Player
{
/// <summary>
/// The unique identifier for the Player.
/// </summary>
public Guid Id { get; set; } = Guid.NewGuid();
/// <summary>
/// The first name of the Player.
/// </summary>
public string? FirstName { get; set; }
/// <summary>
/// The middle name of the Player, if any.
/// </summary>
public string? MiddleName { get; set; }
/// <summary>
/// The last name of the Player.
/// </summary>
public string? LastName { get; set; }
/// <summary>
/// The date of birth of the Player.
/// </summary>
public DateTime? DateOfBirth { get; set; }
/// <summary>
/// The squad number assigned to the Player.
/// </summary>
public int SquadNumber { get; set; }
/// <summary>
/// The playing position of the Player.
/// </summary>
public string? Position { get; set; }
/// <summary>
/// The abbreviated form of the Player's position.
/// </summary>
public string? AbbrPosition { get; set; }
/// <summary>
/// The team to which the Player belongs.
/// </summary>
public string? Team { get; set; }
/// <summary>
/// The league where the team plays.
/// </summary>
public string? League { get; set; }
/// <summary>
/// Indicates whether the Player is in the starting 11.
/// </summary>
public bool Starting11 { get; set; }
}
@@ -0,0 +1,54 @@
using System.ComponentModel.DataAnnotations;
namespace Dotnet.Samples.AspNetCore.WebApi.Models;
/// <summary>
/// Model for Player request.
/// </summary>
/// <remarks>
/// This class is used to receive Player data from the client.
/// The properties are decorated with validation attributes to ensure that
/// the required fields are provided and that the data is in the correct format.
/// </remarks>
public class PlayerRequestModel
{
/// <summary>
/// The first name of the Player.
/// </summary>
public string? FirstName { get; set; }
/// <summary>
/// The middle name of the Player, if any.
/// </summary>
public string? MiddleName { get; set; }
/// <summary>
/// The last name of the Player.
/// </summary>
public string? LastName { get; set; }
/// <summary>
/// The date of birth of the Player.
/// </summary>
public DateTime? DateOfBirth { get; set; }
/// <summary>
/// The squad number assigned to the Player (required).
/// </summary>
public required int SquadNumber { get; set; }
/// <summary>
/// The abbreviated form of the Player's position.
/// </summary>
public string? AbbrPosition { get; set; }
/// <summary>
/// The team to which the Player belongs.
/// </summary>
public string? Team { get; set; }
/// <summary>
/// The league where the team plays.
/// </summary>
public string? League { get; set; }
}
@@ -0,0 +1,47 @@
using System.ComponentModel.DataAnnotations;
namespace Dotnet.Samples.AspNetCore.WebApi.Models;
/// <summary>
/// Model for Player response.
/// </summary>
/// <remarks>
/// This class is used to send Player data to the client.
/// </remarks>
public class PlayerResponseModel
{
/// <summary>
/// The full name of the Player (combined first, middle, and last names).
/// </summary>
public string? FullName { get; set; }
/// <summary>
/// The formatted birth date of the Player.
/// </summary>
public string? Birth { get; set; }
/// <summary>
/// The squad number (dorsal) of the Player.
/// </summary>
public int Dorsal { get; set; }
/// <summary>
/// The playing position of the Player.
/// </summary>
public string? Position { get; set; }
/// <summary>
/// The club (team) to which the Player belongs.
/// </summary>
public string? Club { get; set; }
/// <summary>
/// The league where the club plays.
/// </summary>
public string? League { get; set; }
/// <summary>
/// Indicates whether the Player is in the starting 11 (formatted as string).
/// </summary>
public string? Starting11 { get; set; }
}
@@ -0,0 +1,129 @@
using Dotnet.Samples.AspNetCore.WebApi.Data;
using Dotnet.Samples.AspNetCore.WebApi.Extensions;
using Microsoft.EntityFrameworkCore;
using Serilog;
/* -----------------------------------------------------------------------------
* Web Application
* Registers all services into the DI container before the application is built.
* Throughout this section, builder.Services refers to the ASP.NET Core
* dependency injection (DI) container — not to be confused with the Services
* subsection below, which registers our own application-level business logic.
* https://learn.microsoft.com/en-us/aspnet/core/fundamentals/startup
* -------------------------------------------------------------------------- */
var builder = WebApplication.CreateBuilder(args);
/* Configurations ----------------------------------------------------------- */
builder
.Configuration.SetBasePath(AppContext.BaseDirectory)
.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true)
.AddEnvironmentVariables();
/* Logging ------------------------------------------------------------------ */
Log.Logger = new LoggerConfiguration().ReadFrom.Configuration(builder.Configuration).CreateLogger();
builder.Host.UseSerilog();
/* Infrastructure ----------------------------------------------------------- */
builder.Services.AddHealthChecks();
builder.Services.AddCorsDefaultPolicy(builder.Environment);
builder.Services.AddFixedWindowRateLimiter(builder.Configuration);
if (builder.Environment.IsDevelopment())
{
builder.Services.AddSwaggerConfiguration(builder.Configuration);
}
/* Controllers -------------------------------------------------------------- */
builder.Services.AddControllers();
builder.Services.AddValidators();
/* Services (Business Logic) ------------------------------------------------ */
builder.Services.RegisterPlayerService();
builder.Services.AddMemoryCache();
builder.Services.AddMappings();
/* Repositories ------------------------------------------------------------- */
builder.Services.RegisterPlayerRepository();
/* Data --------------------------------------------------------------------- */
builder.Services.AddPlayerDbContext(builder.Environment);
var app = builder.Build();
/* -----------------------------------------------------------------------------
* Database Migration
* Applies pending EF Core migrations at startup, before the app accepts requests.
* https://learn.microsoft.com/en-us/ef/core/managing-schemas/migrations/applying#apply-migrations-at-runtime
* -------------------------------------------------------------------------- */
await using (var scope = app.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<PlayerDbContext>();
await db.Database.MigrateAsync();
}
/* -----------------------------------------------------------------------------
* Middlewares
* Defines the order in which middleware components process each HTTP request.
* https://learn.microsoft.com/en-us/aspnet/core/fundamentals/middleware
* https://learn.microsoft.com/en-us/aspnet/core/fundamentals/middleware#middleware-order
* -------------------------------------------------------------------------- */
// Replaces the default ASP.NET Core request logging with Serilog's structured
// logging, emitting one log entry per request with timing, status code, and
// other contextual properties.
app.UseSerilogRequestLogging();
// Custom middleware that catches unhandled exceptions and returns a consistent
// RFC 7807 Problem Details response instead of exposing a raw stack trace.
app.UseExceptionHandling();
// Redirects all plain HTTP requests to HTTPS, enforcing transport security.
app.UseHttpsRedirection();
// DisableRateLimiting() exempts the health check endpoint from the global rate
// limiter so that monitoring and orchestration systems can always assess
// liveness and readiness without being throttled.
app.MapHealthChecks("/health").DisableRateLimiting();
// Enforces the fixed-window rate limiting policy defined during service
// registration, returning 429 Too Many Requests when the limit is exceeded.
app.UseRateLimiter();
if (app.Environment.IsDevelopment())
{
// Only active in Development, where AddCorsDefaultPolicy registers a
// permissive wildcard policy for Swagger UI and local frontends. No policy
// exists in Production — the API is assumed to be consumed server-to-server
// or behind a reverse proxy on the same origin, where CORS is not needed.
// Must precede MapControllers so CORS headers are applied before any
// endpoint executes, consistent with the standard middleware pipeline order.
app.UseCors();
// Generates the OpenAPI JSON document consumed by Swagger UI.
app.UseSwagger();
// Serves the interactive Swagger UI at /swagger, allowing manual
// exploration and testing of the API endpoints during development.
app.UseSwaggerUI();
}
// Routes incoming HTTP requests to the matching controller actions. Must come
// after all middleware that needs to run before endpoint execution (CORS, rate
// limiting, etc.).
app.MapControllers();
await app.RunAsync();
public partial class Program
{
protected Program() { }
}
@@ -0,0 +1,12 @@
{
"profiles": {
"Dotnet.Samples.AspNetCore.WebApi": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "https://localhost:56530;http://localhost:56531"
}
}
}
@@ -0,0 +1,21 @@
// IPlayerRepository.cs
using Dotnet.Samples.AspNetCore.WebApi.Models;
namespace Dotnet.Samples.AspNetCore.WebApi.Repositories;
/// <summary>
/// Provides specialized repository operations for Player entities.
/// </summary>
public interface IPlayerRepository : IRepository<Player>
{
/// <summary>
/// Finds a Player in the repository by their Squad Number.
/// </summary>
/// <param name="squadNumber">The Squad Number of the Player to retrieve.</param>
/// <returns>
/// A Task representing the asynchronous operation,containing the Player
/// if found, or null if no Player with the specified Squad Number exists.
/// </returns>
Task<Player?> FindBySquadNumberAsync(int squadNumber);
}
@@ -0,0 +1,47 @@
namespace Dotnet.Samples.AspNetCore.WebApi.Repositories;
/// <summary>
/// Provides generic repository operations for entities of type <typeparamref name="T"/>.
/// </summary>
/// <typeparam name="T">The entity type managed by this repository.</typeparam>
public interface IRepository<T>
where T : class
{
/// <summary>
/// Adds a new entity to the repository.
/// </summary>
/// <param name="entity">The entity to create.</param>
/// <returns>A Task representing the asynchronous operation.</returns>
Task AddAsync(T entity);
/// <summary>
/// Gets all entities from the repository.
/// </summary>
/// <returns>A Task representing the asynchronous operation,
/// containing a list of all entities.</returns>
Task<List<T>> GetAllAsync();
/// <summary>
/// Finds an entity on the repository by its unique identifier.
/// </summary>
/// <param name="id">The unique identifier of the entity to retrieve.</param>
/// <returns>
/// A ValueTask representing the asynchronous operation, containing the entity if found,
/// or null if no entity with the specified ID exists.
/// </returns>
ValueTask<T?> FindByIdAsync(Guid id);
/// <summary>
/// Updates an existing entity in the repository.
/// </summary>
/// <param name="entity">The entity with updated values.</param>
/// <returns>A Task representing the asynchronous operation.</returns>
Task UpdateAsync(T entity);
/// <summary>
/// Removes an entity from the repository by its unique identifier.
/// </summary>
/// <param name="id">The unique identifier of the entity to remove.</param>
/// <returns>A Task representing the asynchronous operation.</returns>
Task RemoveAsync(Guid id);
}
@@ -0,0 +1,18 @@
using Dotnet.Samples.AspNetCore.WebApi.Data;
using Dotnet.Samples.AspNetCore.WebApi.Models;
using Microsoft.EntityFrameworkCore;
namespace Dotnet.Samples.AspNetCore.WebApi.Repositories;
public sealed class PlayerRepository(PlayerDbContext dbContext)
: Repository<Player>(dbContext),
IPlayerRepository
{
public async Task<Player?> FindBySquadNumberAsync(int squadNumber) =>
await _dbSet.FirstOrDefaultAsync(p => p.SquadNumber == squadNumber);
public async Task<bool> SquadNumberExistsAsync(int squadNumber)
{
return await dbContext.Players.AnyAsync(p => p.SquadNumber == squadNumber);
}
}
@@ -0,0 +1,35 @@
using Microsoft.EntityFrameworkCore;
namespace Dotnet.Samples.AspNetCore.WebApi.Repositories;
public class Repository<T>(DbContext dbContext) : IRepository<T>
where T : class
{
protected readonly DbSet<T> _dbSet = dbContext.Set<T>();
public async Task AddAsync(T entity)
{
await _dbSet.AddAsync(entity);
await dbContext.SaveChangesAsync();
}
public async Task<List<T>> GetAllAsync() => await _dbSet.AsNoTracking().ToListAsync();
public async ValueTask<T?> FindByIdAsync(Guid id) => await _dbSet.FindAsync(id);
public async Task UpdateAsync(T entity)
{
_dbSet.Update(entity);
await dbContext.SaveChangesAsync();
}
public async Task RemoveAsync(Guid id)
{
var entity = await _dbSet.FindAsync(id);
if (entity != null)
{
_dbSet.Remove(entity);
await dbContext.SaveChangesAsync();
}
}
}
@@ -0,0 +1,59 @@
using Dotnet.Samples.AspNetCore.WebApi.Models;
namespace Dotnet.Samples.AspNetCore.WebApi.Services
{
/// <summary>
/// Interface for managing Player entities in the repository.
/// </summary>
public interface IPlayerService
{
/// <summary>
/// Adds a new Player to the repository.
/// </summary>
/// <param name="playerRequestModel">The Player to create.</param>
/// <returns>A Task representing the asynchronous operation,
/// containing the created Player.</returns>
public Task<PlayerResponseModel> CreateAsync(PlayerRequestModel playerRequestModel);
/// <summary>
/// Retrieves all players from the repository.
/// </summary>
/// <returns>A Task representing the asynchronous operation,
/// containing a list of all players.</returns>
public Task<List<PlayerResponseModel>> RetrieveAsync();
/// <summary>
/// Retrieves a Player from the repository by its ID.
/// </summary>
/// <param name="id">The ID of the Player to retrieve.</param>
/// <returns>
/// A Task representing the asynchronous operation, containing the Player if found,
/// or null if not.
/// </returns>
public Task<PlayerResponseModel?> RetrieveByIdAsync(Guid id);
/// <summary>
/// Retrieves a Player from the repository by its Squad Number.
/// </summary>
/// <param name="squadNumber">The Squad Number of the Player to retrieve.</param>
/// <returns>
/// A Task representing the asynchronous operation, containing the Player if found,
/// or null if not.
/// </returns>
public Task<PlayerResponseModel?> RetrieveBySquadNumberAsync(int squadNumber);
/// <summary>
/// Updates (entirely) an existing Player in the repository.
/// </summary>
/// <param name="playerRequestModel">The Player to update.</param>
/// <returns>A Task representing the asynchronous operation.</returns>
public Task UpdateAsync(PlayerRequestModel playerRequestModel);
/// <summary>
/// Removes an existing Player from the repository.
/// </summary>
/// <param name="squadNumber">The Squad Number of the Player to delete.</param>
/// <returns>A Task representing the asynchronous operation.</returns>
public Task DeleteAsync(int squadNumber);
}
}
@@ -0,0 +1,150 @@
using AutoMapper;
using Dotnet.Samples.AspNetCore.WebApi.Models;
using Dotnet.Samples.AspNetCore.WebApi.Repositories;
using Microsoft.Extensions.Caching.Memory;
namespace Dotnet.Samples.AspNetCore.WebApi.Services;
public class PlayerService(
IPlayerRepository playerRepository,
ILogger<PlayerService> logger,
IMemoryCache memoryCache,
IMapper mapper,
IHostEnvironment environment
) : IPlayerService
{
/// <summary>
/// Creates a MemoryCacheEntryOptions instance with Normal priority,
/// SlidingExpiration of 10 minutes and AbsoluteExpiration of 1 hour.
/// </summary>
private static readonly MemoryCacheEntryOptions CacheEntryOptions =
new MemoryCacheEntryOptions()
.SetPriority(CacheItemPriority.Normal)
.SetSlidingExpiration(TimeSpan.FromMinutes(10))
.SetAbsoluteExpiration(TimeSpan.FromHours(1));
/// <summary>
/// The key used to store the list of Players in the cache.
/// </summary>
private static readonly string CacheKey_RetrieveAsync = nameof(RetrieveAsync);
/* -------------------------------------------------------------------------
* Create
* ---------------------------------------------------------------------- */
public async Task<PlayerResponseModel> CreateAsync(PlayerRequestModel playerRequestModel)
{
var player = mapper.Map<Player>(playerRequestModel);
await playerRepository.AddAsync(player);
logger.LogInformation("Player added to Repository: {Player}", player);
memoryCache.Remove(CacheKey_RetrieveAsync);
logger.LogInformation("Removed objects from Cache with Key: {Key}", CacheKey_RetrieveAsync);
return mapper.Map<PlayerResponseModel>(player);
}
/* -------------------------------------------------------------------------
* Retrieve
* ---------------------------------------------------------------------- */
public async Task<List<PlayerResponseModel>> RetrieveAsync()
{
if (memoryCache.TryGetValue(CacheKey_RetrieveAsync, out List<PlayerResponseModel>? cached))
{
logger.LogInformation("Players retrieved from Cache");
return cached!;
}
else
{
if (environment.IsDevelopment())
{
await SimulateRepositoryDelayAsync();
}
var players = await playerRepository.GetAllAsync();
logger.LogInformation("Players retrieved from Repository");
var playerResponseModels = mapper.Map<List<PlayerResponseModel>>(players);
using (var cacheEntry = memoryCache.CreateEntry(CacheKey_RetrieveAsync))
{
logger.LogInformation(
"{Count} entries created in Cache with key: {Key}",
playerResponseModels.Count,
CacheKey_RetrieveAsync
);
cacheEntry.SetSize(playerResponseModels.Count);
cacheEntry.Value = playerResponseModels;
cacheEntry.SetOptions(CacheEntryOptions);
}
return playerResponseModels;
}
}
public async Task<PlayerResponseModel?> RetrieveByIdAsync(Guid id)
{
var player = await playerRepository.FindByIdAsync(id);
return player is not null ? mapper.Map<PlayerResponseModel>(player) : null;
}
public async Task<PlayerResponseModel?> RetrieveBySquadNumberAsync(int squadNumber)
{
var player = await playerRepository.FindBySquadNumberAsync(squadNumber);
return player is not null ? mapper.Map<PlayerResponseModel>(player) : null;
}
/* -------------------------------------------------------------------------
* Update
* ---------------------------------------------------------------------- */
public async Task UpdateAsync(PlayerRequestModel playerRequestModel)
{
if (
await playerRepository.FindBySquadNumberAsync(playerRequestModel.SquadNumber)
is Player player
)
{
mapper.Map(playerRequestModel, player);
await playerRepository.UpdateAsync(player);
logger.LogInformation("Player updated in Repository: {Player}", player);
memoryCache.Remove(CacheKey_RetrieveAsync);
logger.LogInformation(
"Removed objects from Cache with Key: {Key}",
CacheKey_RetrieveAsync
);
}
}
/* -------------------------------------------------------------------------
* Delete
* ---------------------------------------------------------------------- */
public async Task DeleteAsync(int squadNumber)
{
if (await playerRepository.FindBySquadNumberAsync(squadNumber) is Player player)
{
await playerRepository.RemoveAsync(player.Id);
logger.LogInformation(
"Player with Id {SquadNumber} removed from Repository",
squadNumber
);
memoryCache.Remove(CacheKey_RetrieveAsync);
logger.LogInformation(
"Removed objects from Cache with Key: {Key}",
CacheKey_RetrieveAsync
);
}
}
/// <summary>
/// Simulates a delay in the repository call to mimic a long-running operation.
/// This is only used in the Development environment to simulate a delay
/// in the repository call. In production, this method should not be called.
/// </summary>
/// <returns>A Task representing the asynchronous operation.</returns>
private async Task SimulateRepositoryDelayAsync()
{
var milliseconds = new Random().Next(2600, 4200);
logger.LogInformation(
"Simulating a random delay of {Milliseconds} milliseconds...",
milliseconds
);
await Task.Delay(milliseconds);
}
}
@@ -0,0 +1,47 @@
using System.Net;
namespace Dotnet.Samples.AspNetCore.WebApi.Utilities;
/// <summary>
/// Utility class for HTTP context operations.
/// </summary>
public static class HttpContextUtilities
{
/// <summary>
/// This method checks for the "X-Forwarded-For" and "X-Real-IP" headers,
/// which are commonly used by proxies to forward the original client IP address.
/// If these headers are not present or the IP address cannot be parsed,
/// it falls back to the remote IP address from the connection.
/// If no valid IP address can be determined, it returns "unknown".
/// </summary>
/// <param name="httpContext">The HTTP context.</param>
/// <returns>The client IP address or "unknown" if not available.</returns>
public static string ExtractIpAddress(HttpContext httpContext)
{
ArgumentNullException.ThrowIfNull(httpContext);
var headers = httpContext.Request.Headers;
IPAddress? ipAddress;
if (headers.TryGetValue("X-Forwarded-For", out var xForwardedFor))
{
var clientIp = xForwardedFor
.ToString()
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.FirstOrDefault();
if (!string.IsNullOrWhiteSpace(clientIp) && IPAddress.TryParse(clientIp, out ipAddress))
return ipAddress.ToString();
}
if (
headers.TryGetValue("X-Real-IP", out var xRealIp)
&& IPAddress.TryParse(xRealIp.ToString(), out ipAddress)
)
{
return ipAddress.ToString();
}
return httpContext.Connection.RemoteIpAddress?.ToString() ?? $"unknown-{Guid.NewGuid()}";
}
}
@@ -0,0 +1,917 @@
using System.Text.Json;
using Dotnet.Samples.AspNetCore.WebApi.Enums;
using Dotnet.Samples.AspNetCore.WebApi.Models;
namespace Dotnet.Samples.AspNetCore.WebApi.Utilities;
/// <summary>
/// Provides static player data for database seeding and testing.
/// Single source of truth for all player definitions.
/// </summary>
public static class PlayerData
{
/// <summary>
/// Returns the starting 11 players without IDs (for EF Core auto-increment).
/// Used for database migrations and seeding.
/// </summary>
/// <returns>List of 11 Player entities representing the starting lineup.</returns>
public static List<Player> MakeStarting11()
{
return
[
new()
{
FirstName = "Damián",
MiddleName = "Emiliano",
LastName = "Martínez",
DateOfBirth = new DateTime(1992, 9, 1, 0, 0, 0, DateTimeKind.Utc),
SquadNumber = 23,
Position = Position.Goalkeeper.Text,
AbbrPosition = Position.Goalkeeper.Abbr,
Team = "Aston Villa FC",
League = "Premier League",
Starting11 = true,
},
new()
{
FirstName = "Nahuel",
LastName = "Molina",
DateOfBirth = new DateTime(1998, 4, 5, 0, 0, 0, DateTimeKind.Utc),
SquadNumber = 26,
Position = Position.RightBack.Text,
AbbrPosition = Position.RightBack.Abbr,
Team = "Altético Madrid",
League = "La Liga",
Starting11 = true,
},
new()
{
FirstName = "Cristian",
MiddleName = "Gabriel",
LastName = "Romero",
DateOfBirth = new DateTime(1998, 4, 26, 0, 0, 0, DateTimeKind.Utc),
SquadNumber = 13,
Position = Position.CentreBack.Text,
AbbrPosition = Position.CentreBack.Abbr,
Team = "Tottenham Hotspur",
League = "Premier League",
Starting11 = true,
},
new()
{
FirstName = "Nicolás",
MiddleName = "Hernán Gonzalo",
LastName = "Otamendi",
DateOfBirth = new DateTime(1988, 2, 11, 0, 0, 0, DateTimeKind.Utc),
SquadNumber = 19,
Position = Position.CentreBack.Text,
AbbrPosition = Position.CentreBack.Abbr,
Team = "SL Benfica",
League = "Liga Portugal",
Starting11 = true,
},
new()
{
FirstName = "Nicolás",
MiddleName = "Alejandro",
LastName = "Tagliafico",
DateOfBirth = new DateTime(1992, 8, 30, 0, 0, 0, DateTimeKind.Utc),
SquadNumber = 3,
Position = Position.LeftBack.Text,
AbbrPosition = Position.LeftBack.Abbr,
Team = "Olympique Lyon",
League = "Ligue 1",
Starting11 = true,
},
new()
{
FirstName = "Ángel",
MiddleName = "Fabián",
LastName = "Di María",
DateOfBirth = new DateTime(1988, 2, 13, 0, 0, 0, DateTimeKind.Utc),
SquadNumber = 11,
Position = Position.RightWinger.Text,
AbbrPosition = Position.RightWinger.Abbr,
Team = "SL Benfica",
League = "Liga Portugal",
Starting11 = true,
},
new()
{
FirstName = "Rodrigo",
MiddleName = "Javier",
LastName = "de Paul",
DateOfBirth = new DateTime(1994, 5, 23, 0, 0, 0, DateTimeKind.Utc),
SquadNumber = 7,
Position = Position.CentralMidfield.Text,
AbbrPosition = Position.CentralMidfield.Abbr,
Team = "Altético Madrid",
League = "La Liga",
Starting11 = true,
},
new()
{
FirstName = "Enzo",
MiddleName = "Jeremías",
LastName = "Fernández",
DateOfBirth = new DateTime(2001, 1, 16, 0, 0, 0, DateTimeKind.Utc),
SquadNumber = 24,
Position = Position.CentralMidfield.Text,
AbbrPosition = Position.CentralMidfield.Abbr,
Team = "SL Benfica",
League = "Liga Portugal",
Starting11 = true,
},
new()
{
FirstName = "Alexis",
LastName = "Mac Allister",
DateOfBirth = new DateTime(1998, 12, 23, 0, 0, 0, DateTimeKind.Utc),
SquadNumber = 20,
Position = Position.CentralMidfield.Text,
AbbrPosition = Position.CentralMidfield.Abbr,
Team = "Brighton & Hove Albion",
League = "Premier League",
Starting11 = true,
},
new()
{
FirstName = "Lionel",
MiddleName = "Andrés",
LastName = "Messi",
DateOfBirth = new DateTime(1987, 6, 23, 0, 0, 0, DateTimeKind.Utc),
SquadNumber = 10,
Position = Position.RightWinger.Text,
AbbrPosition = Position.RightWinger.Abbr,
Team = "Paris Saint-Germain",
League = "Ligue 1",
Starting11 = true,
},
new()
{
FirstName = "Julián",
LastName = "Álvarez",
DateOfBirth = new DateTime(2000, 1, 30, 0, 0, 0, DateTimeKind.Utc),
SquadNumber = 9,
Position = Position.CentreForward.Text,
AbbrPosition = Position.CentreForward.Abbr,
Team = "Manchester City",
League = "Premier League",
Starting11 = true,
},
];
}
/// <summary>
/// Create a predefined starting eleven of players where each player has a fixed GUID identifier and full profile data.
/// </summary>
/// <returns>A list of 11 Player instances representing the starting lineup; each entry includes a predefined Id (Guid) and populated fields such as name, date of birth, squad number, position (and abbreviation), team, league, and Starting11 set to true.</returns>
public static List<Player> MakeStarting11WithId()
{
return
[
new()
{
Id = Guid.Parse("01772c59-43f0-5d85-b913-c78e4e281452"),
FirstName = "Damián",
MiddleName = "Emiliano",
LastName = "Martínez",
DateOfBirth = new DateTime(1992, 9, 1, 0, 0, 0, DateTimeKind.Utc),
SquadNumber = 23,
Position = Position.Goalkeeper.Text,
AbbrPosition = Position.Goalkeeper.Abbr,
Team = "Aston Villa FC",
League = "Premier League",
Starting11 = true,
},
new()
{
Id = Guid.Parse("da31293b-4c7e-5e0f-a168-469ee29ecbc4"),
FirstName = "Nahuel",
LastName = "Molina",
DateOfBirth = new DateTime(1998, 4, 5, 0, 0, 0, DateTimeKind.Utc),
SquadNumber = 26,
Position = Position.RightBack.Text,
AbbrPosition = Position.RightBack.Abbr,
Team = "Altético Madrid",
League = "La Liga",
Starting11 = true,
},
new()
{
Id = Guid.Parse("c096c69e-762b-5281-9290-bb9c167a24a0"),
FirstName = "Cristian",
MiddleName = "Gabriel",
LastName = "Romero",
DateOfBirth = new DateTime(1998, 4, 26, 0, 0, 0, DateTimeKind.Utc),
SquadNumber = 13,
Position = Position.CentreBack.Text,
AbbrPosition = Position.CentreBack.Abbr,
Team = "Tottenham Hotspur",
League = "Premier League",
Starting11 = true,
},
new()
{
Id = Guid.Parse("d5f7dd7a-1dcb-5960-ba27-e34865b63358"),
FirstName = "Nicolás",
MiddleName = "Hernán Gonzalo",
LastName = "Otamendi",
DateOfBirth = new DateTime(1988, 2, 11, 0, 0, 0, DateTimeKind.Utc),
SquadNumber = 19,
Position = Position.CentreBack.Text,
AbbrPosition = Position.CentreBack.Abbr,
Team = "SL Benfica",
League = "Liga Portugal",
Starting11 = true,
},
new()
{
Id = Guid.Parse("2f6f90a0-9b9d-5023-96d2-a2aaf03143a6"),
FirstName = "Nicolás",
MiddleName = "Alejandro",
LastName = "Tagliafico",
DateOfBirth = new DateTime(1992, 8, 30, 0, 0, 0, DateTimeKind.Utc),
SquadNumber = 3,
Position = Position.LeftBack.Text,
AbbrPosition = Position.LeftBack.Abbr,
Team = "Olympique Lyon",
League = "Ligue 1",
Starting11 = true,
},
new()
{
Id = Guid.Parse("b5b46e79-929e-5ed2-949d-0d167109c022"),
FirstName = "Ángel",
MiddleName = "Fabián",
LastName = "Di María",
DateOfBirth = new DateTime(1988, 2, 13, 0, 0, 0, DateTimeKind.Utc),
SquadNumber = 11,
Position = Position.RightWinger.Text,
AbbrPosition = Position.RightWinger.Abbr,
Team = "SL Benfica",
League = "Liga Portugal",
Starting11 = true,
},
new()
{
Id = Guid.Parse("0293b282-1da8-562e-998e-83849b417a42"),
FirstName = "Rodrigo",
MiddleName = "Javier",
LastName = "de Paul",
DateOfBirth = new DateTime(1994, 5, 23, 0, 0, 0, DateTimeKind.Utc),
SquadNumber = 7,
Position = Position.CentralMidfield.Text,
AbbrPosition = Position.CentralMidfield.Abbr,
Team = "Altético Madrid",
League = "La Liga",
Starting11 = true,
},
new()
{
Id = Guid.Parse("d3ba552a-dac3-588a-b961-1ea7224017fd"),
FirstName = "Enzo",
MiddleName = "Jeremías",
LastName = "Fernández",
DateOfBirth = new DateTime(2001, 1, 16, 0, 0, 0, DateTimeKind.Utc),
SquadNumber = 24,
Position = Position.CentralMidfield.Text,
AbbrPosition = Position.CentralMidfield.Abbr,
Team = "SL Benfica",
League = "Liga Portugal",
Starting11 = true,
},
new()
{
Id = Guid.Parse("9613cae9-16ab-5b54-937e-3135123b9e0d"),
FirstName = "Alexis",
LastName = "Mac Allister",
DateOfBirth = new DateTime(1998, 12, 23, 0, 0, 0, DateTimeKind.Utc),
SquadNumber = 20,
Position = Position.CentralMidfield.Text,
AbbrPosition = Position.CentralMidfield.Abbr,
Team = "Brighton & Hove Albion",
League = "Premier League",
Starting11 = true,
},
new()
{
Id = Guid.Parse("acc433bf-d505-51fe-831e-45eb44c4d43c"),
FirstName = "Lionel",
MiddleName = "Andrés",
LastName = "Messi",
DateOfBirth = new DateTime(1987, 6, 23, 0, 0, 0, DateTimeKind.Utc),
SquadNumber = 10,
Position = Position.RightWinger.Text,
AbbrPosition = Position.RightWinger.Abbr,
Team = "Paris Saint-Germain",
League = "Ligue 1",
Starting11 = true,
},
new()
{
Id = Guid.Parse("38bae91d-8519-55a2-b30a-b9fe38849bfb"),
FirstName = "Julián",
LastName = "Álvarez",
DateOfBirth = new DateTime(2000, 1, 30, 0, 0, 0, DateTimeKind.Utc),
SquadNumber = 9,
Position = Position.CentreForward.Text,
AbbrPosition = Position.CentreForward.Abbr,
Team = "Manchester City",
League = "Premier League",
Starting11 = true,
},
];
}
/// <summary>
/// Create a predefined list of 15 substitute players with full profile data but without Id values.
/// </summary>
/// <returns>A list of 15 Player instances representing substitute players; each entry includes populated fields such as name, date of birth, squad number, position (and abbreviation), team, league, and Starting11 set to false. Id values are not assigned.</returns>
public static List<Player> GetSubstitutes()
{
return
[
new()
{
FirstName = "Franco",
MiddleName = "Daniel",
LastName = "Armani",
DateOfBirth = new DateTime(1986, 10, 16, 0, 0, 0, DateTimeKind.Utc),
SquadNumber = 1,
Position = Position.Goalkeeper.Text,
AbbrPosition = Position.Goalkeeper.Abbr,
Team = "River Plate",
League = "Copa de la Liga",
Starting11 = false,
},
new()
{
FirstName = "Gerónimo",
LastName = "Rulli",
DateOfBirth = new DateTime(1992, 5, 20, 0, 0, 0, DateTimeKind.Utc),
SquadNumber = 12,
Position = Position.Goalkeeper.Text,
AbbrPosition = Position.Goalkeeper.Abbr,
Team = "Ajax Amsterdam",
League = "Eredivisie",
Starting11 = false,
},
new()
{
FirstName = "Juan",
MiddleName = "Marcos",
LastName = "Foyth",
DateOfBirth = new DateTime(1998, 1, 12, 0, 0, 0, DateTimeKind.Utc),
SquadNumber = 2,
Position = Position.RightBack.Text,
AbbrPosition = Position.RightBack.Abbr,
Team = "Villarreal",
League = "La Liga",
Starting11 = false,
},
new()
{
FirstName = "Gonzalo",
MiddleName = "Ariel",
LastName = "Montiel",
DateOfBirth = new DateTime(1997, 1, 1, 0, 0, 0, DateTimeKind.Utc),
SquadNumber = 4,
Position = Position.RightBack.Text,
AbbrPosition = Position.RightBack.Abbr,
Team = "Nottingham Forest",
League = "Premier League",
Starting11 = false,
},
new()
{
FirstName = "Germán",
MiddleName = "Alejo",
LastName = "Pezzella",
DateOfBirth = new DateTime(1991, 6, 27, 0, 0, 0, DateTimeKind.Utc),
SquadNumber = 6,
Position = Position.CentreBack.Text,
AbbrPosition = Position.CentreBack.Abbr,
Team = "Real Betis Balompié",
League = "La Liga",
Starting11 = false,
},
new()
{
FirstName = "Marcos",
MiddleName = "Javier",
LastName = "Acuña",
DateOfBirth = new DateTime(1991, 10, 28, 0, 0, 0, DateTimeKind.Utc),
SquadNumber = 8,
Position = Position.LeftBack.Text,
AbbrPosition = Position.LeftBack.Abbr,
Team = "Sevilla FC",
League = "La Liga",
Starting11 = false,
},
new()
{
FirstName = "Lisandro",
LastName = "Martínez",
DateOfBirth = new DateTime(1998, 1, 18, 0, 0, 0, DateTimeKind.Utc),
SquadNumber = 25,
Position = Position.CentreBack.Text,
AbbrPosition = Position.CentreBack.Abbr,
Team = "Manchester United",
League = "Premier League",
Starting11 = false,
},
new()
{
FirstName = "Leandro",
MiddleName = "Daniel",
LastName = "Paredes",
DateOfBirth = new DateTime(1994, 6, 29, 0, 0, 0, DateTimeKind.Utc),
SquadNumber = 5,
Position = Position.DefensiveMidfield.Text,
AbbrPosition = Position.DefensiveMidfield.Abbr,
Team = "AS Roma",
League = "Serie A",
Starting11 = false,
},
new()
{
FirstName = "Exequiel",
MiddleName = "Alejandro",
LastName = "Palacios",
DateOfBirth = new DateTime(1998, 10, 5, 0, 0, 0, DateTimeKind.Utc),
SquadNumber = 14,
Position = Position.CentralMidfield.Text,
AbbrPosition = Position.CentralMidfield.Abbr,
Team = "Bayer 04 Leverkusen",
League = "Bundesliga",
Starting11 = false,
},
new()
{
FirstName = "Alejandro",
MiddleName = "Darío",
LastName = "Gómez",
DateOfBirth = new DateTime(1988, 2, 15, 0, 0, 0, DateTimeKind.Utc),
SquadNumber = 17,
Position = Position.LeftWinger.Text,
AbbrPosition = Position.LeftWinger.Abbr,
Team = "AC Monza",
League = "Serie A",
Starting11 = false,
},
new()
{
FirstName = "Guido",
LastName = "Rodríguez",
DateOfBirth = new DateTime(1994, 4, 12, 0, 0, 0, DateTimeKind.Utc),
SquadNumber = 18,
Position = Position.DefensiveMidfield.Text,
AbbrPosition = Position.DefensiveMidfield.Abbr,
Team = "Real Betis Balompié",
League = "La Liga",
Starting11 = false,
},
new()
{
FirstName = "Ángel",
MiddleName = "Martín",
LastName = "Correa",
DateOfBirth = new DateTime(1995, 3, 9, 0, 0, 0, DateTimeKind.Utc),
SquadNumber = 15,
Position = Position.RightWinger.Text,
AbbrPosition = Position.RightWinger.Abbr,
Team = "Atlético Madrid",
League = "La Liga",
Starting11 = false,
},
new()
{
FirstName = "Thiago",
MiddleName = "Ezequiel",
LastName = "Almada",
DateOfBirth = new DateTime(2001, 4, 26, 0, 0, 0, DateTimeKind.Utc),
SquadNumber = 16,
Position = Position.AttackingMidfield.Text,
AbbrPosition = Position.AttackingMidfield.Abbr,
Team = "Atlanta United FC",
League = "Major League Soccer",
Starting11 = false,
},
new()
{
FirstName = "Paulo",
MiddleName = "Exequiel",
LastName = "Dybala",
DateOfBirth = new DateTime(1993, 11, 15, 0, 0, 0, DateTimeKind.Utc),
SquadNumber = 21,
Position = Position.SecondStriker.Text,
AbbrPosition = Position.SecondStriker.Abbr,
Team = "AS Roma",
League = "Serie A",
Starting11 = false,
},
new()
{
FirstName = "Lautaro",
MiddleName = "Javier",
LastName = "Martínez",
DateOfBirth = new DateTime(1997, 8, 22, 0, 0, 0, DateTimeKind.Utc),
SquadNumber = 22,
Position = Position.CentreForward.Text,
AbbrPosition = Position.CentreForward.Abbr,
Team = "Inter Milan",
League = "Serie A",
Starting11 = false,
},
new()
{
FirstName = "Giovani",
LastName = "Lo Celso",
DateOfBirth = new DateTime(1996, 4, 9, 0, 0, 0, DateTimeKind.Utc),
SquadNumber = 27,
Position = Position.CentralMidfield.Text,
AbbrPosition = Position.CentralMidfield.Abbr,
Team = "Real Betis Balompié",
League = "La Liga",
Starting11 = false,
},
];
}
/// <summary>
/// Create a predefined list of 15 substitute players where each player has a fixed GUID identifier and full profile data.
/// </summary>
/// <returns>A list of 15 Player instances representing substitute players (squads 1–26); each entry includes a predefined Id (Guid) and populated fields such as name, date of birth, squad number, position (and abbreviation), team, league, and Starting11 set to false. Lo Celso (squad 27) is intentionally excluded — his squad number falls outside the seeded range so he can serve as the canonical Create/Delete fixture without conflicting with seeded data.</returns>
public static List<Player> GetSubstitutesWithId()
{
return
[
new()
{
Id = Guid.Parse("5a9cd988-95e6-54c1-bc34-9aa08acca8d0"),
FirstName = "Franco",
MiddleName = "Daniel",
LastName = "Armani",
DateOfBirth = new DateTime(1986, 10, 16, 0, 0, 0, DateTimeKind.Utc),
SquadNumber = 1,
Position = Position.Goalkeeper.Text,
AbbrPosition = Position.Goalkeeper.Abbr,
Team = "River Plate",
League = "Copa de la Liga",
Starting11 = false,
},
new()
{
Id = Guid.Parse("c62f2ac1-41e8-5d34-b073-2ba0913d0e31"),
FirstName = "Gerónimo",
LastName = "Rulli",
DateOfBirth = new DateTime(1992, 5, 20, 0, 0, 0, DateTimeKind.Utc),
SquadNumber = 12,
Position = Position.Goalkeeper.Text,
AbbrPosition = Position.Goalkeeper.Abbr,
Team = "Ajax Amsterdam",
League = "Eredivisie",
Starting11 = false,
},
new()
{
Id = Guid.Parse("5fdb10e8-38c0-5084-9a3f-b369a960b9c2"),
FirstName = "Juan",
MiddleName = "Marcos",
LastName = "Foyth",
DateOfBirth = new DateTime(1998, 1, 12, 0, 0, 0, DateTimeKind.Utc),
SquadNumber = 2,
Position = Position.RightBack.Text,
AbbrPosition = Position.RightBack.Abbr,
Team = "Villarreal",
League = "La Liga",
Starting11 = false,
},
new()
{
Id = Guid.Parse("bbd441f7-fcfb-5834-8468-2a9004b64c8c"),
FirstName = "Gonzalo",
MiddleName = "Ariel",
LastName = "Montiel",
DateOfBirth = new DateTime(1997, 1, 1, 0, 0, 0, DateTimeKind.Utc),
SquadNumber = 4,
Position = Position.RightBack.Text,
AbbrPosition = Position.RightBack.Abbr,
Team = "Nottingham Forest",
League = "Premier League",
Starting11 = false,
},
new()
{
Id = Guid.Parse("d8bfea25-f189-5d5e-b3a5-ed89329b9f7c"),
FirstName = "Germán",
MiddleName = "Alejo",
LastName = "Pezzella",
DateOfBirth = new DateTime(1991, 6, 27, 0, 0, 0, DateTimeKind.Utc),
SquadNumber = 6,
Position = Position.CentreBack.Text,
AbbrPosition = Position.CentreBack.Abbr,
Team = "Real Betis Balompié",
League = "La Liga",
Starting11 = false,
},
new()
{
Id = Guid.Parse("dca343a8-12e5-53d6-89a8-916b120a5ee4"),
FirstName = "Marcos",
MiddleName = "Javier",
LastName = "Acuña",
DateOfBirth = new DateTime(1991, 10, 28, 0, 0, 0, DateTimeKind.Utc),
SquadNumber = 8,
Position = Position.LeftBack.Text,
AbbrPosition = Position.LeftBack.Abbr,
Team = "Sevilla FC",
League = "La Liga",
Starting11 = false,
},
new()
{
Id = Guid.Parse("98306555-a466-5d18-804e-dc82175e697b"),
FirstName = "Lisandro",
LastName = "Martínez",
DateOfBirth = new DateTime(1998, 1, 18, 0, 0, 0, DateTimeKind.Utc),
SquadNumber = 25,
Position = Position.CentreBack.Text,
AbbrPosition = Position.CentreBack.Abbr,
Team = "Manchester United",
League = "Premier League",
Starting11 = false,
},
new()
{
Id = Guid.Parse("9d140400-196f-55d8-86e1-e0b96a375c83"),
FirstName = "Leandro",
MiddleName = "Daniel",
LastName = "Paredes",
DateOfBirth = new DateTime(1994, 6, 29, 0, 0, 0, DateTimeKind.Utc),
SquadNumber = 5,
Position = Position.DefensiveMidfield.Text,
AbbrPosition = Position.DefensiveMidfield.Abbr,
Team = "AS Roma",
League = "Serie A",
Starting11 = false,
},
new()
{
Id = Guid.Parse("d3b0e8e8-2c34-531a-b608-b24fed0ef986"),
FirstName = "Exequiel",
MiddleName = "Alejandro",
LastName = "Palacios",
DateOfBirth = new DateTime(1998, 10, 5, 0, 0, 0, DateTimeKind.Utc),
SquadNumber = 14,
Position = Position.CentralMidfield.Text,
AbbrPosition = Position.CentralMidfield.Abbr,
Team = "Bayer 04 Leverkusen",
League = "Bundesliga",
Starting11 = false,
},
new()
{
Id = Guid.Parse("7cc8d527-56a2-58bd-9528-2618fc139d30"),
FirstName = "Alejandro",
MiddleName = "Darío",
LastName = "Gómez",
DateOfBirth = new DateTime(1988, 2, 15, 0, 0, 0, DateTimeKind.Utc),
SquadNumber = 17,
Position = Position.LeftWinger.Text,
AbbrPosition = Position.LeftWinger.Abbr,
Team = "AC Monza",
League = "Serie A",
Starting11 = false,
},
new()
{
Id = Guid.Parse("191c82af-0c51-526a-b903-c3600b61b506"),
FirstName = "Guido",
LastName = "Rodríguez",
DateOfBirth = new DateTime(1994, 4, 12, 0, 0, 0, DateTimeKind.Utc),
SquadNumber = 18,
Position = Position.DefensiveMidfield.Text,
AbbrPosition = Position.DefensiveMidfield.Abbr,
Team = "Real Betis Balompié",
League = "La Liga",
Starting11 = false,
},
new()
{
Id = Guid.Parse("b1306b7b-a3a4-5f7c-90fd-dd5bdbed57ba"),
FirstName = "Ángel",
MiddleName = "Martín",
LastName = "Correa",
DateOfBirth = new DateTime(1995, 3, 9, 0, 0, 0, DateTimeKind.Utc),
SquadNumber = 15,
Position = Position.RightWinger.Text,
AbbrPosition = Position.RightWinger.Abbr,
Team = "Atlético Madrid",
League = "La Liga",
Starting11 = false,
},
new()
{
Id = Guid.Parse("ecec27e8-487b-5622-b116-0855020477ed"),
FirstName = "Thiago",
MiddleName = "Ezequiel",
LastName = "Almada",
DateOfBirth = new DateTime(2001, 4, 26, 0, 0, 0, DateTimeKind.Utc),
SquadNumber = 16,
Position = Position.AttackingMidfield.Text,
AbbrPosition = Position.AttackingMidfield.Abbr,
Team = "Atlanta United FC",
League = "Major League Soccer",
Starting11 = false,
},
new()
{
Id = Guid.Parse("7941cd7c-4df1-5952-97e8-1e7f5d08e8aa"),
FirstName = "Paulo",
MiddleName = "Exequiel",
LastName = "Dybala",
DateOfBirth = new DateTime(1993, 11, 15, 0, 0, 0, DateTimeKind.Utc),
SquadNumber = 21,
Position = Position.SecondStriker.Text,
AbbrPosition = Position.SecondStriker.Abbr,
Team = "AS Roma",
League = "Serie A",
Starting11 = false,
},
new()
{
Id = Guid.Parse("79c96f29-c59f-5f98-96b8-3a5946246624"),
FirstName = "Lautaro",
MiddleName = "Javier",
LastName = "Martínez",
DateOfBirth = new DateTime(1997, 8, 22, 0, 0, 0, DateTimeKind.Utc),
SquadNumber = 22,
Position = Position.CentreForward.Text,
AbbrPosition = Position.CentreForward.Abbr,
Team = "Inter Milan",
League = "Serie A",
Starting11 = false,
},
];
}
/// <summary>
/// The purpose of this method is to demonstrate the capabilities of
/// System.Text.Json.JsonSerializer
/// https://learn.microsoft.com/en-us/dotnet/standard/serialization/system-text-json/deserialization
/// </summary>
/// <returns>A list of Players.</returns>
public static List<Player> MakeStarting11FromDeserializedJson()
{
var players = new List<Player>();
var json = """
[{
"firstName": "Damián",
"middleName": "Emiliano",
"lastName": "Martínez",
"dateOfBirth": "1992-09-01T00:00:00.000Z",
"squadNumber": 23,
"position": "Goalkeeper",
"abbrPosition": "GK",
"team": "Aston Villa FC",
"league": "Premier League",
"starting11": true
},
{
"firstName": "Nahuel",
"middleName": null,
"lastName": "Molina",
"dateOfBirth": "1998-04-05T00:00:00.000Z",
"squadNumber": 26,
"position": "Right-Back",
"abbrPosition": "RB",
"team": "Atlético Madrid",
"league": "La Liga",
"starting11": true
},
{
"firstName": "Cristian",
"middleName": "Gabriel",
"lastName": "Romero",
"dateOfBirth": "1998-04-26T00:00:00.000Z",
"squadNumber": 13,
"position": "Centre-Back",
"abbrPosition": "CB",
"team": "Tottenham Hotspur",
"league": "Premier League",
"starting11": true
},
{
"firstName": "Nicolás",
"middleName": "Hernán Gonzalo",
"lastName": "Otamendi",
"dateOfBirth": "1988-02-11T00:00:00.000Z",
"squadNumber": 19,
"position": "Centre-Back",
"abbrPosition": "CB",
"team": "SL Benfica",
"league": "Liga Portugal",
"starting11": true
},
{
"firstName": "Nicolás",
"middleName": "Alejandro",
"lastName": "Tagliafico",
"dateOfBirth": "1992-08-30T00:00:00.000Z",
"squadNumber": 3,
"position": "Left-Back",
"abbrPosition": "LB",
"team": "Olympique Lyon",
"league": "Ligue 1",
"starting11": true
},
{
"firstName": "Ángel",
"middleName": "Fabián",
"lastName": "Di María",
"dateOfBirth": "1988-02-13T00:00:00.000Z",
"squadNumber": 11,
"position": "Right Winger",
"abbrPosition": "RW",
"team": "SL Benfica",
"league": "Liga Portugal",
"starting11": true
},
{
"firstName": "Rodrigo",
"middleName": "Javier",
"lastName": "de Paul",
"dateOfBirth": "1994-05-23T00:00:00.000Z",
"squadNumber": 7,
"position": "Central Midfield",
"abbrPosition": "CM",
"team": "Atlético Madrid",
"league": "La Liga",
"starting11": true
},
{
"firstName": "Enzo",
"middleName": "Jeremías",
"lastName": "Fernández",
"dateOfBirth": "2001-01-16T00:00:00.000Z",
"squadNumber": 24,
"position": "Central Midfield",
"abbrPosition": "CM",
"team": "SL Benfica",
"league": "Liga Portugal",
"starting11": true
},
{
"firstName": "Alexis",
"middleName": null,
"lastName": "Mac Allister",
"dateOfBirth": "1998-12-23T00:00:00.000Z",
"squadNumber": 20,
"position": "Central Midfield",
"abbrPosition": "CM",
"team": "Brighton & Hove Albion",
"league": "Premier League",
"starting11": true
},
{
"firstName": "Lionel",
"middleName": "Andrés",
"lastName": "Messi",
"dateOfBirth": "1987-06-23T00:00:00.000Z",
"squadNumber": 10,
"position": "Right Winger",
"abbrPosition": "RW",
"team": "Paris Saint-Germain",
"league": "Ligue 1",
"starting11": true
},
{
"firstName": "Julián",
"middleName": null,
"lastName": "Álvarez",
"dateOfBirth": "2000-01-30T00:00:00.000Z",
"squadNumber": 9,
"position": "Centre-Forward",
"abbrPosition": "CF",
"team": "Manchester City",
"league": "Premier League",
"starting11": true
}]
""";
var starting11 = JsonSerializer.Deserialize<List<Player>>(json, options);
if (starting11 != null && starting11.Count > 0)
{
players.AddRange(starting11);
}
return players;
}
private static readonly JsonSerializerOptions options =
new() { PropertyNameCaseInsensitive = true, };
}
@@ -0,0 +1,64 @@
using System.Reflection;
using Microsoft.OpenApi;
namespace Dotnet.Samples.AspNetCore.WebApi.Utilities;
/// <summary>
/// Utility methods for Swagger/OpenAPI configuration.
/// Contains reusable helper methods that create OpenAPI objects.
/// </summary>
public static class SwaggerUtilities
{
/// <summary>
/// Resolves the path to the XML comments file generated from code
/// documentation.
/// This is used to enrich the Swagger UI with method summaries and remarks.
/// </summary>
/// <returns>Full file path to the XML documentation file.</returns>
public static string ConfigureXmlCommentsFilePath()
{
var path = Path.Combine(
AppContext.BaseDirectory,
$"{Assembly.GetExecutingAssembly().GetName().Name}.xml"
);
if (!File.Exists(path))
{
throw new FileNotFoundException("XML comments file not found.", path);
}
return path;
}
/// <summary>
/// Configures the OpenAPI security definition for JWT Bearer authentication.
/// This will show the padlock icon in Swagger UI and allow users to
/// authenticate.
/// </summary>
/// <returns>An <see cref="OpenApiSecurityScheme"/> describing the Bearer token format.</returns>
public static OpenApiSecurityScheme ConfigureSecurityDefinition()
{
return new OpenApiSecurityScheme
{
Name = "Authorization",
Type = SecuritySchemeType.Http,
Scheme = "bearer",
BearerFormat = "JWT",
In = ParameterLocation.Header,
Description = "Enter your JWT token below. Example: Bearer {token}"
};
}
/// <summary>
/// Adds a global security requirement to the Swagger spec that applies
/// the Bearer definition.
/// Routes decorated with [Authorize] will be marked as protected in the UI.
/// </summary>
/// <returns>An <see cref="OpenApiSecurityRequirement"/> referencing the Bearer definition.</returns>
public static OpenApiSecurityRequirement ConfigureSecurityRequirement()
{
return new OpenApiSecurityRequirement
{
{ new OpenApiSecuritySchemeReference("Bearer", null), new List<string>() }
};
}
}
@@ -0,0 +1,135 @@
using Dotnet.Samples.AspNetCore.WebApi.Enums;
using Dotnet.Samples.AspNetCore.WebApi.Models;
using Dotnet.Samples.AspNetCore.WebApi.Repositories;
using FluentValidation;
namespace Dotnet.Samples.AspNetCore.WebApi.Validators;
/// <summary>
/// Validator for PlayerRequestModel.
/// This class uses FluentValidation to define validation rules for the
/// PlayerRequestModel.
/// </summary>
/// <remarks>
/// Rules are organized into CRUD-named rule sets to make their intent explicit.
/// This prevents <c>BeUniqueSquadNumber</c> from running on PUT requests, where
/// the player's squad number already exists in the database by definition.
///
/// <list type="bullet">
/// <item><description>
/// <c>"Create"</c> — used by <c>POST /players</c>; includes all rules plus
/// the uniqueness check for <c>SquadNumber</c>.
/// </description></item>
/// <item><description>
/// <c>"Update"</c> — used by <c>PUT /players/squadNumber/{n}</c>; same
/// rules, but <c>BeUniqueSquadNumber</c> is intentionally omitted.
/// </description></item>
/// </list>
///
/// Controllers pass <c>opts.IncludeRuleSets("Create")</c> or
/// <c>opts.IncludeRuleSets("Update")</c> so that only the appropriate rule
/// set runs for each operation.
/// </remarks>
public class PlayerRequestModelValidator : AbstractValidator<PlayerRequestModel>
{
private readonly IPlayerRepository _playerRepository;
public PlayerRequestModelValidator(
IPlayerRepository playerRepository,
TimeProvider? timeProvider = null
)
{
_playerRepository = playerRepository;
var clock = timeProvider ?? TimeProvider.System;
// "Create" rule set — POST /players
// Includes BeUniqueSquadNumber to prevent duplicate squad numbers on insert.
RuleSet(
"Create",
() =>
{
RuleFor(player => player.FirstName)
.NotEmpty()
.WithMessage("FirstName is required.");
RuleFor(player => player.LastName).NotEmpty().WithMessage("LastName is required.");
RuleFor(player => player.SquadNumber)
.NotEmpty()
.WithMessage("SquadNumber is required.")
.GreaterThan(0)
.WithMessage("SquadNumber must be greater than 0.")
.MustAsync(BeUniqueSquadNumber)
.WithMessage("SquadNumber must be unique.");
RuleFor(player => player.AbbrPosition)
.NotEmpty()
.WithMessage("AbbrPosition is required.")
.Must(Position.IsValidAbbr)
.WithMessage("AbbrPosition is invalid.");
When(
player => player.DateOfBirth.HasValue,
() =>
{
RuleFor(player => player.DateOfBirth)
.Must(date => date!.Value.Date < clock.GetUtcNow().Date)
.WithMessage("DateOfBirth must be a date in the past.")
.Must(date =>
date!.Value.Date
>= new DateTime(1900, 1, 1, 0, 0, 0, DateTimeKind.Utc)
)
.WithMessage("DateOfBirth must be on or after January 1, 1900.");
}
);
}
);
// "Update" rule set — PUT /players/squadNumber/{n}
// BeUniqueSquadNumber is intentionally omitted: on PUT the player being
// updated already exists in the database, so the check would always fail.
RuleSet(
"Update",
() =>
{
RuleFor(player => player.FirstName)
.NotEmpty()
.WithMessage("FirstName is required.");
RuleFor(player => player.LastName).NotEmpty().WithMessage("LastName is required.");
RuleFor(player => player.SquadNumber)
.NotEmpty()
.WithMessage("SquadNumber is required.")
.GreaterThan(0)
.WithMessage("SquadNumber must be greater than 0.");
RuleFor(player => player.AbbrPosition)
.NotEmpty()
.WithMessage("AbbrPosition is required.")
.Must(Position.IsValidAbbr)
.WithMessage("AbbrPosition is invalid.");
When(
player => player.DateOfBirth.HasValue,
() =>
{
RuleFor(player => player.DateOfBirth)
.Must(date => date!.Value.Date < clock.GetUtcNow().Date)
.WithMessage("DateOfBirth must be a date in the past.")
.Must(date =>
date!.Value.Date
>= new DateTime(1900, 1, 1, 0, 0, 0, DateTimeKind.Utc)
)
.WithMessage("DateOfBirth must be on or after January 1, 1900.");
}
);
}
);
}
private async Task<bool> BeUniqueSquadNumber(
int squadNumber,
CancellationToken cancellationToken
) => (await _playerRepository.FindBySquadNumberAsync(squadNumber)) is null;
}
@@ -0,0 +1,47 @@
{
"Serilog": {
"Using": ["Serilog.Sinks.Console", "Serilog.Sinks.File"],
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft.EntityFrameworkCore.Database.Command": "Information"
}
},
"WriteTo": [
{
"Name": "Console",
"Args": {
"theme": "Serilog.Sinks.SystemConsole.Themes.AnsiConsoleTheme::Code, Serilog.Sinks.Console",
"outputTemplate": "{Timestamp:HH:mm:ss.fff zzz} [{Level}] {Message:lj}{NewLine}{Exception}"
}
},
{
"Name": "File",
"Args": {
"path": "logs/log-.log",
"rollingInterval": "Day",
"outputTemplate": "{Timestamp:HH:mm:ss.fff zzz} [{Level}] {Message:lj}{NewLine}{Exception}"
}
}
],
"Enrich": ["FromLogContext"]
},
"OpenApiInfo": {
"Version": "1.0.0",
"Title": "Dotnet.Samples.AspNetCore.WebApi",
"Description": "🧪 Proof of Concept for a Web API (Async) made with .NET 8 (LTS) and ASP.NET Core 8.0",
"Contact": {
"Name": "GitHub",
"Url": "https://github.com/nanotaboada/Dotnet.Samples.AspNetCore.WebApi"
},
"License": {
"Name": "MIT License",
"Url": "https://opensource.org/license/mit"
}
},
"RateLimiter": {
"PermitLimit": 60,
"WindowSeconds": 60,
"QueueLimit": 0
}
}
@@ -0,0 +1,47 @@
{
"Serilog": {
"Using": ["Serilog.Sinks.Console", "Serilog.Sinks.File"],
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft.EntityFrameworkCore.Database.Command": "Warning"
}
},
"WriteTo": [
{
"Name": "Console",
"Args": {
"theme": "Serilog.Sinks.SystemConsole.Themes.AnsiConsoleTheme::Code, Serilog.Sinks.Console",
"outputTemplate": "{Timestamp:HH:mm:ss.fff zzz} [{Level}] {Message:lj}{NewLine}{Exception}"
}
},
{
"Name": "File",
"Args": {
"path": "logs/log-.log",
"rollingInterval": "Day",
"outputTemplate": "{Timestamp:HH:mm:ss.fff zzz} [{Level}] {Message:lj}{NewLine}{Exception}"
}
}
],
"Enrich": ["FromLogContext"]
},
"OpenApiInfo": {
"Version": "1.0.0",
"Title": "Dotnet.Samples.AspNetCore.WebApi",
"Description": "🧪 Proof of Concept for a Web API (Async) made with .NET 8 (LTS) and ASP.NET Core 8.0",
"Contact": {
"Name": "GitHub",
"Url": "https://github.com/nanotaboada/Dotnet.Samples.AspNetCore.WebApi"
},
"License": {
"Name": "MIT License",
"Url": "https://opensource.org/license/mit"
}
},
"RateLimiter": {
"PermitLimit": 60,
"WindowSeconds": 60,
"QueueLimit": 0
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
</PropertyGroup>
<ItemGroup Label="Test dependencies">
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.9" PrivateAssets="all" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.7.0" PrivateAssets="all" />
<PackageReference Include="Moq" Version="4.20.72" PrivateAssets="all" />
<PackageReference Include="FluentAssertions" Version="8.10.0" PrivateAssets="all" />
<PackageReference Include="xunit" Version="2.9.3" PrivateAssets="all" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5" PrivateAssets="all" />
<PackageReference Include="coverlet.collector" Version="10.0.1" PrivateAssets="all" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Dotnet.Samples.AspNetCore.WebApi\Dotnet.Samples.AspNetCore.WebApi.csproj" />
</ItemGroup>
<PropertyGroup>
<SatelliteResourceLanguages>en-US</SatelliteResourceLanguages>
</PropertyGroup>
</Project>
@@ -0,0 +1,186 @@
using System.Data.Common;
using Dotnet.Samples.AspNetCore.WebApi.Data;
using Dotnet.Samples.AspNetCore.WebApi.Models;
using Dotnet.Samples.AspNetCore.WebApi.Repositories;
using Dotnet.Samples.AspNetCore.WebApi.Tests.Utilities;
using FluentAssertions;
namespace Dotnet.Samples.AspNetCore.WebApi.Tests.Integration;
/// <summary>
/// Integration tests for <see cref="Repository{T}"/> and <see cref="PlayerRepository"/>.
/// Each test runs against an in-memory SQLite database with the full EF Core migration
/// chain applied via <see cref="DatabaseFakes.MigrateAsync"/>, which also validates
/// that the migration chain itself is healthy as a side effect.
/// </summary>
public class PlayerRepositoryTests : IAsyncLifetime
{
private DbConnection _connection = default!;
private PlayerDbContext _dbContext = default!;
private PlayerRepository _repository = default!;
public async Task InitializeAsync()
{
var (connection, options) = DatabaseFakes.CreateSqliteConnection();
_connection = connection;
_dbContext = DatabaseFakes.CreateDbContext(options);
await _dbContext.MigrateAsync();
_repository = new PlayerRepository(_dbContext);
}
public async Task DisposeAsync()
{
await _dbContext.DisposeAsync();
await _connection.DisposeAsync();
}
/* -------------------------------------------------------------------------
* GetAllAsync
* ---------------------------------------------------------------------- */
[Fact]
[Trait("Category", "Integration")]
public async Task GetAllAsync_WhenCalled_ReturnsAllSeededPlayers()
{
// Act
var players = await _repository.GetAllAsync();
// Assert
players.Should().HaveCount(26);
_dbContext.ChangeTracker.Entries<Player>().Should().BeEmpty();
}
/* -------------------------------------------------------------------------
* FindByIdAsync
* ---------------------------------------------------------------------- */
[Fact]
[Trait("Category", "Integration")]
public async Task FindByIdAsync_ExistingId_ReturnsPlayer()
{
// Arrange — resolve a real ID from the seeded database
var seeded = await _repository.GetAllAsync();
var existingId = seeded[0].Id;
// Act
var player = await _repository.FindByIdAsync(existingId);
// Assert
player.Should().NotBeNull();
player!.Id.Should().Be(existingId);
}
[Fact]
[Trait("Category", "Integration")]
public async Task FindByIdAsync_UnknownId_ReturnsNull()
{
// Act
var player = await _repository.FindByIdAsync(Guid.NewGuid());
// Assert
player.Should().BeNull();
}
/* -------------------------------------------------------------------------
* RemoveAsync
* ---------------------------------------------------------------------- */
[Fact]
[Trait("Category", "Integration")]
public async Task RemoveAsync_ExistingEntity_RemovesFromDatabase()
{
// Arrange
var seeded = await _repository.GetAllAsync();
var existingId = seeded[0].Id;
// Act
await _repository.RemoveAsync(existingId);
// Assert
var player = await _repository.FindByIdAsync(existingId);
player.Should().BeNull();
}
[Fact]
[Trait("Category", "Integration")]
public async Task RemoveAsync_UnknownId_NoExceptionThrown()
{
// Arrange
var countBefore = (await _repository.GetAllAsync()).Count;
// Act
var act = async () => await _repository.RemoveAsync(Guid.NewGuid());
// Assert
await act.Should().NotThrowAsync();
var countAfter = (await _repository.GetAllAsync()).Count;
countAfter.Should().Be(countBefore);
}
/* -------------------------------------------------------------------------
* FindBySquadNumberAsync
* ---------------------------------------------------------------------- */
[Fact]
[Trait("Category", "Integration")]
public async Task FindBySquadNumberAsync_ExistingSquadNumber_ReturnsPlayer()
{
// Arrange
var expected = PlayerFakes.MakeFromStarting11(23);
// Act
var player = await _repository.FindBySquadNumberAsync(expected.SquadNumber);
// Assert
player.Should().NotBeNull();
player!.SquadNumber.Should().Be(expected.SquadNumber);
}
[Fact]
[Trait("Category", "Integration")]
public async Task FindBySquadNumberAsync_UnknownSquadNumber_ReturnsNull()
{
// Arrange — derive a squad number guaranteed not to exist in the seeded data
var seeded = await _repository.GetAllAsync();
var unknownSquadNumber = seeded.Max(p => p.SquadNumber) + 1;
// Act
var player = await _repository.FindBySquadNumberAsync(unknownSquadNumber);
// Assert
player.Should().BeNull();
}
/* -------------------------------------------------------------------------
* SquadNumberExistsAsync
* ---------------------------------------------------------------------- */
[Fact]
[Trait("Category", "Integration")]
public async Task SquadNumberExistsAsync_ExistingSquadNumber_ReturnsTrue()
{
// Arrange
var expected = PlayerFakes.MakeFromStarting11(23);
// Act
var exists = await _repository.SquadNumberExistsAsync(expected.SquadNumber);
// Assert
exists.Should().BeTrue();
}
[Fact]
[Trait("Category", "Integration")]
public async Task SquadNumberExistsAsync_UnknownSquadNumber_ReturnsFalse()
{
// Arrange — derive a squad number guaranteed not to exist in the seeded data
var seeded = await _repository.GetAllAsync();
var unknownSquadNumber = seeded.Max(p => p.SquadNumber) + 1;
// Act
var exists = await _repository.SquadNumberExistsAsync(unknownSquadNumber);
// Assert
exists.Should().BeFalse();
}
}
@@ -0,0 +1,345 @@
using System.Data.Common;
using System.Net;
using System.Net.Http.Json;
using Dotnet.Samples.AspNetCore.WebApi.Data;
using Dotnet.Samples.AspNetCore.WebApi.Migrations;
using Dotnet.Samples.AspNetCore.WebApi.Models;
using Dotnet.Samples.AspNetCore.WebApi.Tests.Utilities;
using FluentAssertions;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.Extensions.DependencyInjection;
namespace Dotnet.Samples.AspNetCore.WebApi.Tests.Integration;
/// <summary>
/// Integration tests for the player endpoints exposed by the web application.
/// Each test exercises the full ASP.NET Core request pipeline — routing,
/// middleware, validation, serialization — via <see cref="WebApplicationFactory{TEntryPoint}"/>
/// backed by an in-memory SQLite database. The factory is created fresh per test
/// (via <see cref="IAsyncLifetime"/>) to ensure state isolation.
/// </summary>
public class PlayerWebApplicationTests : IAsyncLifetime
{
private WebApplicationFactory<Program> _factory = default!;
private WebApplicationFactory<Program> _authorizedFactory = default!;
private HttpClient _client = default!;
private HttpClient _authorizedClient = default!;
private DbConnection _connection = default!;
public Task InitializeAsync()
{
var (connection, _) = DatabaseFakes.CreateSqliteConnection();
_connection = connection;
_factory = new WebApplicationFactory<Program>().WithWebHostBuilder(builder =>
{
builder.ConfigureTestServices(services =>
{
// AddDbContextPool registers three service types. All three must be
// removed before re-registering with AddDbContext; leaving any one
// causes startup to fail when the pool tries to resolve its options.
var descriptors = services
.Where(d =>
d.ServiceType == typeof(DbContextOptions<PlayerDbContext>)
|| d.ServiceType == typeof(PlayerDbContext)
|| (
d.ServiceType.IsGenericType
&& d.ServiceType.GetGenericArguments().Length > 0
&& d.ServiceType.GetGenericArguments()[0] == typeof(PlayerDbContext)
)
)
.ToList();
foreach (var descriptor in descriptors)
services.Remove(descriptor);
services.AddDbContext<PlayerDbContext>(options =>
options
.UseSqlite(_connection)
.ReplaceService<IMigrationsAssembly, ProviderSpecificMigrationsAssembly>()
);
});
});
_client = _factory.CreateClient();
// Derived factory that layers authentication on top of the base factory.
// Used only by tests targeting endpoints protected with [Authorize].
// Keeping auth out of the base factory means unauthenticated tests will
// fail if an endpoint accidentally becomes protected.
_authorizedFactory = _factory.WithWebHostBuilder(builder =>
builder.ConfigureTestServices(services =>
services
.AddAuthentication(TestAuthHandler.SchemeName)
.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(
TestAuthHandler.SchemeName,
_ => { }
)
)
);
_authorizedClient = _authorizedFactory.CreateClient();
return Task.CompletedTask;
}
public async Task DisposeAsync()
{
_authorizedClient.Dispose();
_client.Dispose();
await _authorizedFactory.DisposeAsync();
await _factory.DisposeAsync();
await _connection.DisposeAsync();
}
/* -------------------------------------------------------------------------
* GET /players
* ---------------------------------------------------------------------- */
[Fact]
[Trait("Category", "Integration")]
public async Task Get_Players_Existing_Returns200Ok()
{
// Act
var response = await _client.GetAsync("/players");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var players = await response.Content.ReadFromJsonAsync<List<PlayerResponseModel>>();
players.Should().HaveCount(26);
}
/* -------------------------------------------------------------------------
* GET /players/{id:Guid}
* ---------------------------------------------------------------------- */
[Fact]
[Trait("Category", "Integration")]
public async Task Get_PlayerById_Existing_Returns200Ok()
{
// Arrange — resolve a real ID from the seeded database
await using var scope = _authorizedFactory.Services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<PlayerDbContext>();
var existingId = (await db.Players.FirstAsync()).Id;
// Act
var response = await _authorizedClient.GetAsync($"/players/{existingId}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var player = await response.Content.ReadFromJsonAsync<PlayerResponseModel>();
player.Should().NotBeNull();
}
[Fact]
[Trait("Category", "Integration")]
public async Task Get_PlayerById_Unknown_Returns404NotFound()
{
// Act
var response = await _authorizedClient.GetAsync($"/players/{Guid.NewGuid()}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
var problem = await response.Content.ReadFromJsonAsync<ProblemDetails>();
problem!.Status.Should().Be(StatusCodes.Status404NotFound);
problem.Title.Should().Be("Not Found");
}
/* -------------------------------------------------------------------------
* GET /players/squadNumber/{squadNumber:int}
* ---------------------------------------------------------------------- */
[Fact]
[Trait("Category", "Integration")]
public async Task Get_PlayerBySquadNumber_Existing_Returns200Ok()
{
// Act
var response = await _client.GetAsync("/players/squadNumber/23");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var player = await response.Content.ReadFromJsonAsync<PlayerResponseModel>();
player.Should().NotBeNull();
player!.Dorsal.Should().Be(23);
}
[Fact]
[Trait("Category", "Integration")]
public async Task Get_PlayerBySquadNumber_Unknown_Returns404NotFound()
{
// Act
var response = await _client.GetAsync("/players/squadNumber/999");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
var problem = await response.Content.ReadFromJsonAsync<ProblemDetails>();
problem!.Status.Should().Be(StatusCodes.Status404NotFound);
problem.Title.Should().Be("Not Found");
}
/* -------------------------------------------------------------------------
* POST /players
* ---------------------------------------------------------------------- */
[Fact]
[Trait("Category", "Integration")]
public async Task Post_Players_Nonexistent_Returns201Created()
{
// Arrange — squad number 27 (Lo Celso) is not in the seeded data
var request = PlayerFakes.MakeRequestModelForCreate();
// Act
var response = await _client.PostAsJsonAsync("/players", request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Created);
var player = await response.Content.ReadFromJsonAsync<PlayerResponseModel>();
player.Should().NotBeNull();
player!.Dorsal.Should().Be(request.SquadNumber);
}
// Note: the controller's 409 Conflict branch (squad number already exists) is
// unreachable via the HTTP pipeline. The "Create" validation rule set includes
// BeUniqueSquadNumber, which returns a 422 validation error (Unprocessable Entity)
// before the controller's own duplicate check ever runs. The 409 path is covered
// by the unit test Post_Players_Existing_Returns409Conflict, where validation is
// mocked to pass so the controller logic can be exercised in isolation.
[Fact]
[Trait("Category", "Integration")]
public async Task Post_Players_ValidationError_Returns422UnprocessableEntity()
{
// Arrange — SquadNumber 0 is the int default, fails NotEmpty
var request = PlayerFakes.MakeRequestModelForCreate();
request.SquadNumber = 0;
// Act
var response = await _client.PostAsJsonAsync("/players", request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity);
var problem = await response.Content.ReadFromJsonAsync<ProblemDetails>();
problem!.Status.Should().Be(StatusCodes.Status422UnprocessableEntity);
}
/* -------------------------------------------------------------------------
* PUT /players/squadNumber/{squadNumber:int}
* ---------------------------------------------------------------------- */
[Fact]
[Trait("Category", "Integration")]
public async Task Put_PlayerBySquadNumber_Existing_Returns204NoContent()
{
// Arrange
var request = PlayerFakes.MakeRequestModelForUpdate(23);
// Act
var response = await _client.PutAsJsonAsync("/players/squadNumber/23", request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NoContent);
}
[Fact]
[Trait("Category", "Integration")]
public async Task Put_PlayerBySquadNumber_Unknown_Returns404NotFound()
{
// Arrange — squad number 999 does not exist; body matches route to avoid mismatch error
var request = PlayerFakes.MakeRequestModelForUpdate(23);
request.SquadNumber = 999;
// Act
var response = await _client.PutAsJsonAsync("/players/squadNumber/999", request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
var problem = await response.Content.ReadFromJsonAsync<ProblemDetails>();
problem!.Status.Should().Be(StatusCodes.Status404NotFound);
problem.Title.Should().Be("Not Found");
}
[Fact]
[Trait("Category", "Integration")]
public async Task Put_PlayerBySquadNumber_ValidationError_Returns422UnprocessableEntity()
{
// Arrange — SquadNumber -1 fails GreaterThan(0)
var request = PlayerFakes.MakeRequestModelForUpdate(23);
request.SquadNumber = -1;
// Act
var response = await _client.PutAsJsonAsync("/players/squadNumber/23", request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity);
var problem = await response.Content.ReadFromJsonAsync<ProblemDetails>();
problem!.Status.Should().Be(StatusCodes.Status422UnprocessableEntity);
}
[Fact]
[Trait("Category", "Integration")]
public async Task Put_PlayerBySquadNumber_SquadNumberMismatch_Returns400BadRequest()
{
// Arrange — body squad number (99) differs from route (23); both are valid values
// so validation passes and the mismatch guard in the controller fires instead
var request = PlayerFakes.MakeRequestModelForUpdate(23);
request.SquadNumber = 99;
// Act
var response = await _client.PutAsJsonAsync("/players/squadNumber/23", request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
var problem = await response.Content.ReadFromJsonAsync<ProblemDetails>();
problem!.Status.Should().Be(StatusCodes.Status400BadRequest);
problem.Title.Should().Be("Bad Request");
}
/* -------------------------------------------------------------------------
* DELETE /players/squadNumber/{squadNumber:int}
* ---------------------------------------------------------------------- */
[Fact]
[Trait("Category", "Integration")]
public async Task Delete_PlayerBySquadNumber_Existing_Returns204NoContent()
{
// Act
var response = await _client.DeleteAsync("/players/squadNumber/23");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NoContent);
}
[Fact]
[Trait("Category", "Integration")]
public async Task Delete_PlayerBySquadNumber_Unknown_Returns404NotFound()
{
// Act
var response = await _client.DeleteAsync("/players/squadNumber/999");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
var problem = await response.Content.ReadFromJsonAsync<ProblemDetails>();
problem!.Status.Should().Be(StatusCodes.Status404NotFound);
problem.Title.Should().Be("Not Found");
}
/* -------------------------------------------------------------------------
* GET /health
* ---------------------------------------------------------------------- */
[Fact]
[Trait("Category", "Integration")]
public async Task Get_Health_Healthy_Returns200Ok()
{
// Act
var response = await _client.GetAsync("/health");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
}
}
@@ -0,0 +1,544 @@
using Dotnet.Samples.AspNetCore.WebApi.Controllers;
using Dotnet.Samples.AspNetCore.WebApi.Models;
using Dotnet.Samples.AspNetCore.WebApi.Tests.Utilities;
using FluentAssertions;
using FluentValidation;
using FluentValidation.Results;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using Moq;
namespace Dotnet.Samples.AspNetCore.WebApi.Tests.Unit;
public class PlayerControllerTests : IDisposable
{
private bool _disposed;
public PlayerControllerTests()
{
Environment.SetEnvironmentVariable("ASPNETCORE_ENVIRONMENT", "Development");
}
/* -------------------------------------------------------------------------
* HTTP POST
* ---------------------------------------------------------------------- */
[Fact]
[Trait("Category", "Unit")]
public async Task Post_Players_ValidationError_Returns422UnprocessableEntity()
{
// Arrange
var request = PlayerFakes.MakeRequestModelForCreate();
var (service, logger, validator) = PlayerMocks.InitControllerMocks();
validator
.Setup(validator =>
validator.ValidateAsync(
It.IsAny<IValidationContext>(),
It.IsAny<CancellationToken>()
)
)
.ReturnsAsync(
new ValidationResult(
new List<ValidationFailure>
{
new("SquadNumber", "SquadNumber must be greater than 0."),
}
)
);
var controller = new PlayerController(service.Object, logger.Object, validator.Object);
// Act
var result = await controller.PostAsync(request);
// Assert
service.Verify(service => service.RetrieveBySquadNumberAsync(It.IsAny<int>()), Times.Never);
service.Verify(service => service.CreateAsync(It.IsAny<PlayerRequestModel>()), Times.Never);
validator.Verify(
validator =>
validator.ValidateAsync(
It.IsAny<IValidationContext>(),
It.IsAny<CancellationToken>()
),
Times.Once
);
var httpResult = result.Should().BeOfType<ProblemHttpResult>().Subject;
httpResult.StatusCode.Should().Be(StatusCodes.Status422UnprocessableEntity);
var problemDetails = httpResult
.ProblemDetails.Should()
.BeOfType<HttpValidationProblemDetails>()
.Subject;
problemDetails.Status.Should().Be(StatusCodes.Status422UnprocessableEntity);
problemDetails
.Errors.Should()
.ContainKey("SquadNumber")
.WhoseValue.Should()
.Contain("SquadNumber must be greater than 0.");
}
[Fact]
[Trait("Category", "Unit")]
public async Task Post_Players_Existing_Returns409Conflict()
{
// Arrange
var request = PlayerFakes.MakeRequestModelForCreate();
var response = PlayerFakes.MakeResponseModelForCreate();
var (service, logger, validator) = PlayerMocks.InitControllerMocks();
service
.Setup(service => service.RetrieveBySquadNumberAsync(request.SquadNumber))
.ReturnsAsync(response);
validator
.Setup(validator =>
validator.ValidateAsync(
It.IsAny<IValidationContext>(),
It.IsAny<CancellationToken>()
)
)
.ReturnsAsync(new ValidationResult(new List<ValidationFailure> { }));
var controller = new PlayerController(service.Object, logger.Object, validator.Object);
// Act
var result = await controller.PostAsync(request);
// Assert
service.Verify(service => service.RetrieveBySquadNumberAsync(It.IsAny<int>()), Times.Once);
service.Verify(service => service.CreateAsync(It.IsAny<PlayerRequestModel>()), Times.Never);
validator.Verify(
validator =>
validator.ValidateAsync(
It.IsAny<IValidationContext>(),
It.IsAny<CancellationToken>()
),
Times.Once
);
var httpResult = result.Should().BeOfType<Conflict<ProblemDetails>>().Subject;
httpResult.StatusCode.Should().Be(StatusCodes.Status409Conflict);
}
[Fact]
[Trait("Category", "Unit")]
public async Task Post_Players_Nonexistent_Returns201Created()
{
// Arrange
var request = PlayerFakes.MakeRequestModelForCreate();
var response = PlayerFakes.MakeResponseModelForCreate();
var (service, logger, validator) = PlayerMocks.InitControllerMocks();
service
.Setup(service => service.RetrieveBySquadNumberAsync(request.SquadNumber))
.ReturnsAsync(null as PlayerResponseModel);
service.Setup(service => service.CreateAsync(request)).ReturnsAsync(response);
validator
.Setup(validator =>
validator.ValidateAsync(
It.IsAny<IValidationContext>(),
It.IsAny<CancellationToken>()
)
)
.ReturnsAsync(new ValidationResult(new List<ValidationFailure> { }));
var controller = new PlayerController(service.Object, logger.Object, validator.Object)
{
Url = PlayerMocks.SetupUrlHelperMock().Object,
};
// Act
var result = await controller.PostAsync(request);
// Assert
service.Verify(service => service.RetrieveBySquadNumberAsync(It.IsAny<int>()), Times.Once);
service.Verify(service => service.CreateAsync(It.IsAny<PlayerRequestModel>()), Times.Once);
validator.Verify(
validator =>
validator.ValidateAsync(
It.IsAny<IValidationContext>(),
It.IsAny<CancellationToken>()
),
Times.Once
);
var httpResult = result.Should().BeOfType<CreatedAtRoute<PlayerResponseModel>>().Subject;
httpResult.StatusCode.Should().Be(StatusCodes.Status201Created);
httpResult.Value.Should().BeEquivalentTo(response);
httpResult.RouteName.Should().Be("RetrieveBySquadNumber");
httpResult.RouteValues.Should().NotBeNull().And.ContainKey("squadNumber");
httpResult.RouteValues!["squadNumber"].Should().Be(response.Dorsal);
}
/* -------------------------------------------------------------------------
* HTTP GET
* ---------------------------------------------------------------------- */
[Fact]
[Trait("Category", "Unit")]
public async Task Get_Players_Existing_ReturnsPlayers()
{
// Arrange
var response = PlayerFakes.MakeResponseModelsForRetrieve();
var (service, logger, validator) = PlayerMocks.InitControllerMocks();
service.Setup(service => service.RetrieveAsync()).ReturnsAsync(response);
var controller = new PlayerController(service.Object, logger.Object, validator.Object);
// Act
var result = await controller.GetAsync();
// Assert
service.Verify(service => service.RetrieveAsync(), Times.Once);
var httpResult = result.Should().BeOfType<Ok<List<PlayerResponseModel>>>().Subject;
httpResult.StatusCode.Should().Be(StatusCodes.Status200OK);
httpResult.Value.Should().NotBeNull().And.BeOfType<List<PlayerResponseModel>>();
httpResult.Value.Should().BeEquivalentTo(response);
}
[Fact]
[Trait("Category", "Unit")]
public async Task Get_Players_Empty_Returns200OkWithEmptyList()
{
// Arrange
var (service, logger, validator) = PlayerMocks.InitControllerMocks();
service.Setup(service => service.RetrieveAsync()).ReturnsAsync([]);
var controller = new PlayerController(service.Object, logger.Object, validator.Object);
// Act
var result = await controller.GetAsync();
// Assert
service.Verify(service => service.RetrieveAsync(), Times.Once);
var httpResult = result.Should().BeOfType<Ok<List<PlayerResponseModel>>>().Subject;
httpResult.StatusCode.Should().Be(StatusCodes.Status200OK);
httpResult.Value.Should().NotBeNull().And.BeEmpty();
}
[Fact]
[Trait("Category", "Unit")]
public async Task Get_PlayerById_Unknown_Returns404NotFound()
{
// Arrange
var id = Guid.NewGuid();
var (service, logger, validator) = PlayerMocks.InitControllerMocks();
service
.Setup(service => service.RetrieveByIdAsync(id))
.ReturnsAsync(null as PlayerResponseModel);
var controller = new PlayerController(service.Object, logger.Object, validator.Object);
// Act
var result = await controller.GetByIdAsync(id);
// Assert
service.Verify(service => service.RetrieveByIdAsync(It.IsAny<Guid>()), Times.Once);
var httpResult = result.Should().BeOfType<ProblemHttpResult>().Subject;
httpResult.StatusCode.Should().Be(StatusCodes.Status404NotFound);
}
[Fact]
[Trait("Category", "Unit")]
public async Task Get_PlayerById_Existing_Returns200OK()
{
// Arrange
var id = Guid.NewGuid();
var response = PlayerFakes.MakeResponseModelForRetrieve(10);
var (service, logger, validator) = PlayerMocks.InitControllerMocks();
service.Setup(service => service.RetrieveByIdAsync(id)).ReturnsAsync(response);
var controller = new PlayerController(service.Object, logger.Object, validator.Object);
// Act
var result = await controller.GetByIdAsync(id);
// Assert
service.Verify(service => service.RetrieveByIdAsync(It.IsAny<Guid>()), Times.Once);
var httpResult = result.Should().BeOfType<Ok<PlayerResponseModel>>().Subject;
httpResult.StatusCode.Should().Be(StatusCodes.Status200OK);
httpResult.Value.Should().NotBeNull().And.BeOfType<PlayerResponseModel>();
httpResult.Value.Should().BeEquivalentTo(response);
}
[Fact]
[Trait("Category", "Unit")]
public async Task Get_PlayerBySquadNumber_Unknown_Returns404NotFound()
{
// Arrange
var squadNumber = 999;
var (service, logger, validator) = PlayerMocks.InitControllerMocks();
service
.Setup(service => service.RetrieveBySquadNumberAsync(squadNumber))
.ReturnsAsync(null as PlayerResponseModel);
var controller = new PlayerController(service.Object, logger.Object, validator.Object);
// Act
var result = await controller.GetBySquadNumberAsync(squadNumber);
// Assert
service.Verify(service => service.RetrieveBySquadNumberAsync(It.IsAny<int>()), Times.Once);
var httpResult = result.Should().BeOfType<ProblemHttpResult>().Subject;
httpResult.StatusCode.Should().Be(StatusCodes.Status404NotFound);
}
[Fact]
[Trait("Category", "Unit")]
public async Task Get_PlayerBySquadNumber_Existing_Returns200OK()
{
// Arrange
var squadNumber = 10;
var response = PlayerFakes.MakeResponseModelForRetrieve(squadNumber);
var (service, logger, validator) = PlayerMocks.InitControllerMocks();
service
.Setup(service => service.RetrieveBySquadNumberAsync(squadNumber))
.ReturnsAsync(response);
var controller = new PlayerController(service.Object, logger.Object, validator.Object);
// Act
var result = await controller.GetBySquadNumberAsync(squadNumber);
// Assert
service.Verify(service => service.RetrieveBySquadNumberAsync(It.IsAny<int>()), Times.Once);
var httpResult = result.Should().BeOfType<Ok<PlayerResponseModel>>().Subject;
httpResult.StatusCode.Should().Be(StatusCodes.Status200OK);
httpResult.Value.Should().NotBeNull().And.BeOfType<PlayerResponseModel>();
httpResult.Value.Should().BeEquivalentTo(response);
}
/* -------------------------------------------------------------------------
* HTTP PUT
* ---------------------------------------------------------------------- */
[Fact]
[Trait("Category", "Unit")]
public async Task Put_PlayerBySquadNumber_ValidationError_Returns422UnprocessableEntity()
{
// Arrange
var squadNumber = 20;
var request = PlayerFakes.MakeRequestModelForUpdate(squadNumber);
request.SquadNumber = -999; // Invalid Squad Number
var (service, logger, validator) = PlayerMocks.InitControllerMocks();
var controller = new PlayerController(service.Object, logger.Object, validator.Object);
validator
.Setup(validator =>
validator.ValidateAsync(
It.IsAny<IValidationContext>(),
It.IsAny<CancellationToken>()
)
)
.ReturnsAsync(
new ValidationResult(
new List<ValidationFailure>
{
new("SquadNumber", "SquadNumber must be greater than 0."),
}
)
);
// Act
var result = await controller.PutAsync(squadNumber, request);
// Assert
service.Verify(service => service.RetrieveBySquadNumberAsync(It.IsAny<int>()), Times.Never);
service.Verify(service => service.UpdateAsync(It.IsAny<PlayerRequestModel>()), Times.Never);
validator.Verify(
validator =>
validator.ValidateAsync(
It.IsAny<IValidationContext>(),
It.IsAny<CancellationToken>()
),
Times.Once
);
var httpResult = result.Should().BeOfType<ProblemHttpResult>().Subject;
httpResult.StatusCode.Should().Be(StatusCodes.Status422UnprocessableEntity);
var problemDetails = httpResult
.ProblemDetails.Should()
.BeOfType<HttpValidationProblemDetails>()
.Subject;
problemDetails.Status.Should().Be(StatusCodes.Status422UnprocessableEntity);
problemDetails
.Errors.Should()
.ContainKey("SquadNumber")
.WhoseValue.Should()
.Contain("SquadNumber must be greater than 0.");
}
[Fact]
[Trait("Category", "Unit")]
public async Task Put_PlayerBySquadNumber_Unknown_Returns404NotFound()
{
// Arrange
var squadNumber = 999;
var (service, logger, validator) = PlayerMocks.InitControllerMocks();
service
.Setup(service => service.RetrieveBySquadNumberAsync(squadNumber))
.ReturnsAsync(null as PlayerResponseModel);
validator
.Setup(validator =>
validator.ValidateAsync(
It.IsAny<IValidationContext>(),
It.IsAny<CancellationToken>()
)
)
.ReturnsAsync(new ValidationResult(new List<ValidationFailure> { }));
var controller = new PlayerController(service.Object, logger.Object, validator.Object);
// Act
var result = await controller.PutAsync(
squadNumber,
new PlayerRequestModel { SquadNumber = squadNumber }
);
// Assert
service.Verify(service => service.RetrieveBySquadNumberAsync(It.IsAny<int>()), Times.Once);
service.Verify(service => service.UpdateAsync(It.IsAny<PlayerRequestModel>()), Times.Never);
validator.Verify(
validator =>
validator.ValidateAsync(
It.IsAny<IValidationContext>(),
It.IsAny<CancellationToken>()
),
Times.Once
);
var httpResult = result.Should().BeOfType<ProblemHttpResult>().Subject;
httpResult.StatusCode.Should().Be(StatusCodes.Status404NotFound);
}
[Fact]
[Trait("Category", "Unit")]
public async Task Put_PlayerBySquadNumber_SquadNumberMismatch_Returns400BadRequest()
{
// Arrange
var squadNumber = 23;
var request = PlayerFakes.MakeRequestModelForUpdate(squadNumber);
request.SquadNumber = 99; // Mismatched squad number
var response = PlayerFakes.MakeResponseModelForUpdate(squadNumber);
var (service, logger, validator) = PlayerMocks.InitControllerMocks();
service
.Setup(service => service.RetrieveBySquadNumberAsync(squadNumber))
.ReturnsAsync(response);
validator
.Setup(validator =>
validator.ValidateAsync(
It.IsAny<IValidationContext>(),
It.IsAny<CancellationToken>()
)
)
.ReturnsAsync(new ValidationResult(new List<ValidationFailure> { }));
var controller = new PlayerController(service.Object, logger.Object, validator.Object);
// Act
var result = await controller.PutAsync(squadNumber, request);
// Assert
service.Verify(service => service.RetrieveBySquadNumberAsync(It.IsAny<int>()), Times.Never);
service.Verify(service => service.UpdateAsync(It.IsAny<PlayerRequestModel>()), Times.Never);
var httpResult = result.Should().BeOfType<ProblemHttpResult>().Subject;
httpResult.StatusCode.Should().Be(StatusCodes.Status400BadRequest);
}
[Fact]
[Trait("Category", "Unit")]
public async Task Put_PlayerBySquadNumber_Existing_Returns204NoContent()
{
// Arrange
var squadNumber = 23;
var request = PlayerFakes.MakeRequestModelForUpdate(squadNumber);
var response = PlayerFakes.MakeResponseModelForUpdate(squadNumber);
request.FirstName = "Emiliano";
request.MiddleName = string.Empty;
var (service, logger, validator) = PlayerMocks.InitControllerMocks();
service
.Setup(service => service.RetrieveBySquadNumberAsync(squadNumber))
.ReturnsAsync(response);
service.Setup(service => service.UpdateAsync(request));
validator
.Setup(validator =>
validator.ValidateAsync(
It.IsAny<IValidationContext>(),
It.IsAny<CancellationToken>()
)
)
.ReturnsAsync(new ValidationResult(new List<ValidationFailure> { }));
var controller = new PlayerController(service.Object, logger.Object, validator.Object);
// Act
var result = await controller.PutAsync(squadNumber, request);
// Assert
service.Verify(service => service.RetrieveBySquadNumberAsync(It.IsAny<int>()), Times.Once);
service.Verify(service => service.UpdateAsync(It.IsAny<PlayerRequestModel>()), Times.Once);
var httpResult = result.Should().BeOfType<NoContent>().Subject;
httpResult.StatusCode.Should().Be(StatusCodes.Status204NoContent);
}
/* -------------------------------------------------------------------------
* HTTP DELETE
* ---------------------------------------------------------------------- */
[Fact]
[Trait("Category", "Unit")]
public async Task Delete_PlayerBySquadNumber_Unknown_Returns404NotFound()
{
// Arrange
var squadNumber = 999;
var (service, logger, validator) = PlayerMocks.InitControllerMocks();
service
.Setup(service => service.RetrieveBySquadNumberAsync(squadNumber))
.ReturnsAsync(null as PlayerResponseModel);
var controller = new PlayerController(service.Object, logger.Object, validator.Object);
// Act
var result = await controller.DeleteAsync(squadNumber);
// Assert
service.Verify(service => service.RetrieveBySquadNumberAsync(It.IsAny<int>()), Times.Once);
service.Verify(service => service.DeleteAsync(It.IsAny<int>()), Times.Never);
var httpResult = result.Should().BeOfType<ProblemHttpResult>().Subject;
httpResult.StatusCode.Should().Be(StatusCodes.Status404NotFound);
}
[Fact]
[Trait("Category", "Unit")]
public async Task Delete_PlayerBySquadNumber_Existing_Returns204NoContent()
{
// Arrange
var response = PlayerFakes.MakeResponseModelForCreate();
var squadNumber = response.Dorsal;
var (service, logger, validator) = PlayerMocks.InitControllerMocks();
service
.Setup(service => service.RetrieveBySquadNumberAsync(squadNumber))
.ReturnsAsync(response);
service.Setup(service => service.DeleteAsync(squadNumber));
var controller = new PlayerController(service.Object, logger.Object, validator.Object);
// Act
var result = await controller.DeleteAsync(squadNumber);
// Assert
service.Verify(service => service.RetrieveBySquadNumberAsync(It.IsAny<int>()), Times.Once);
service.Verify(service => service.DeleteAsync(It.IsAny<int>()), Times.Once);
var httpResult = result.Should().BeOfType<NoContent>().Subject;
httpResult.StatusCode.Should().Be(StatusCodes.Status204NoContent);
}
protected virtual void Dispose(bool disposing)
{
if (!_disposed)
{
Environment.SetEnvironmentVariable("ASPNETCORE_ENVIRONMENT", null);
_disposed = true;
}
}
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
}
@@ -0,0 +1,400 @@
using System.Diagnostics;
using Dotnet.Samples.AspNetCore.WebApi.Models;
using Dotnet.Samples.AspNetCore.WebApi.Services;
using Dotnet.Samples.AspNetCore.WebApi.Tests.Utilities;
using FluentAssertions;
using Moq;
namespace Dotnet.Samples.AspNetCore.WebApi.Tests.Unit;
public class PlayerServiceTests
{
/* -------------------------------------------------------------------------
* Create
* ---------------------------------------------------------------------- */
[Fact]
[Trait("Category", "Unit")]
public async Task CreateAsync_WhenCalled_AddsPlayerAndRemovesCache()
{
// Arrange
var request = PlayerFakes.MakeRequestModelForCreate();
var response = PlayerFakes.MakeResponseModelForCreate();
var (repository, logger, memoryCache, mapper, environment) = PlayerMocks.InitServiceMocks();
mapper.Setup(mapper => mapper.Map<PlayerResponseModel>(request)).Returns(response);
var service = new PlayerService(
repository.Object,
logger.Object,
memoryCache.Object,
mapper.Object,
environment.Object
);
// Act
await service.CreateAsync(request);
// Assert
repository.Verify(repository => repository.AddAsync(It.IsAny<Player>()), Times.Once);
memoryCache.Verify(cache => cache.Remove(It.IsAny<object>()), Times.Once);
mapper.Verify(
mapper => mapper.Map<PlayerResponseModel>(It.IsAny<PlayerRequestModel>()),
Times.Once
);
}
/* -------------------------------------------------------------------------
* Retrieve
* ---------------------------------------------------------------------- */
[Fact]
[Trait("Category", "Unit")]
public async Task RetrieveAsync_CacheMiss_QueriesRepositoryAndCachesResult()
{
// Arrange
var value = It.IsAny<object>();
var players = PlayerFakes.MakeStarting11();
var response = PlayerFakes.MakeResponseModelsForRetrieve();
var (repository, logger, memoryCache, mapper, environment) = PlayerMocks.InitServiceMocks();
repository.Setup(repository => repository.GetAllAsync()).ReturnsAsync(players);
mapper.Setup(mapper => mapper.Map<List<PlayerResponseModel>>(players)).Returns(response);
var service = new PlayerService(
repository.Object,
logger.Object,
memoryCache.Object,
mapper.Object,
environment.Object
);
// Act
var result = await service.RetrieveAsync();
// Assert
repository.Verify(repository => repository.GetAllAsync(), Times.Once);
memoryCache.Verify(cache => cache.TryGetValue(It.IsAny<object>(), out value), Times.Once);
memoryCache.Verify(cache => cache.CreateEntry(It.IsAny<object>()), Times.Once);
mapper.Verify(
mapper => mapper.Map<List<PlayerResponseModel>>(It.IsAny<List<Player>>()),
Times.Once
);
result.Should().BeEquivalentTo(response);
}
[Fact]
[Trait("Category", "Unit")]
public async Task RetrieveAsync_CacheHit_ReturnsBeforeQueryingRepository()
{
// Arrange
var value = It.IsAny<object>();
var players = PlayerFakes.MakeStarting11();
var response = PlayerFakes.MakeResponseModelsForRetrieve();
var (repository, logger, memoryCache, mapper, environment) = PlayerMocks.InitServiceMocks();
repository.Setup(repository => repository.GetAllAsync()).ReturnsAsync(players);
mapper.Setup(mapper => mapper.Map<List<PlayerResponseModel>>(players)).Returns(response);
var service = new PlayerService(
repository.Object,
logger.Object,
memoryCache.Object,
mapper.Object,
environment.Object
);
// Act
var first = await ExecutionTimeAsync(() => service.RetrieveAsync());
var second = await ExecutionTimeAsync(() => service.RetrieveAsync());
// Assert
memoryCache.Verify(
cache => cache.TryGetValue(It.IsAny<object>(), out value),
Times.Exactly(2) // first + second
);
memoryCache.Verify(cache => cache.CreateEntry(It.IsAny<object>()), Times.Once); // first only
repository.Verify(repository => repository.GetAllAsync(), Times.Once); // first only
mapper.Verify(
mapper => mapper.Map<List<PlayerResponseModel>>(It.IsAny<List<Player>>()),
Times.Once // first only
);
second.Should().BeLessThan(first);
}
[Fact]
[Trait("Category", "Unit")]
public async Task RetrieveByIdAsync_PlayerNotFound_ReturnsNull()
{
// Arrange
var id = Guid.NewGuid();
var (repository, logger, memoryCache, mapper, environment) = PlayerMocks.InitServiceMocks();
repository.Setup(repository => repository.FindByIdAsync(id)).ReturnsAsync(null as Player);
var service = new PlayerService(
repository.Object,
logger.Object,
memoryCache.Object,
mapper.Object,
environment.Object
);
// Act
var result = await service.RetrieveByIdAsync(id);
// Assert
repository.Verify(repository => repository.FindByIdAsync(It.IsAny<Guid>()), Times.Once);
mapper.Verify(mapper => mapper.Map<PlayerResponseModel>(It.IsAny<Player>()), Times.Never);
result.Should().BeNull();
}
[Fact]
[Trait("Category", "Unit")]
public async Task RetrieveByIdAsync_PlayerFound_ReturnsMappedResponseModel()
{
// Arrange
var id = Guid.NewGuid();
var squadNumber = 10;
var player = PlayerFakes.MakeFromStarting11(squadNumber);
var response = PlayerFakes.MakeResponseModelForRetrieve(squadNumber);
var (repository, logger, memoryCache, mapper, environment) = PlayerMocks.InitServiceMocks();
repository.Setup(repository => repository.FindByIdAsync(id)).ReturnsAsync(player);
mapper.Setup(mapper => mapper.Map<PlayerResponseModel>(player)).Returns(response);
var service = new PlayerService(
repository.Object,
logger.Object,
memoryCache.Object,
mapper.Object,
environment.Object
);
// Act
var result = await service.RetrieveByIdAsync(id);
// Assert
repository.Verify(repository => repository.FindByIdAsync(It.IsAny<Guid>()), Times.Once);
mapper.Verify(mapper => mapper.Map<PlayerResponseModel>(It.IsAny<Player>()), Times.Once);
result.Should().BeOfType<PlayerResponseModel>();
result.Should().BeEquivalentTo(response);
}
[Fact]
[Trait("Category", "Unit")]
public async Task RetrieveBySquadNumberAsync_PlayerNotFound_ReturnsNull()
{
// Arrange
var squadNumber = 999;
var (repository, logger, memoryCache, mapper, environment) = PlayerMocks.InitServiceMocks();
repository
.Setup(repository => repository.FindBySquadNumberAsync(squadNumber))
.ReturnsAsync(null as Player);
var service = new PlayerService(
repository.Object,
logger.Object,
memoryCache.Object,
mapper.Object,
environment.Object
);
// Act
var result = await service.RetrieveBySquadNumberAsync(squadNumber);
// Assert
repository.Verify(
repository => repository.FindBySquadNumberAsync(It.IsAny<int>()),
Times.Once
);
mapper.Verify(mapper => mapper.Map<PlayerResponseModel>(It.IsAny<Player>()), Times.Never);
result.Should().BeNull();
}
[Fact]
[Trait("Category", "Unit")]
public async Task RetrieveBySquadNumberAsync_PlayerFound_ReturnsMappedResponseModel()
{
// Arrange
var squadNumber = 10;
var player = PlayerFakes.MakeFromStarting11(squadNumber);
var response = PlayerFakes.MakeResponseModelForRetrieve(squadNumber);
var (repository, logger, memoryCache, mapper, environment) = PlayerMocks.InitServiceMocks();
repository
.Setup(repository => repository.FindBySquadNumberAsync(squadNumber))
.ReturnsAsync(player);
mapper.Setup(mapper => mapper.Map<PlayerResponseModel>(player)).Returns(response);
var service = new PlayerService(
repository.Object,
logger.Object,
memoryCache.Object,
mapper.Object,
environment.Object
);
// Act
var result = await service.RetrieveBySquadNumberAsync(squadNumber);
// Assert
repository.Verify(
repository => repository.FindBySquadNumberAsync(It.IsAny<int>()),
Times.Once
);
mapper.Verify(mapper => mapper.Map<PlayerResponseModel>(It.IsAny<Player>()), Times.Once);
result.Should().BeOfType<PlayerResponseModel>();
result.Should().BeEquivalentTo(response);
}
/* -------------------------------------------------------------------------
* Update
* ---------------------------------------------------------------------- */
[Fact]
[Trait("Category", "Unit")]
public async Task UpdateAsync_PlayerFound_UpdatesRepositoryAndRemovesCache()
{
// Arrange
var squadNumber = 23;
var player = PlayerFakes.MakeFromStarting11(squadNumber);
var request = PlayerFakes.MakeRequestModelForUpdate(squadNumber);
var (repository, logger, memoryCache, mapper, environment) = PlayerMocks.InitServiceMocks();
repository
.Setup(repository => repository.FindBySquadNumberAsync(squadNumber))
.ReturnsAsync(player);
var service = new PlayerService(
repository.Object,
logger.Object,
memoryCache.Object,
mapper.Object,
environment.Object
);
// Act
await service.UpdateAsync(request);
// Assert
repository.Verify(
repository => repository.FindBySquadNumberAsync(It.IsAny<int>()),
Times.Once
);
repository.Verify(repository => repository.UpdateAsync(It.IsAny<Player>()), Times.Once);
memoryCache.Verify(cache => cache.Remove(It.IsAny<object>()), Times.Once);
mapper.Verify(
mapper => mapper.Map(It.IsAny<PlayerRequestModel>(), It.IsAny<Player>()),
Times.Once
);
}
[Fact]
[Trait("Category", "Unit")]
public async Task UpdateAsync_PlayerNotFound_DoesNotUpdateRepository()
{
// Arrange
var squadNumber = 999;
var request = PlayerFakes.MakeRequestModelForCreate();
request.SquadNumber = squadNumber;
var (repository, logger, memoryCache, mapper, environment) = PlayerMocks.InitServiceMocks();
repository
.Setup(repository => repository.FindBySquadNumberAsync(squadNumber))
.ReturnsAsync(null as Player);
var service = new PlayerService(
repository.Object,
logger.Object,
memoryCache.Object,
mapper.Object,
environment.Object
);
// Act
await service.UpdateAsync(request);
// Assert
repository.Verify(
repository => repository.FindBySquadNumberAsync(It.IsAny<int>()),
Times.Once
);
repository.Verify(repository => repository.UpdateAsync(It.IsAny<Player>()), Times.Never);
memoryCache.Verify(cache => cache.Remove(It.IsAny<object>()), Times.Never);
mapper.Verify(
mapper => mapper.Map(It.IsAny<PlayerRequestModel>(), It.IsAny<Player>()),
Times.Never
);
}
/* -------------------------------------------------------------------------
* Delete
* ---------------------------------------------------------------------- */
[Fact]
[Trait("Category", "Unit")]
public async Task DeleteAsync_PlayerFound_RemovesFromRepositoryAndRemovesCache()
{
// Arrange
var squadNumber = 26;
var player = PlayerFakes.MakeFromStarting11(squadNumber);
var (repository, logger, memoryCache, mapper, environment) = PlayerMocks.InitServiceMocks();
repository
.Setup(repository => repository.FindBySquadNumberAsync(squadNumber))
.ReturnsAsync(player);
var service = new PlayerService(
repository.Object,
logger.Object,
memoryCache.Object,
mapper.Object,
environment.Object
);
// Act
await service.DeleteAsync(squadNumber);
// Assert
repository.Verify(
repository => repository.FindBySquadNumberAsync(It.IsAny<int>()),
Times.Once
);
repository.Verify(repository => repository.RemoveAsync(It.IsAny<Guid>()), Times.Once);
memoryCache.Verify(cache => cache.Remove(It.IsAny<object>()), Times.Once);
}
[Fact]
[Trait("Category", "Unit")]
public async Task DeleteAsync_PlayerNotFound_DoesNotRemoveFromRepository()
{
// Arrange
var squadNumber = 999;
var (repository, logger, memoryCache, mapper, environment) = PlayerMocks.InitServiceMocks();
repository
.Setup(repository => repository.FindBySquadNumberAsync(squadNumber))
.ReturnsAsync(null as Player);
var service = new PlayerService(
repository.Object,
logger.Object,
memoryCache.Object,
mapper.Object,
environment.Object
);
// Act
await service.DeleteAsync(squadNumber);
// Assert
repository.Verify(
repository => repository.FindBySquadNumberAsync(It.IsAny<int>()),
Times.Once
);
repository.Verify(repository => repository.RemoveAsync(It.IsAny<Guid>()), Times.Never);
memoryCache.Verify(cache => cache.Remove(It.IsAny<object>()), Times.Never);
}
private static async Task<long> ExecutionTimeAsync(Func<Task> awaitable)
{
var stopwatch = new Stopwatch();
stopwatch.Start();
await awaitable();
stopwatch.Stop();
return stopwatch.ElapsedMilliseconds;
}
}

Some files were not shown because too many files have changed in this diff Show More