20 KiB
Xpensely Server — API Reference
Last updated: 2026-05-14 · Branch:
dev
Table of Contents
- Overview
- Authentication
- Rate Limiting
- Endpoints
- 4.1 Home
- 4.2 Users
- 4.3 Expense Lists
- Data Models
- Error Handling
- Recent Changes —
feature/security-hardening
1. Overview
Xpensely Server is a Spring Boot REST API that manages shared expense lists for pairs of users. It uses Google OAuth2 JWT tokens for authentication. All protected endpoints require a valid Bearer token in the Authorization header.
Base URL (local dev): http://localhost:8080
Content-Type: application/json for all request and response bodies.
Public endpoints (no auth required):
| Method | Path | Description |
|---|---|---|
| GET | / |
Health check — returns "Welcome" |
| GET | /api/version |
Returns build version and timestamp |
| POST | /api/users/createUser |
Register a new user |
| GET | /api/users/byName |
Look up a user by username |
All other endpoints require authentication (see Section 2).
2. Authentication
The server uses OAuth2 Resource Server authentication with Google ID JWT tokens.
How it works
- The client authenticates with Google and receives a Google ID JWT.
- Every protected API request must include this token in the header:
Authorization: Bearer <google-id-jwt>
- The server validates the JWT signature and extracts the
subclaim (Google User ID). - The
subvalue is used to look up the registeredAppUserin the database viaAuthenticatedUserResolver. - If no
AppUserexists for that Google ID, the request is rejected with 403 Forbidden.
Test profile
When the application runs under the test Spring profile (-Dspring.profiles.active=test), all security is disabled — every endpoint is accessible without a token. This is used for automated tests only.
User registration flow
Before a user can call any protected endpoint they must first be registered:
- Authenticate with Google to obtain a Google ID JWT.
- Call
POST /api/users/createUserwith the JWT'ssubvalue asgoogleIdand a chosenusername. - All subsequent protected calls use the same JWT — the server resolves the caller automatically.
3. Rate Limiting
All requests pass through a RateLimitFilter (implemented with Bucket4j).
| Setting | Value |
|---|---|
| Limit | 60 requests per minute |
| Window | Rolling 1-minute bucket |
| Key (authenticated) | JWT sub claim (Google User ID) |
| Key (unauthenticated) | X-Forwarded-For header, falling back to remote IP |
When the limit is exceeded the server responds with:
HTTP 429 Too Many Requests
No Retry-After header is currently returned. Clients should back off and retry after 60 seconds.
Note: Rate limiting applies in the
!testprofile only. Tests run without rate limiting.
4. Endpoints
4.1 Home
GET /
Health check. No authentication required.
Response: 200 OK
Welcome
GET /api/version
Returns the application version and build timestamp. No authentication required.
Response: 200 OK
{
"version": "0.0.1-SNAPSHOT",
"builtAt": "2026-05-09T10:00:00Z"
}
| Field | Type | Notes |
|---|---|---|
version |
String | Maven project version |
builtAt |
String (ISO-8601) | UTC timestamp of the build |
4.2 Users
Base path: /api/users
POST /api/users/createUser — Register a user
Auth required: No
Request body:
{
"username": "alice",
"googleId": "118400012345678901234"
}
| Field | Type | Constraints |
|---|---|---|
username |
String | Required. 3–30 chars. Pattern: ^[a-zA-Z0-9_.\-]+$ |
googleId |
String | Required. Non-blank. Must match the JWT sub from Google. |
Success response: 201 Created — returns the created AppUser object.
Error responses:
| Status | Condition |
|---|---|
| 400 | Validation failure (field errors returned as {"fieldName": "message"}) |
| 409 | username already taken |
GET /api/users — Get user by ID
Auth required: Yes
Query params:
| Param | Type | Required | Description |
|---|---|---|---|
id |
Long | Yes | Database ID of the user |
Success response: 200 OK — returns AppUser.
Error responses:
| Status | Condition |
|---|---|
| 403 | Authenticated user's ID does not match the requested id |
| 404 | No user found for id |
GET /api/users/byName — Get user by username
Auth required: No
Query params:
| Param | Type | Required | Description |
|---|---|---|---|
username |
String | Yes | Exact username (case-sensitive) |
Success response: 200 OK — returns AppUser.
Error responses:
| Status | Condition |
|---|---|
| 404 | No user found for username |
GET /api/users/byGoogleId — Get user by Google ID
Auth required: Yes
Query params:
| Param | Type | Required | Description |
|---|---|---|---|
id |
String | Yes | Google sub claim |
Success response: 200 OK — returns AppUser.
Error responses:
| Status | Condition |
|---|---|
| 403 | Requested Google ID does not match the authenticated user's Google ID |
| 404 | No user found for that Google ID |
DELETE /api/users — Delete a user
Auth required: Yes
Query params:
| Param | Type | Required | Description |
|---|---|---|---|
id |
Long | Yes | Database ID of the user to delete |
Success response: 200 OK — returns a plain string: "User deleted: <username>".
Error responses:
| Status | Condition |
|---|---|
| 403 | Authenticated user's ID does not match the requested id |
| 404 | No user found for id |
4.3 Expense Lists
Base path: /api/expenselist
All endpoints in this group require authentication. The authenticated caller is resolved from the JWT and used for ownership checks.
GET /api/expenselist/mine — Get caller's expense lists
Returns all expense lists where the caller is the owner or has been shared the list.
Auth required: Yes
Request body: None
Success responses:
| Status | Condition |
|---|---|
| 200 OK | Returns array of ExpenseList |
| 204 No Content | Caller has no expense lists |
[
{
"id": 1,
"name": "Holiday Trip",
"inviteCode": null,
"owner": { "id": 1, "username": "alice" },
"sharedWith": null,
"expenses": [],
"customCategories": []
}
]
GET /api/expenselist/byId — Get expense list by ID
Auth required: Yes
Query params:
| Param | Type | Required | Description |
|---|---|---|---|
id |
Long | Yes | Expense list database ID |
The caller must be the owner or the shared user of the list.
Success response: 200 OK — returns ExpenseList.
Error responses:
| Status | Condition |
|---|---|
| 403 | Caller is neither owner nor shared user |
| 404 | No list found for id |
POST /api/expenselist/create — Create an expense list
Auth required: Yes
Request body:
{
"name": "Road Trip 2026"
}
| Field | Type | Constraints |
|---|---|---|
name |
String | Required. Max 100 chars. |
The authenticated caller becomes the owner of the new list. The list is initialised with the default Xpensely standard categories.
Success response: 201 Created — returns the created ExpenseList.
Error responses:
| Status | Condition |
|---|---|
| 400 | Validation failure |
DELETE /api/expenselist/{id} — Delete an expense list
Auth required: Yes
Path params:
| Param | Type | Description |
|---|---|---|
id |
Long | Expense list database ID |
Only the owner may delete a list. Deleting a list cascades to all its expenses and custom categories.
Success response: 204 No Content
Error responses:
| Status | Condition |
|---|---|
| 403 | Caller is not the owner |
| 404 | No list found for id |
POST /api/expenselist/{id}/add — Add an expense to a list
Auth required: Yes
Path params:
| Param | Type | Description |
|---|---|---|
id |
Long | Expense list database ID |
Request body:
{
"title": "Dinner",
"owner": "alice",
"amount": 42.50,
"personalUseAmount": 21.25,
"otherPersonAmount": 21.25,
"date": "2026-05-09",
"category": "Food"
}
| Field | Type | Constraints |
|---|---|---|
title |
String | Required. Max 100 chars. |
owner |
String | Required. Username of the person who paid. |
amount |
Double | Required. Min 0.01. |
personalUseAmount |
Double | Optional. Caller's share. |
otherPersonAmount |
Double | Optional. Other person's share. |
date |
String (ISO-8601) | Required. Format: YYYY-MM-DD. |
category |
String | Required. Non-blank category name. |
Success response: 201 Created — returns the created Expense.
Error responses:
| Status | Condition |
|---|---|
| 400 | Validation failure |
| 403 | Caller is not a member (owner or sharedWith) of the list |
| 404 | List not found |
PUT /api/expenselist/{id}/update — Update an expense
Auth required: Yes
Path params:
| Param | Type | Description |
|---|---|---|
id |
Long | Expense list database ID |
Request body:
{
"id": 7,
"title": "Dinner (updated)",
"ownerName": "alice",
"amount": 50.00,
"personalUseAmount": 25.00,
"otherPersonAmount": 25.00,
"date": "2026-05-09",
"category": "Food"
}
| Field | Type | Constraints |
|---|---|---|
id |
Long | Required. ID of the expense to update. |
title |
String | Required. Max 100 chars. |
ownerName |
String | Required. Username of the payer. |
amount |
Double | Required. Min 0.01. |
personalUseAmount |
Double | Optional. |
otherPersonAmount |
Double | Optional. |
date |
String (ISO-8601) | Required. YYYY-MM-DD. |
category |
String | Required. |
Caller must be a member of the list. Expense must belong to the specified list.
Success response: 200 OK — returns the updated Expense.
Error responses:
| Status | Condition |
|---|---|
| 400 | Validation failure or expense does not belong to this list |
| 403 | Caller is not a member of the list |
| 404 | List or expense not found |
DELETE /api/expenselist/{id}/delete — Remove an expense from a list
Auth required: Yes
Path params:
| Param | Type | Description |
|---|---|---|
id |
Long | Expense list database ID |
Query params:
| Param | Type | Required | Description |
|---|---|---|---|
expenseId |
Long | Yes | ID of the expense to remove |
Caller must be a member of the list.
Success response: 204 No Content
Error responses:
| Status | Condition |
|---|---|
| 403 | Caller is not a member of the list |
| 404 | List or expense not found |
POST /api/expenselist/{listId}/invite — Generate an invite code
Generates (or refreshes) a 6-character uppercase invite code for the list, valid for 1 week.
Auth required: Yes
Path params:
| Param | Type | Description |
|---|---|---|
listId |
Long | Expense list database ID |
Caller must be the owner of the list.
Success response: 200 OK — returns the invite code as a plain string.
AB3X7Q
Error responses:
| Status | Condition |
|---|---|
| 403 | Caller is not the owner |
| 404 | List not found |
POST /api/expenselist/accept-invite — Accept an invite
Joins the caller to a shared expense list using an invite code.
Auth required: Yes
Request body:
{
"inviteCode": "AB3X7Q"
}
| Field | Type | Constraints |
|---|---|---|
inviteCode |
String | Required. Exactly 6 characters. |
Success response: 200 OK — returns a plain string: "User added to the list".
Error responses:
| Status | Condition |
|---|---|
| 400 | Validation failure or caller is already the owner of the list |
| 404 | Invite code not found or expired |
| 226 IM Used | List already has a second member (sharedWith is not null) |
5. Data Models
AppUser
Returned by all /api/users endpoints. Sensitive fields (googleId, createdAt) are hidden from API responses via @JsonIgnore.
{
"id": 1,
"username": "alice"
}
| Field | Type | Notes |
|---|---|---|
id |
Long | Auto-generated primary key |
username |
String | Unique. 3–30 chars. |
ExpenseList
{
"id": 1,
"name": "Road Trip",
"inviteCode": "AB3X7Q",
"owner": { "id": 1, "username": "alice" },
"sharedWith": { "id": 2, "username": "bob" },
"xpenselyStandardCategories": {
"id": 1,
"categories": [
{ "id": 1, "name": "Food", "colorCode": "#FF5733" }
]
},
"customCategories": [],
"expenses": []
}
| Field | Type | Notes |
|---|---|---|
id |
Long | Auto-generated primary key |
name |
String | Display name of the list |
inviteCode |
String | null | 6-char code; null if not yet generated or expired |
owner |
AppUser | User who created the list |
sharedWith |
AppUser | null | Second member of the list; null until an invite is accepted |
xpenselyStandardCategories |
XpenselyStandardCategories | Default category set assigned at creation |
customCategories |
XpenselyCustomCategory[] | User-defined categories, ordered A→Z |
expenses |
Expense[] | All expenses, ordered by date ASC then id ASC |
inviteCodeExpirationis hidden from API responses (@JsonIgnore).
Expense
{
"id": 7,
"title": "Dinner",
"owner": { "id": 1, "username": "alice" },
"amount": 42.50,
"personalUseAmount": 21.25,
"otherPersonAmount": 21.25,
"category": "Food",
"date": "2026-05-09"
}
| Field | Type | Notes |
|---|---|---|
id |
Long | Auto-generated |
title |
String | Description of the expense |
owner |
AppUser | Who paid |
amount |
Double | Total amount. Min 0.01. |
personalUseAmount |
Double | null | Caller's share |
otherPersonAmount |
Double | null | Other member's share |
category |
String | Category name (free text — must match a category on the list) |
date |
String (ISO-8601) | YYYY-MM-DD |
The
expenseListback-reference is excluded via@JsonBackReferenceto prevent circular serialisation.
XpenselyStandardCategories
A named group of standard categories assigned to every new expense list.
{
"id": 1,
"categories": [
{ "id": 1, "name": "Food", "colorCode": "#FF5733" },
{ "id": 2, "name": "Transport", "colorCode": "#3498DB" }
]
}
XpenselyCustomCategory
A user-defined category attached to a specific expense list.
{
"id": 5,
"name": "Souvenirs",
"colorCode": "#9B59B6"
}
| Field | Type | Notes |
|---|---|---|
id |
Long | Auto-generated |
name |
String | Category label |
colorCode |
String | 7-char hex string e.g. #RRGGBB |
The
expenseListback-reference is excluded via@JsonBackReference.
6. Error Handling
All errors are returned as JSON. The GlobalExceptionHandler (@RestControllerAdvice) maps exceptions to HTTP status codes consistently across every endpoint.
Error response format
Most errors:
{
"error": "Human-readable message"
}
Validation errors (400):
{
"username": "size must be between 3 and 30",
"googleId": "must not be blank"
}
Status code reference
| HTTP Status | Condition | Source |
|---|---|---|
| 400 Bad Request | Input validation failed (missing/invalid fields) | MethodArgumentNotValidException via @Valid |
| 400 Bad Request | Business rule violation (e.g. expense not in this list) | IllegalArgumentException |
| 403 Forbidden | User not registered in the system | AuthenticatedUserResolver → ResponseStatusException(FORBIDDEN) |
| 403 Forbidden | Ownership check failed (e.g. deleting someone else's list) | ResponseStatusException(FORBIDDEN) in controller |
| 404 Not Found | Entity does not exist (user, list, expense) | ResourceNotFoundException |
| 409 Conflict | Username already taken | UsernameAlreadyExistsException |
| 429 Too Many Requests | Rate limit exceeded | RateLimitFilter (returned directly, not via exception handler) |
| 500 Internal Server Error | Unexpected runtime or generic exception | RuntimeException / Exception — message is hidden from client |
7. Recent Changes — feature/security-hardening
This section summarises API-visible changes introduced on the feature/security-hardening branch (commits 3d456f2 → b1324e3 → 8b96433 → f0de751). A developer or agent reading this document should be aware of these differences compared to the main branch.
New: Centralised error handling (GlobalExceptionHandler)
File: src/main/java/.../controller/GlobalExceptionHandler.java
All error responses now follow the consistent JSON shape described in Section 6. Previously, individual controllers returned ad-hoc responses, including HTTP 417 and raw stack trace output. Both have been removed. SLF4J logging has been added for server-side error visibility.
New: AuthenticatedUserResolver
File: src/main/java/.../security/AuthenticatedUserResolver.java
A new component that extracts the Google ID from the JWT sub claim and resolves the corresponding AppUser. Injected into all protected controller methods. Returns 403 Forbidden if the token is valid but the user has never called POST /api/users/createUser.
New: RateLimitFilter — 60 req/min
File: src/main/java/.../security/RateLimitFilter.java
Rate limiting was not present on main. See Section 3 for full details.
New: CreateExpenseListRequest DTO with validation
File: src/main/java/.../model/CreateExpenseListRequest.java
POST /api/expenselist/create previously accepted a raw ExpenseList body. It now requires a CreateExpenseListRequest with a single validated name field (@NotBlank, @Size(max=100)). Clients must update their request body shape.
Updated: Validation constraints tightened on existing DTOs
| DTO | Field | Change |
|---|---|---|
AppUserCreateRequest |
username |
Added @Pattern(regexp="^[a-zA-Z0-9_.\-]+$") — special characters (other than _, ., -) now rejected |
InviteRequest |
inviteCode |
Added @Size(min=6, max=6) — non-6-char codes now rejected at binding |
ExpenseInput |
amount |
Added @DecimalMin("0.01") — zero and negative amounts now rejected |
ExpenseChangeRequest |
amount |
Added @DecimalMin("0.01") — zero and negative amounts now rejected |
Updated: SecurityConfig — test-profile isolation
File: src/main/java/.../security/SecurityConfig.java
Two separate SecurityFilterChain beans now exist:
@Profile("test")— all endpoints permitted, CSRF disabled (for automated tests only).@Profile("!test")— full JWT/OAuth2 enforcement (production behaviour).
Previously a single config was used for both, which made tests fragile.
Fixed: ExpenseListService — JPQL and exception consistency
- Single
@Paramused in all JPQL queries (multi-param variant was broken). ResourceNotFoundExceptionis now thrown consistently instead of returningnullor a genericRuntimeException.addExpenseToListdelegates directly toExpenseList.addExpense()rather than looping.
Fixed: UserService — null returns replaced with exceptions
All not-found paths in UserService now throw ResourceNotFoundException instead of returning null. Callers that previously needed to null-check responses will now receive a 404 response.