ASP.NET Core Web API
Before you start
You need: stage 4 — the repositories this stage exposes over HTTP.
Time: 40–48 classes. The largest backend stage.
Learning objective
Expose the repositories from stage 4 as a validated, secured REST API that a browser can call.
Topics
Program.cs, middleware and the request pipeline- Dependency injection and configuration
- Routing, controllers and action results
- Model binding, DTOs and validation
- REST design and status codes
- Error handling and logging
- CORS
- JWT authentication and authorisation
What this stage covers
This is where the application becomes reachable. Everything so far runs in a console; from here it responds to HTTP.
It is also where security starts being real. A console application has one user — you. An API has whoever can reach the URL.
| Concept | Used later in |
|---|---|
| Dependency injection | Wiring repositories and services |
| Middleware order | CORS, authentication, error handling |
| DTOs | What the frontend receives, stages 6 and 7 |
| Validation | Rejecting bad input before it reaches a service |
| Status codes | How the frontend knows what happened |
| CORS | The browser calling a different origin, stage 7 |
| JWT | Who the caller is, and which school they belong to |
Three rules established here recur in every remaining stage:
SchoolIdcomes from the token claim, never from a request parameter. An endpoint accepting?schoolId=lets any signed-in user read any school's data — a 200 response, plausible data, no error anywhere.- Validation on the server is the control; validation in the browser is a convenience. Postman bypasses every browser check.
- Never return an entity directly.
TeachercarriesSalary;StudentcarriesParentPhone. Project into a DTO that carries only what the caller may see.
Worked flow: the fee receipt
[ApiController]
[Authorize]
[Route("api/fee-payments")]
public class FeePaymentController : ControllerBase
{
private readonly IFeeService _feeService;
private readonly ILogger<FeePaymentController> _logger;
public FeePaymentController(IFeeService feeService, ILogger<FeePaymentController> logger)
{
_feeService = feeService;
_logger = logger;
}
[HttpGet("{paymentId:int}/receipt")]
[Authorize(Roles = "Admin,Principal,Staff")]
public async Task<IActionResult> GetReceipt(int paymentId)
{
int schoolId = int.Parse(User.FindFirst("schoolId").Value);
FeeReceiptDto receipt = await _feeService.GetReceiptAsync(schoolId, paymentId);
if (receipt == null)
{
return NotFound(new { message = "Receipt not found." });
}
return Ok(receipt);
}
[HttpPost]
[Authorize(Roles = "Admin,Staff")]
public async Task<IActionResult> RecordPayment([FromBody] RecordPaymentRequest request)
{
int schoolId = int.Parse(User.FindFirst("schoolId").Value);
try
{
FeeReceiptDto receipt = await _feeService.RecordPaymentAsync(schoolId, request);
return CreatedAtAction(nameof(GetReceipt), new { paymentId = receipt.PaymentId }, receipt);
}
catch (FeeAccountNotFoundException ex)
{
_logger.LogWarning(ex, "No fee account for student {StudentId} in school {SchoolId}",
request.StudentId, schoolId);
return NotFound(new { message = "No fee account exists for this student." });
}
catch (InvalidOperationException ex)
{
return BadRequest(new { message = ex.Message });
}
}
}
Five things in that controller are deliberate:
schoolIdcomes fromUser.FindFirst("schoolId"). It is not a route parameter, a query parameter or a body field. A caller cannot supply it.[Authorize(Roles = ...)]differs per action. A Staff member may record a payment; only Admin and Principal may do everything else.- It returns a DTO, not the entity.
- Specific exceptions map to specific status codes.
FeeAccountNotFoundExceptionis a 404; an invalid amount is a 400. Anything unanticipated reaches the global handler as a 500. - The controller contains no business rules and no SQL. It reads the claim, calls the service, chooses a status code.
public class RecordPaymentRequest
{
[Required]
public int StudentId { get; set; }
[Required]
[Range(0.01, 1000000)]
public decimal Amount { get; set; }
[Required]
public PaymentMode PaymentMode { get; set; }
[StringLength(50)]
public string TransactionId { get; set; }
}
No SchoolId on the request. That is the point.
[Range(0.01, ...)] rejects zero and negative amounts before the service runs. With [ApiController], a failing rule returns a 400 with an errors object naming the field.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddScoped<IDbConnectionFactory, SqlConnectionFactory>();
builder.Services.AddScoped<IFeeRepository, FeeRepository>();
builder.Services.AddScoped<IFeeService, FeeService>();
builder.Services.AddCors(options =>
{
options.AddPolicy("SchoolPortal", policy =>
{
policy.WithOrigins("http://localhost:5173").AllowAnyHeader().AllowAnyMethod();
});
});
var app = builder.Build();
app.UseExceptionHandler("/error");
app.UseHttpsRedirection();
app.UseRouting();
app.UseCors("SchoolPortal");
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();
Middleware order is behaviour, not style. UseAuthorization before UseAuthentication returns 401 for every authenticated request. UseCors after UseAuthentication produces CORS errors only on authenticated calls. The exception handler must be first, because it can only catch what runs after it.
Where to learn it
| Topic | Read |
|---|---|
Program.cs and fundamentals | Track 10 — Fundamentals and Program |
| Middleware and the pipeline | Track 10 — Middleware and pipeline |
| Configuration and environments | Track 10 — Configuration and environments |
| Dependency injection | Track 10 — Dependency injection |
| Routing and controllers | Track 10 — Routing and controllers |
| MVC and Razor Pages | Track 10 — MVC and Razor Pages |
| Model binding and validation | Track 10 — Model binding and validation |
| REST design | Track 10 — REST API design |
| Dapper integration | Track 10 — Data access with Dapper |
| Errors and logging | Track 10 — Error handling and logging |
| CORS, JWT, authorisation | Track 10 — Security, CORS and JWT |
| The API capstone | Track 10 — API project |
Tools: Track 15 — Postman and Swagger.
Stage exercises
From the guided path syllabus:
Create CRUD endpoints for Student and FeePayment, returning correct status codes — 200, 201, 400, 401, 403, 404.
Validate a request DTO. Add [Required] and [Range] to RecordPaymentRequest and confirm a 400 with a named field.
Test endpoints in Postman and Swagger. Build a collection with an environment, capture the token in a test script, and call every endpoint including the failure cases.
Add a service layer. FeeService holds the rules — a payment may not exceed the balance, a receipt number is generated once. The controller holds none of them.
Debugging drills
Trace a 400, a 401 and a 500. Send a payment with no Amount and read the errors object. Remove the Authorization header. Force an unhandled exception and find it in the API log, not the browser.
Inspect a dependency-injection error. Remove one AddScoped registration and read the startup exception naming the unresolvable service.
Debug API → Dapper → SQL. Enable SQL logging, call the receipt endpoint, read the generated statement and its parameters, then run the same statement in SSMS.
Prove the tenant bug. Change the endpoint to take [FromQuery] int schoolId, sign in as School 1, request School 2's receipt, and confirm you get a 200 with the wrong school's data. Then revert to the claim.
Practice
- Work through Track 10's twelve articles.
- Build the fee payment controller, service and DTOs above.
- Confirm
RecordPaymentRequesthas noSchoolIdfield. - Send a payment of
0and of-5000, and read both 400 responses. - Take
schoolIdfrom the query string and demonstrate the cross-school leak. Then fix it. - Return the
Teacherentity directly from an endpoint and check whetherSalaryappears in the JSON. - Swap
UseAuthenticationandUseAuthorization, and confirm every request returns 401. - Move
UseCorsafterUseAuthenticationand confirm CORS fails only on authenticated calls. - Call an endpoint as a Teacher that requires Admin, and confirm a 403 rather than a 200.
- Add a global exception handler and confirm a 500 logs the full exception but returns a generic message.
- Build a Postman collection with an environment and an automatic token capture.
- Open Swagger, authorise with a token, and call every endpoint.
Exercises 5 and 6 are data-exposure bugs that return 200.
You can now
- Build a validated REST API with correct status codes
- Wire services with dependency injection
- Order middleware correctly and say what each ordering breaks
- Take
SchoolIdfrom the token, never the request - Return DTOs rather than entities
- Secure endpoints with JWT and role checks
Review questions
- Why must
SchoolIdcome from the token rather than the request? - What breaks when
UseCorsis placed afterUseAuthentication? - Why is returning an entity directly a risk?
- Why does the controller contain no business rules?