A laboratory management REST API built with .NET and MS SQL Server, following Clean Architecture principles.
A full walkthrough of all features — authentication, reports, filtering, pagination, localization, and PDF generation.
- Architecture
- Tech Stack
- Project Structure
- Getting Started
- Authentication
- Test Accounts
- API Reference
- Filtering & Pagination
- Localization
- Security
- Design Decisions
- License
The solution follows Clean Architecture with a strict dependency rule — dependencies only point inward, never outward.
LabAPI.API ──► LabAPI.Application ──► LabAPI.Domain
│ ▲
└──────► LabAPI.Infrastructure ─────────┘
│
LabAPI.Shared
| Project | Responsibility |
|---|---|
LabAPI.Domain |
Entities, repository interfaces, service interfaces. No external dependencies. |
LabAPI.Application |
Use cases (commands & queries), IUnitOfWork. References Domain only. |
LabAPI.Infrastructure |
EF Core, repository implementations, password hasher, PDF generator. |
LabAPI.API |
Controllers, middleware, Swagger, DI wiring. Composition root. |
LabAPI.Shared |
Result<T>, Error, ReportFilters. Referenced by all layers. |
- .NET 10 — Web API
- MS SQL Server — Database
- EF Core — ORM
- QuestPDF — PDF generation
- Swashbuckle — Swagger / OpenAPI documentation
- PBKDF2 (RFC 2898) — Password hashing (built-in)
LabAPI.Domain/
Entities/ — User, Patient, Session, UserTranslation,
RoleTranslation, Report,
ReportTypeTranslation, ReportStatusTranslation
Repositories/ — All repository interfaces (IUserRepository, etc.)
Services/ — IPdfGenerator, IPasswordHasher
LabAPI.Application/
Auth/
Register/ — RegisterCmd, RegisterCmdHandler
Login/ — LoginCmd, LoginCmdHandler
Logout/ — LogoutCmd, LogoutCmdHandler
ChangePassword/ — ChangePasswordCmd, ChangePasswordCmdHandler
Common/ — AuthResponse
Users/
GetProfile/ — GetProfileQuery, GetProfileQueryHandler, ProfileResponse
Reports/
Create/ — CreateReportCmd, CreateReportCmdHandler
Update/ — UpdateReportCmd, UpdateReportCmdHandler
Pdf/ — GetReportPdfQuery, GetReportPdfQueryHandler
Queries/
GetReport/ — GetReportQuery, GetReportQueryHandler, ReportResponse
GetReports/ — GetReportsQuery, GetReportsQueryHandler, ReportSummaryResponse
LabAPI.Infrastructure/
Persistence/
Configurations/ — EF IEntityTypeConfiguration<T> for every entity
Repositories/ — Concrete repository implementations
Generated/ — Scaffolded EF entities (reference only, not used directly)
LabDbContext.cs
UnitOfWork.cs
Services/
PasswordHasher.cs
PdfGenerator.cs
LabAPI.API/
Controllers/ — AuthController, ReportsController
Middleware/ — SessionMiddleware
Attributes/ — AuthenticatedAttribute, RequireRoleAttribute
Swagger/ — AcceptLanguageHeaderFilter
- .NET 10 SDK
- MS SQL Server (local or remote)
- SSMS or any SQL client (to run the DB script)
Run the full database script against your SQL Server instance. The script creates all tables, indexes, foreign keys, and seeds lookup data (roles, report types, statuses and their translations).
The project is Database First — EF Core maps to the existing schema. There are no EF migrations.
Since only Staff can register patients, you need at least one Staff account seeded directly.
Step 1 — Generate a password hash by running this locally:
var hasher = new LabAPI.Infrastructure.Services.PasswordHasher();
var (hash, salt) = hasher.Hash("YourPassword");
Console.WriteLine($"Hash: {hash}");
Console.WriteLine($"Salt: {salt}");Step 2 — Insert the Staff user:
DECLARE @Hash NVARCHAR(255) = N'<paste hash>';
DECLARE @Salt NVARCHAR(255) = N'<paste salt>';
INSERT INTO dbo.Users (Email, RoleCode, PasswordHash, PasswordSalt)
VALUES (N'staff@lab.com', 'STAFF', @Hash, @Salt);
INSERT INTO dbo.UserTranslations (UserId, Locale, DisplayName) VALUES
(SCOPE_IDENTITY(), 'en', N'Lab Staff'),
(SCOPE_IDENTITY(), 'ar', N'موظف المعمل');Important: Always use the
Nprefix before Arabic string literals in SSMS — otherwise Arabic characters are stored as????.
Edit LabAPI.API/appsettings.json:
{
"ConnectionStrings": {
"LabDb": "Server=YOUR_SERVER;Database=LabDb;Trusted_Connection=True;TrustServerCertificate=True;"
}
}cd LabAPI.API
dotnet runSwagger UI will be available at:
https://localhost:{PORT}/
The API uses session-based authentication with HTTP-only cookies.
POST /api/auth/login— validates credentials, creates a session, sets an HTTP-only cookie (sid) in the response- Every subsequent request — the browser sends the cookie automatically
SessionMiddlewarereads the cookie, validates the session, and loadsCurrentUserintoHttpContext.Items- Endpoints decorated with
[Authenticated]reject requests where no valid session is found - Endpoints decorated with
[RequireRole("STAFF")]reject non-staff users with403 Forbidden POST /api/auth/logout— revokes the session in the DB and clears the cookie
| Field | Value |
|---|---|
staff@lab.com |
|
| Password | (seeded by you — see Getting Started) |
| Role | STAFF |
| Field | Value |
|---|---|
ahmed@lab.com |
|
| Password | password |
| Role | PATIENT |
| Patient No | 000002-3 |
| Method | Endpoint | Auth | Description |
|---|---|---|---|
POST |
/api/auth/register |
Staff only | Register a new patient, returns generated PatientNo |
POST |
/api/auth/login |
Public | Login, sets session cookie |
POST |
/api/auth/logout |
Authenticated | Revoke current session, clear cookie |
PUT |
/api/auth/change-password |
Authenticated | Change password, revokes all active sessions |
GET |
/api/auth/profile |
Authenticated | Get current user profile (localized) |
| Method | Endpoint | Auth | Description |
|---|---|---|---|
POST |
/api/reports |
Staff only | Create a new report |
GET |
/api/reports |
Authenticated | List reports with filters and pagination |
GET |
/api/reports/{accNo} |
Authenticated | Get single report (flips IsSeen for patients) |
PUT |
/api/reports/{accNo} |
Staff only | Update report |
GET |
/api/reports/{accNo}/pdf?lang=en |
Authenticated | View PDF inline in browser |
GET |
/api/reports/{accNo}/pdf/download?lang=en |
Authenticated | Download PDF as file |
Role restriction on reports: Patients can only see their own reports. Staff can see all reports.
| Parameter | Type | Description |
|---|---|---|
PatientName |
string | Partial match on localized display name |
PatientNo |
string | Exact match |
VisitDateFrom |
datetime | Range start |
VisitDateTo |
datetime | Range end |
AccNoFrom |
long | Range start |
AccNoTo |
long | Range end |
ReferenceNo |
string | Exact match |
ResultDateFrom |
datetime | Range start |
ResultDateTo |
datetime | Range end |
MobileNo |
string | Exact match |
IsSeen |
bool | true or false |
StatusCode |
string | PENDING or READY |
| Parameter | Default | Description |
|---|---|---|
page |
1 |
Page number |
pageSize |
20 |
Items per page |
| Header | Description |
|---|---|
X-Total-Count |
Total number of matching records |
X-Total-Pages |
Total number of pages |
X-Page |
Current page |
X-Page-Size |
Current page size |
The API supports English (en) and Arabic (ar) via a lang query parameter.
GET /api/reports?lang=ar
GET /api/auth/profile?lang=ar
GET /api/reports/220001/pdf?lang=ar
Localized fields:
DisplayName— patient name in profile and reportsRoleLabel— in profile responseReportType— in report responsesStatus— in report responses
If lang is missing or unsupported, the API defaults to en.
The
langquery parameter approach was chosen over theAccept-Languageheader because browsers override that header with their own locale when navigating directly to a URL (like opening a PDF in a browser tab), making the header unreliable for PDF endpoints.
| Concern | Implementation |
|---|---|
| Password storage | PBKDF2 with SHA-256, 350,000 iterations, random 32-byte salt per user |
| Session token | 64-character random hex string (two Guid.NewGuid().ToString("N")) |
| XSS protection | Session token stored in HttpOnly cookie — inaccessible to JavaScript |
| HTTPS enforcement | Cookie marked Secure — only sent over HTTPS |
| CSRF protection | Cookie marked SameSite=Strict — not sent on cross-site requests |
| Session expiry | Sessions expire after 24 hours (ExpiresAt enforced server-side) |
| Logout | Sets RevokedAt on the session in DB and deletes the cookie |
| Password change | Revokes all active sessions for the user, forces re-login |
| Credential enumeration | Login returns the same error for wrong email or wrong password |
| Role authorization | [RequireRole] attribute enforces role checks at the action level |
Browsers automatically set the Accept-Language header to their own locale (e.g. en-US) when navigating directly to a URL, overriding any intended locale. This caused PDF view to always render in English regardless of the requested language. The lang query parameter gives explicit, reliable control from both API clients and direct browser navigation.
The GET /api/reports endpoint uses batch loading — all user translations, report type translations, and status translations for a page are fetched in single WHERE IN (...) queries, then resolved via dictionary lookup. Total DB calls: 5, regardless of page size.
Domain has zero external dependencies — no EF, HTTP, third-party packages. Infrastructure implements all contracts defined in Domain. The API layer is the composition root and the only place that references Infrastructure directly.
MIT License
Copyright (c) 2026 Lab API