From 1f1362f1f538aaa96462839b0c0abaaf96cfc87e Mon Sep 17 00:00:00 2001 From: mahmoud karzoun <135722882+mkarson1997@users.noreply.github.com> Date: Fri, 4 Sep 2026 13:53:44 +0300 Subject: [PATCH 1/3] feat: modernize CargoAPI to .NET 10 with containerized local stack --- .dockerignore | 11 ++ .env.example | 3 + .github/workflows/ci.yml | 15 +- .gitignore | 3 + CargoAPI.API/CargoAPI.API.csproj | 4 +- CargoAPI.API/Program.cs | 39 ++++- CargoAPI.API/appsettings.json | 3 + CargoAPI.Business/CargoAPI.Business.csproj | 4 +- .../CargoAPI.DataAccess.csproj | 10 +- CargoAPI.Entities/CargoAPI.Entities.csproj | 4 +- CargoAPI.Tests/CargoAPI.Tests.csproj | 2 +- Dockerfile | 25 +++ README.md | 165 ++++++++++++------ docker-compose.yml | 39 +++++ global.json | 6 + 15 files changed, 254 insertions(+), 79 deletions(-) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 global.json diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..cd9c526 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +**/bin/ +**/obj/ +.vs/ +.idea/ +.git/ +.gitignore +.env +*.user +*.suo +*.nupkg +README.md diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c51ecac --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +# Copy this file to .env before running Docker Compose. +# Use a strong local-only password that satisfies SQL Server complexity requirements. +MSSQL_SA_PASSWORD=ChangeThis_LocalOnly_2026! diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 10ada63..cc35c3a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,19 +18,16 @@ jobs: - name: Checkout uses: actions/checkout@v4 - - name: Setup .NET + - name: Setup .NET 10 uses: actions/setup-dotnet@v4 with: - dotnet-version: '6.0.x' + dotnet-version: '10.0.x' - - name: Restore application + - name: Restore run: dotnet restore CargoAPI.sln - - name: Restore tests - run: dotnet restore CargoAPI.Tests/CargoAPI.Tests.csproj - - - name: Build application + - name: Build run: dotnet build CargoAPI.sln --configuration Release --no-restore - - name: Run unit tests - run: dotnet test CargoAPI.Tests/CargoAPI.Tests.csproj --configuration Release --no-restore --verbosity normal + - name: Test + run: dotnet test CargoAPI.Tests/CargoAPI.Tests.csproj --configuration Release --no-build --verbosity normal diff --git a/.gitignore b/.gitignore index 482cfe6..70c10cd 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,9 @@ obj/ ## Rider .idea/ +## Local environment +.env + ## OS Thumbs.db .DS_Store diff --git a/CargoAPI.API/CargoAPI.API.csproj b/CargoAPI.API/CargoAPI.API.csproj index af4c337..52fb5fb 100644 --- a/CargoAPI.API/CargoAPI.API.csproj +++ b/CargoAPI.API/CargoAPI.API.csproj @@ -1,7 +1,7 @@ - net6.0 + net10.0 enable enable @@ -9,7 +9,7 @@ - + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/CargoAPI.API/Program.cs b/CargoAPI.API/Program.cs index 3b8f51b..6b3db9e 100644 --- a/CargoAPI.API/Program.cs +++ b/CargoAPI.API/Program.cs @@ -7,8 +7,11 @@ var builder = WebApplication.CreateBuilder(args); +var connectionString = builder.Configuration.GetConnectionString("DefaultConnection") + ?? throw new InvalidOperationException("ConnectionStrings:DefaultConnection is required."); + builder.Services.AddDbContext(options => - options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection"))); + options.UseSqlServer(connectionString)); builder.Services.AddScoped(typeof(IGenericRepository<>), typeof(GenericRepository<>)); builder.Services.AddScoped(); @@ -18,38 +21,62 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); -// Hangfire configuration using same SQL Server connection builder.Services.AddHangfire(config => { - config.UseSqlServerStorage(builder.Configuration.GetConnectionString("DefaultConnection")); + config.UseSqlServerStorage(connectionString); }); builder.Services.AddHangfireServer(); builder.Services.AddControllers(); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); +builder.Services.AddHealthChecks(); var app = builder.Build(); +if (builder.Configuration.GetValue("Database:ApplyMigrations")) +{ + using var scope = app.Services.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + await dbContext.Database.MigrateAsync(); +} + if (app.Environment.IsDevelopment()) { app.UseSwagger(); app.UseSwaggerUI(); } -// Global exception handling - must be before other middleware app.UseMiddleware(); -// Hangfire Dashboard app.UseHangfireDashboard("/hangfire"); -// Register recurring job - runs every hour RecurringJob.AddOrUpdate( "carrier-reports", service => service.GenerateReportsAsync(), Cron.Hourly, new RecurringJobOptions { TimeZone = TimeZoneInfo.Local }); +app.MapGet("/health/live", () => Results.Ok(new +{ + status = "ok", + service = "CargoAPI" +})); + +app.MapGet("/health/ready", async (AppDbContext dbContext, CancellationToken cancellationToken) => +{ + if (await dbContext.Database.CanConnectAsync(cancellationToken)) + { + return Results.Ok(new + { + status = "ready", + database = "reachable" + }); + } + + return Results.StatusCode(StatusCodes.Status503ServiceUnavailable); +}); + app.MapControllers(); app.Run(); diff --git a/CargoAPI.API/appsettings.json b/CargoAPI.API/appsettings.json index cca46e4..d8795d0 100644 --- a/CargoAPI.API/appsettings.json +++ b/CargoAPI.API/appsettings.json @@ -2,6 +2,9 @@ "ConnectionStrings": { "DefaultConnection": "Server=(localdb)\\MSSQLLocalDB;Database=CargoDb;Trusted_Connection=True;TrustServerCertificate=True;" }, + "Database": { + "ApplyMigrations": false + }, "Logging": { "LogLevel": { "Default": "Information", diff --git a/CargoAPI.Business/CargoAPI.Business.csproj b/CargoAPI.Business/CargoAPI.Business.csproj index 7aaac8c..2f7abc8 100644 --- a/CargoAPI.Business/CargoAPI.Business.csproj +++ b/CargoAPI.Business/CargoAPI.Business.csproj @@ -1,11 +1,11 @@ - + - net6.0 + net10.0 enable enable diff --git a/CargoAPI.DataAccess/CargoAPI.DataAccess.csproj b/CargoAPI.DataAccess/CargoAPI.DataAccess.csproj index f53b8d5..2ff6cbc 100644 --- a/CargoAPI.DataAccess/CargoAPI.DataAccess.csproj +++ b/CargoAPI.DataAccess/CargoAPI.DataAccess.csproj @@ -1,20 +1,20 @@ - + - - - + + + runtime; build; native; contentfiles; analyzers; buildtransitive all - net6.0 + net10.0 enable enable diff --git a/CargoAPI.Entities/CargoAPI.Entities.csproj b/CargoAPI.Entities/CargoAPI.Entities.csproj index 27ac386..9ed914b 100644 --- a/CargoAPI.Entities/CargoAPI.Entities.csproj +++ b/CargoAPI.Entities/CargoAPI.Entities.csproj @@ -1,7 +1,7 @@ - + - net6.0 + net10.0 enable enable diff --git a/CargoAPI.Tests/CargoAPI.Tests.csproj b/CargoAPI.Tests/CargoAPI.Tests.csproj index 436ef41..60c103c 100644 --- a/CargoAPI.Tests/CargoAPI.Tests.csproj +++ b/CargoAPI.Tests/CargoAPI.Tests.csproj @@ -1,7 +1,7 @@ - net6.0 + net10.0 enable enable false diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2c4c9c0 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,25 @@ +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src + +COPY CargoAPI.sln ./ +COPY CargoAPI.API/CargoAPI.API.csproj CargoAPI.API/ +COPY CargoAPI.Business/CargoAPI.Business.csproj CargoAPI.Business/ +COPY CargoAPI.DataAccess/CargoAPI.DataAccess.csproj CargoAPI.DataAccess/ +COPY CargoAPI.Entities/CargoAPI.Entities.csproj CargoAPI.Entities/ +COPY CargoAPI.Tests/CargoAPI.Tests.csproj CargoAPI.Tests/ +RUN dotnet restore CargoAPI.API/CargoAPI.API.csproj + +COPY . . +RUN dotnet publish CargoAPI.API/CargoAPI.API.csproj \ + --configuration Release \ + --no-restore \ + --output /app/publish + +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime +WORKDIR /app +COPY --from=build /app/publish . + +ENV ASPNETCORE_HTTP_PORTS=8080 +EXPOSE 8080 + +ENTRYPOINT ["dotnet", "CargoAPI.API.dll"] diff --git a/README.md b/README.md index 3f14740..a261466 100644 --- a/README.md +++ b/README.md @@ -2,26 +2,28 @@ [![CI](https://github.com/mkarson1997/CargoAPI/actions/workflows/ci.yml/badge.svg)](https://github.com/mkarson1997/CargoAPI/actions/workflows/ci.yml) -A layered .NET Web API that selects the most cost-effective cargo carrier for an order based on dimensional-weight rules, persists operational data, and generates recurring daily carrier-cost reports in the background. +A layered .NET 10 Web API that selects the most cost-effective cargo carrier for an order based on dimensional-weight rules, persists operational data in SQL Server, and generates recurring carrier-cost reports with Hangfire. -This repository is maintained as a backend engineering case study: business rules live outside controllers, persistence is isolated behind repositories, validation protects domain invariants, Hangfire handles recurring work, and automated tests protect core pricing behavior. - -> **Runtime note:** the current code targets .NET 6. A tracked portfolio-hardening issue covers migration to a supported LTS runtime and containerized local development. +This repository is maintained as a backend engineering case study: business rules live outside controllers, persistence is isolated behind repositories, validation protects domain invariants, automated tests cover core pricing behavior, and the complete development stack can be started with Docker Compose. ## Engineering highlights +- .NET 10 LTS and Entity Framework Core 10 - N-tier architecture with API, Business, DataAccess and Entities projects - Carrier selection based on configurable desi ranges and pricing rules -- Entity Framework Core with SQL Server and Code First migrations +- SQL Server persistence with EF Core Code First migrations - Hangfire recurring background jobs and dashboard - Idempotent-style daily carrier report upsert flow - Swagger/OpenAPI development interface - Global exception handling with safe JSON error responses -- Input validation for names, dimensions and pricing values +- Liveness and database-readiness endpoints +- Optional migration-on-start behavior controlled by configuration - Automated xUnit tests for core `OrderService` behavior - Moq-based dependency isolation in business-layer tests - GitHub Actions build + unit-test validation -- Security, contribution and pull-request documentation +- Multi-stage production-style Docker image +- Docker Compose development stack with SQL Server health gating +- Security, contribution, architecture and domain-rule documentation ## Architecture @@ -30,7 +32,7 @@ Client │ ▼ CargoAPI.API - │ HTTP / validation / middleware / Swagger + │ HTTP / validation / middleware / Swagger / health ▼ CargoAPI.Business │ application and carrier-selection rules @@ -56,11 +58,21 @@ CargoAPI.sln ├── CargoAPI.Tests ├── database/ ├── docs/ +├── Dockerfile +├── docker-compose.yml +├── .env.example +├── global.json ├── .github/workflows/ci.yml ├── SECURITY.md └── CONTRIBUTING.md ``` +See also: + +- [Architecture notes](docs/ARCHITECTURE.md) +- [Domain rules](docs/DOMAIN_RULES.md) +- [Portfolio hardening roadmap](docs/PORTFOLIO_UPGRADE.md) + ## Core business rule When an order is created, the API receives `orderDesi` and evaluates active carrier configurations. @@ -69,9 +81,9 @@ When an order is created, the API receives `orderDesi` and evaluates active carr The system selects the lowest-priced eligible carrier configuration. -### Case 2: desi is outside every configured range +### Case 2: desi is above every configured range -The current implementation chooses the configuration whose `CarrierMaxDesi` is closest and applies the extra-desi calculation: +The documented example applies the extra-desi calculation: ```text finalPrice = carrierPrice + (carrierPlusDesiCost × difference) @@ -88,7 +100,7 @@ Extra desi price: 4 TRY 32 + (4 × 3) = 44 TRY ``` -The behavior for orders below every configured range and for gaps between ranges is intentionally tracked as an explicit domain-rule decision before changing production behavior. +Behavior for orders below every configured range and gaps between ranges remains an explicit product/domain decision rather than being silently frozen by tests. See [issue #3](https://github.com/mkarson1997/CargoAPI/issues/3). ## Background reporting @@ -138,79 +150,126 @@ This keeps reporting work outside the request/response path and demonstrates a b | `GET` | `/api/CarrierReports` | List carrier reports | | `POST` | `/api/CarrierReports/generate` | Trigger report generation manually | +## Health endpoints + +| Route | Meaning | +|---|---| +| `GET /health/live` | Process is running and HTTP is responsive | +| `GET /health/ready` | API can connect to SQL Server | + +The readiness endpoint returns HTTP `503` when the database cannot be reached, making it suitable for container/orchestrator readiness checks. + ## Automated tests -The `CargoAPI.Tests` project currently protects the most important `OrderService` behaviors: +`CargoAPI.Tests` protects key `OrderService` behavior including: -- non-positive desi is rejected without persistence, -- the cheapest carrier is selected when multiple ranges match, -- extra-desi pricing is calculated for the documented above-range case, -- no order is persisted when no carrier configuration exists. +- non-positive desi rejection without persistence, +- exact lower and upper range boundaries, +- cheapest-carrier selection when multiple ranges match, +- documented above-range extra-desi pricing, +- missing configuration failure without persistence. Run the suite: ```bash -dotnet restore CargoAPI.Tests/CargoAPI.Tests.csproj +dotnet restore CargoAPI.sln dotnet test CargoAPI.Tests/CargoAPI.Tests.csproj --configuration Release ``` -The tests use mocks for repositories and logging so the core business behavior can be validated without SQL Server. +The tests use mocks for repositories and logging so business behavior can be validated without SQL Server. -## Quick start +## Quick start: Docker Compose ### Requirements -- .NET 6 SDK for the current repository state -- SQL Server LocalDB, Express or full SQL Server -- `dotnet-ef` +- Docker Desktop or Docker Engine with Compose support -Install EF tooling: +Create your local environment file: ```bash -dotnet tool install --global dotnet-ef +cp .env.example .env ``` -### Configure the database +On Windows PowerShell: -Set `DefaultConnection` in `CargoAPI.API/appsettings.json` for your local environment. Do not commit production credentials. +```powershell +Copy-Item .env.example .env +``` -Example LocalDB configuration: +Edit `.env` and replace the example SQL Server password with a strong local-only password. -```json -{ - "ConnectionStrings": { - "DefaultConnection": "Server=(localdb)\\MSSQLLocalDB;Database=CargoDb;Trusted_Connection=True;TrustServerCertificate=True;" - } -} +Start the complete stack: + +```bash +docker compose up --build +``` + +Compose waits for SQL Server to become healthy before starting the API. The API receives its connection string through environment variables and applies EF Core migrations because the Compose environment explicitly sets: + +```text +Database__ApplyMigrations=true +``` + +Useful local URLs: + +```text +Swagger: http://localhost:8080/swagger +Hangfire: http://localhost:8080/hangfire +Liveness: http://localhost:8080/health/live +Readiness: http://localhost:8080/health/ready +SQL Server: localhost,14333 ``` -Apply migrations: +Stop the stack: ```bash -dotnet ef database update \ - --project CargoAPI.DataAccess \ - --startup-project CargoAPI.API +docker compose down ``` -Or use the idempotent SQL bootstrap script: +Remove the local SQL Server volume as well: ```bash -sqlcmd -S "(localdb)\MSSQLLocalDB" -i database/CargoDb_Create.sql +docker compose down -v ``` -Run the API: +## Quick start: local .NET SDK + +### Requirements + +- .NET 10 SDK +- SQL Server LocalDB, Express or another SQL Server instance +- `dotnet-ef` 10.x for migration commands + +The repository includes `global.json` to keep local and CI SDK selection in the .NET 10 toolchain. + +Install EF tooling if needed: ```bash -dotnet run --project CargoAPI.API +dotnet tool install --global dotnet-ef --version 10.* ``` -Development interfaces: +The committed `appsettings.json` contains only a LocalDB development connection string and does not contain a database password. You can override it without editing tracked files: ```text -Swagger: http://localhost:5246/swagger -Hangfire: http://localhost:5246/hangfire +ConnectionStrings__DefaultConnection= ``` +Apply migrations manually: + +```bash +dotnet ef database update \ + --project CargoAPI.DataAccess \ + --startup-project CargoAPI.API +``` + +Run the API: + +```bash +dotnet run --project CargoAPI.API +``` + +`Database:ApplyMigrations` is `false` by default. Automatic migration is opt-in and enabled by the local Docker Compose configuration only. + ## Example workflow Create a carrier: @@ -259,34 +318,36 @@ Unhandled failures pass through global exception middleware so the API returns a ## CI -Every push and pull request to `main` performs restore, Release build and unit tests. +Every push and pull request to `main` uses the .NET 10 SDK and performs restore, Release build and unit tests: ```bash dotnet restore CargoAPI.sln -dotnet restore CargoAPI.Tests/CargoAPI.Tests.csproj dotnet build CargoAPI.sln --configuration Release --no-restore -dotnet test CargoAPI.Tests/CargoAPI.Tests.csproj --configuration Release --no-restore +dotnet test CargoAPI.Tests/CargoAPI.Tests.csproj --configuration Release --no-build ``` ## Engineering roadmap -- Upgrade the runtime to a currently supported LTS target -- Add integration tests for order and report endpoints -- Add Docker-based SQL Server development environment +- Add API/database integration tests - Resolve and test below-range/gap pricing semantics - Move API contracts toward explicit request/response DTOs where needed - Add authentication/authorization before internet-facing deployment - Add structured logging and request correlation -- Add test coverage reporting +- Add coverage reporting +- Add container image build validation to CI ## Security -This repository is a portfolio/reference project and should not be exposed to the public internet without deployment hardening and authentication. See [SECURITY.md](SECURITY.md) for vulnerability reporting guidance. +This repository is a portfolio/reference project and should not be exposed to the public internet without authentication and deployment hardening. `.env` is ignored and should never be committed. See [SECURITY.md](SECURITY.md) for vulnerability reporting guidance. ## Contributing See [CONTRIBUTING.md](CONTRIBUTING.md). Pull requests use the repository PR checklist and should include validation evidence. +## License + +Project-authored source and documentation are available under the MIT License. Third-party packages and tooling retain their own licenses; see [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md). + --- Built and maintained by [Mahmoud Karzoun](https://github.com/mkarson1997). diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..75076bc --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,39 @@ +services: + sqlserver: + image: mcr.microsoft.com/mssql/server:2022-latest + environment: + ACCEPT_EULA: "Y" + MSSQL_SA_PASSWORD: ${MSSQL_SA_PASSWORD:?Create .env from .env.example and set MSSQL_SA_PASSWORD} + ports: + - "14333:1433" + healthcheck: + test: + [ + "CMD-SHELL", + "/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P \"$$MSSQL_SA_PASSWORD\" -C -Q \"SELECT 1\" -b -o /dev/null" + ] + interval: 10s + timeout: 5s + retries: 12 + start_period: 20s + volumes: + - cargoapi-sql-data:/var/opt/mssql + + api: + build: + context: . + dockerfile: Dockerfile + environment: + ASPNETCORE_ENVIRONMENT: Development + ASPNETCORE_HTTP_PORTS: 8080 + ConnectionStrings__DefaultConnection: >- + Server=sqlserver,1433;Database=CargoDb;User Id=sa;Password=${MSSQL_SA_PASSWORD};Encrypt=True;TrustServerCertificate=True; + Database__ApplyMigrations: "true" + depends_on: + sqlserver: + condition: service_healthy + ports: + - "8080:8080" + +volumes: + cargoapi-sql-data: diff --git a/global.json b/global.json new file mode 100644 index 0000000..01cb658 --- /dev/null +++ b/global.json @@ -0,0 +1,6 @@ +{ + "sdk": { + "version": "10.0.400", + "rollForward": "latestFeature" + } +} From 86cbc0342a7e8d295a0f6939c77d6e10b3787937 Mon Sep 17 00:00:00 2001 From: mahmoud karzoun <135722882+mkarson1997@users.noreply.github.com> Date: Fri, 4 Sep 2026 13:55:00 +0300 Subject: [PATCH 2/3] ci: validate compose config and container build --- .github/workflows/ci.yml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cc35c3a..fbd5957 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,7 +12,7 @@ permissions: jobs: build-and-test: runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 15 steps: - name: Checkout @@ -31,3 +31,11 @@ jobs: - name: Test run: dotnet test CargoAPI.Tests/CargoAPI.Tests.csproj --configuration Release --no-build --verbosity normal + + - name: Validate Docker Compose configuration + env: + MSSQL_SA_PASSWORD: CI_Only_Password_2026! + run: docker compose config --quiet + + - name: Build API container image + run: docker build --tag cargoapi:ci . From 489b519f9bfaadb37f5cab062617a446df68cbfc Mon Sep 17 00:00:00 2001 From: mahmoud karzoun <135722882+mkarson1997@users.noreply.github.com> Date: Fri, 4 Sep 2026 13:55:36 +0300 Subject: [PATCH 3/3] security: restrict Hangfire dashboard to development --- CargoAPI.API/Program.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/CargoAPI.API/Program.cs b/CargoAPI.API/Program.cs index 6b3db9e..76366d3 100644 --- a/CargoAPI.API/Program.cs +++ b/CargoAPI.API/Program.cs @@ -45,12 +45,11 @@ { app.UseSwagger(); app.UseSwaggerUI(); + app.UseHangfireDashboard("/hangfire"); } app.UseMiddleware(); -app.UseHangfireDashboard("/hangfire"); - RecurringJob.AddOrUpdate( "carrier-reports", service => service.GenerateReportsAsync(),