Files
XpenselyServer/docs/API.md
T

636 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Xpensely Server — API Reference
> Last updated: 2026-05-09 · Branch: `feature/security-hardening`
## Table of Contents
1. [Overview](#1-overview)
2. [Authentication](#2-authentication)
3. [Rate Limiting](#3-rate-limiting)
4. [Endpoints](#4-endpoints)
- 4.1 [Home](#41-home)
- 4.2 [Users](#42-users)
- 4.3 [Expense Lists](#43-expense-lists)
5. [Data Models](#5-data-models)
6. [Error Handling](#6-error-handling)
7. [Recent Changes — `feature/security-hardening`](#7-recent-changes)
---
## 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"` |
| 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)).
## 2. Authentication
The server uses **OAuth2 Resource Server** authentication with **Google ID JWT tokens**.
### How it works
1. The client authenticates with Google and receives a Google ID JWT.
2. Every protected API request must include this token in the header:
```
Authorization: Bearer <google-id-jwt>
```
3. The server validates the JWT signature and extracts the `sub` claim (Google User ID).
4. The `sub` value is used to look up the registered `AppUser` in the database via `AuthenticatedUserResolver`.
5. If no `AppUser` exists 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:
1. Authenticate with Google to obtain a Google ID JWT.
2. Call `POST /api/users/createUser` with the JWT's `sub` value as `googleId` and a chosen `username`.
3. 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 `!test` profile only. Tests run without rate limiting.
## 4. Endpoints
### 4.1 Home
#### `GET /`
Health check. No authentication required.
**Response:** `200 OK`
```
Welcome
```
---
### 4.2 Users
Base path: `/api/users`
---
#### `POST /api/users/createUser` — Register a user
**Auth required:** No
**Request body:**
```json
{
"username": "alice",
"googleId": "118400012345678901234"
}
```
| Field | Type | Constraints |
|-------|------|-------------|
| `username` | String | Required. 330 chars. Pattern: `^[a-zA-Z0-9_.\-]+$` |
| `googleId` | String | Required. Non-blank. Must match the JWT `sub` from Google. |
**Success response:** `200 OK` — returns the created [AppUser](#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](#appuser).
**Error responses:**
| Status | Condition |
|--------|-----------|
| 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](#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](#appuser).
**Error responses:**
| Status | Condition |
|--------|-----------|
| 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 the deleted [AppUser](#appuser).
**Error responses:**
| Status | Condition |
|--------|-----------|
| 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 response:** `200 OK` — array of [ExpenseList](#expenselist).
```json
[
{
"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](#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:**
```json
{
"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](#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:**
```json
{
"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:** `200 OK` — returns the created [Expense](#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:**
```json
{
"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](#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:** `200 OK` — returns the deleted [Expense](#expense).
**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:**
```json
{
"inviteCode": "AB3X7Q"
}
```
| Field | Type | Constraints |
|-------|------|-------------|
| `inviteCode` | String | Required. Exactly 6 characters. |
**Success response:** `200 OK` — returns the [ExpenseList](#expenselist) the caller joined.
**Error responses:**
| Status | Condition |
|--------|-----------|
| 400 | Validation failure or invite code not found / expired |
| 403 | Caller is already the owner of this list |
---
## 5. Data Models
### AppUser
Returned by all `/api/users` endpoints. Sensitive fields (`googleId`, `createdAt`) are hidden from API responses via `@JsonIgnore`.
```json
{
"id": 1,
"username": "alice"
}
```
| Field | Type | Notes |
|-------|------|-------|
| `id` | Long | Auto-generated primary key |
| `username` | String | Unique. 330 chars. |
---
### ExpenseList
```json
{
"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 |
> `inviteCodeExpiration` is hidden from API responses (`@JsonIgnore`).
---
### Expense
```json
{
"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 `expenseList` back-reference is excluded via `@JsonBackReference` to prevent circular serialisation.
---
### XpenselyStandardCategories
A named group of standard categories assigned to every new expense list.
```json
{
"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.
```json
{
"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 `expenseList` back-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:
```json
{
"error": "Human-readable message"
}
```
Validation errors (`400`):
```json
{
"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`
_TODO_