Files
Cedric 7ad72119a8
Build and Deploy Spring Boot Server / build (push) Successful in 1m13s
fix secrets no longer needed
2026-05-14 23:46:40 +02:00

20 KiB
Raw Permalink Blame History

Xpensely Server — API Reference

Last updated: 2026-05-14 · Branch: dev

Table of Contents

  1. Overview
  2. Authentication
  3. Rate Limiting
  4. Endpoints
  5. Data Models
  6. Error Handling
  7. 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

  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>
  1. The server validates the JWT signature and extracts the sub claim (Google User ID).
  2. The sub value is used to look up the registered AppUser in the database via AuthenticatedUserResolver.
  3. 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

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. 330 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. 330 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

inviteCodeExpiration is 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 expenseList back-reference is excluded via @JsonBackReference to 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 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:

{
  "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 AuthenticatedUserResolverResponseStatusException(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 3d456f2b1324e38b96433f0de751). 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 @Param used in all JPQL queries (multi-param variant was broken).
  • ResourceNotFoundException is now thrown consistently instead of returning null or a generic RuntimeException.
  • addExpenseToList delegates directly to ExpenseList.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.