Skip to main content
Published / updated

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.

ConceptUsed later in
Dependency injectionWiring repositories and services
Middleware orderCORS, authentication, error handling
DTOsWhat the frontend receives, stages 6 and 7
ValidationRejecting bad input before it reaches a service
Status codesHow the frontend knows what happened
CORSThe browser calling a different origin, stage 7
JWTWho the caller is, and which school they belong to

Three rules established here recur in every remaining stage:

  • SchoolId comes 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. Teacher carries Salary; Student carries ParentPhone. 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:

  • schoolId comes from User.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. FeeAccountNotFoundException is 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

TopicRead
Program.cs and fundamentalsTrack 10 — Fundamentals and Program
Middleware and the pipelineTrack 10 — Middleware and pipeline
Configuration and environmentsTrack 10 — Configuration and environments
Dependency injectionTrack 10 — Dependency injection
Routing and controllersTrack 10 — Routing and controllers
MVC and Razor PagesTrack 10 — MVC and Razor Pages
Model binding and validationTrack 10 — Model binding and validation
REST designTrack 10 — REST API design
Dapper integrationTrack 10 — Data access with Dapper
Errors and loggingTrack 10 — Error handling and logging
CORS, JWT, authorisationTrack 10 — Security, CORS and JWT
The API capstoneTrack 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

  1. Work through Track 10's twelve articles.
  2. Build the fee payment controller, service and DTOs above.
  3. Confirm RecordPaymentRequest has no SchoolId field.
  4. Send a payment of 0 and of -5000, and read both 400 responses.
  5. Take schoolId from the query string and demonstrate the cross-school leak. Then fix it.
  6. Return the Teacher entity directly from an endpoint and check whether Salary appears in the JSON.
  7. Swap UseAuthentication and UseAuthorization, and confirm every request returns 401.
  8. Move UseCors after UseAuthentication and confirm CORS fails only on authenticated calls.
  9. Call an endpoint as a Teacher that requires Admin, and confirm a 403 rather than a 200.
  10. Add a global exception handler and confirm a 500 logs the full exception but returns a generic message.
  11. Build a Postman collection with an environment and an automatic token capture.
  12. 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 SchoolId from the token, never the request
  • Return DTOs rather than entities
  • Secure endpoints with JWT and role checks

Review questions

  1. Why must SchoolId come from the token rather than the request?
  2. What breaks when UseCors is placed after UseAuthentication?
  3. Why is returning an entity directly a risk?
  4. Why does the controller contain no business rules?

Next: Web development foundation