first commit
This commit is contained in:
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(dotnet *)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
||||||
@@ -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!
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
logs/
|
||||||
|
bin/
|
||||||
|
obj/
|
||||||
|
TestResults/
|
||||||
|
storage/*.db
|
||||||
|
.env
|
||||||
|
.claude/settings.local.json
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"MD013": false,
|
||||||
|
"MD024": {
|
||||||
|
"siblings_only": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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.
BIN
Binary file not shown.
Binary file not shown.
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": ""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
+257
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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>.
|
||||||
@@ -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
@@ -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"]
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,355 @@
|
|||||||
|
# 🧪 RESTful API with .NET and ASP.NET Core
|
||||||
|
|
||||||
|
[](https://github.com/nanotaboada/Dotnet.Samples.AspNetCore.WebApi/actions/workflows/dotnet-ci.yml)
|
||||||
|
[](https://github.com/nanotaboada/Dotnet.Samples.AspNetCore.WebApi/actions/workflows/dotnet-cd.yml)
|
||||||
|
[](https://github.com/nanotaboada/Dotnet.Samples.AspNetCore.WebApi/actions/workflows/github-code-scanning/codeql)
|
||||||
|
[](https://sonarcloud.io/summary/new_code?id=nanotaboada_Dotnet.Samples.AspNetCore.WebApi)
|
||||||
|
[](https://codecov.io/gh/nanotaboada/Dotnet.Samples.AspNetCore.WebApi)
|
||||||
|
[](https://www.codefactor.io/repository/github/nanotaboada/Dotnet.Samples.AspNetCore.WebApi)
|
||||||
|
[](https://opensource.org/licenses/MIT)
|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
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
@@ -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
|
||||||
|
```
|
||||||
@@ -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
@@ -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\/.*
|
||||||
@@ -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),
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"sdk": {
|
||||||
|
"version": "10.0.100",
|
||||||
|
"rollForward": "latestFeature"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 "$@"
|
||||||
@@ -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."
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
+69
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+222
@@ -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")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+429
@@ -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")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+68
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+53
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+68
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+251
@@ -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")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+68
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+327
@@ -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
+29
@@ -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
Reference in New Issue
Block a user