A REST API for a robot that drives around a grid on the Moon. It stores the commands the robot understands, the maps it can drive on, and the people allowed to change them. I built it up task by task through SIT331 at Deakin, from a list in memory to PostgreSQL with sign-in, two roles, paged lists and a request log.
The Swagger page, generated from the XML comments in the controllers. Swagger marks every endpoint with a Basic auth lock.
20
endpoints
10
admin only
160
Newman checks passed (in-memory API)
119 ms
median signed-in request
Context
SIT331 builds one backend across the trimester. Each task adds a layer to the same robot controller API, so the repo holds a folder per task. The robot itself is a console simulator that takes commands such as PLACE, MOVE, LEFT, RIGHT and REPORT. The API is the part I wrote.
It was never deployed, and the repo is private. Everything below comes from a copy of the 7.1P code that I ran on 28 Oct 2026 against a local PostgreSQL, with made-up commands and maps and two test accounts, one admin and one operator.
How it grew
2.1P to 2.2P
A minimal API in memory
Robot commands held in a list and one map size held in Program.cs, served by minimal API routes. 2.2P added a Postman collection to try them.
3.2P
Controllers
The routes moved into a RobotCommandsController and a MapsController under /api. The data was still in memory.
4.1P
PostgreSQL
Hand-written, parameterised SQL through Npgsql. The map table works out whether a map is square in a generated column.
4.2C to 4.4H
Three storage layers behind one interface
4.2C put storage behind an interface, with an ADO class and a generic repository that maps rows to objects with FastMember. 4.3D added EF Core, scaffolded from the database, and logged the SQL that EF writes for each method. One line per interface in Program.cs picks which one runs. 4.4H moved to snake_case tables and columns, with custom scaffolding templates.
5.1P
Swagger
XML comments on each action became the Swagger page, with sample requests and the response codes each action can return. A stylesheet themes the UI. This line of work builds on the 4.1P data classes, not the interface.
6.1P
Users and roles
A user table with BCrypt hashes, a Basic auth handler, and two policies: AdminOnly and UserOnly.
7.1P
Paging and request logs
Page, limit, sort, search and a filter on the three main lists. A middleware logs each request with an ID. The Postman collection ran five times over in Newman, against the small in-memory API.
Who may do what
Anyone signed in can read commands and maps. Only an admin can change them. Here is one create request sent four ways, then an operator trying to change the admin's password.
POST/api/robot-commands (no auth)
-> 401POST/api/robot-commands (Basic, operator)
-> 403POST/api/robot-commands (Basic, admin)
{"name": "HOVER", "isMoveCommand": false, "description": "Hold position for one tick"}
-> 201Location: http://127.0.0.1:3420/api/robot-commands/8POST/api/robot-commands (Basic, admin)
{"name": "hover", "isMoveCommand": false}
-> 409
A command with name 'hover' already exists.
PATCH/api/users/1 (Basic, operator)
-> 403
Requests to the local copy, 28 Oct 2026, trimmed to the request bodies, status lines, Location header and error text. The Basic header is shown only as the account's role.
Lists come back a page at a time. This is the maps list in Swagger after signing in as the operator, two to a page, widest first.
GET /api/maps with Page=1, Limit=2 and Sort=-columns. Five maps in total, so three pages, and an X-Request-Id header.
One endpoint checks whether a cell is on a map. Cells count from 0, so on the 12 by 4 Crater rim map, column 11 is the last one.
Server log lines for the same probe requests as the two blocks above (the maps line is an API call, not the Swagger one), 28 Oct 2026, without the logger's prefix line.
Key technical decisions
a fixed list of sort columns over the sort text in the SQL
Values such as the search text go in as parameters, but a column name cannot be a parameter. So the sort field is looked up in a small dictionary of allowed columns, a leading minus flips it to descending, and anything else sorts by id. Nothing the caller types reaches the SQL as text.
two queries per page over loading the whole table
Each list runs a COUNT with the same filters, then the page itself with LIMIT and OFFSET. Page is at least 1 and limit is held between 1 and 100, so a caller cannot ask for the whole table in one go. The reply carries page, limit, total and totalPages.
policies on each action over role checks inside the code
Every action is tagged AdminOnly or UserOnly, except sign-up. Of the 20 endpoints, 10 are admin only: every create, update and delete on maps and commands, plus both user lists and replacing or deleting a user. The one rule a policy cannot express, that only the owner or an admin may change a user’s email and password, is a check on the caller’s ID and role inside that action.
BCrypt over storing what was sent
Passwords are hashed with BCrypt before they are stored, each with its own salt. The stored hashes start with $2a$11$, so each check does 2^11 rounds of key setup. That is what makes guessing slow, and most of why every signed-in request is slow (see below).
names that ignore case over exact matches
Before a create, the API looks for the name with ILIKE, so HOVER and hover count as the same command and the second gets a 409. Map names work the same way.
What sign-in costs
Basic auth sends the email and password with every request, and the handler checks the password against its BCrypt hash every time. The request log shows what that costs. A request with no header is turned away before any check. A wrong password never reaches the endpoint, yet it takes about as long as a right one, so nearly all of that time goes on looking up the user and checking the BCrypt hash.
Time inside the API per request
Median of 30 requests each to GET /api/robot-commands/1, from the API’s own request log
view data
category
median
no auth header
0.0 ms
wrong password
122.5 ms
signed in
119.0 ms
Measured on 28 Oct 2026 on a MacBook, against the local copy and a local PostgreSQL. With no header the log shows 0 ms for all 30.
Testing
The 7.1P Postman collection has 13 requests with 32 checks between them: status codes, body shapes, the Location header on a create, and that the move list holds only move commands. Newman ran it five times over in the task report, 29 May 2026. I ran it again on 28 Oct 2026 and got the same result.
executed failed
iterations 50
requests 650
test-scripts 650
prerequest-scripts 00
assertions 1600
total run duration: 719ms
total data received: 1.54kB (approx)
average response time: 3ms [min: 1ms, max: 58ms, s.d.: 9ms]
Newman summary from the re-run, 28 Oct 2026, with the table's box lines removed.
That collection tests a small in-memory API kept in the 7.1P folder (/robot-commands, /robot-map), not the PostgreSQL one. The collections kept for the PostgreSQL API send 13 requests each but check nothing, so the checks on this page are the ones I ran by hand.
What I'd do differently
Fix sign-up first. POST /api/users is open to anyone and lets the caller pick their own role, which is how I made the admin test account. The user replies also include the password hash, and any signed-in user can fetch any other user by ID.
Then stop paying for BCrypt on every request: check the password once and hand back a short-lived token. Point the Newman checks at the PostgreSQL API. And update Npgsql, because the build warns that 6.0.5 has a known high-severity advisory.
Stack
APIC# · .NET 10 · ASP.NET Core controllers
DataPostgreSQL · Npgsql (SQL by hand) · EF Core 9 (4.3D to 4.4H)
AuthBasic auth handler · BCrypt.Net-Next · role policies