# Loan Rest API

- Link: https://ultimasolution.pl/loan-rest-api/
- Published: 2026-04-10T09:05:53+02:00
- Author: Karol Preiskorn

# Loan REST API

⌊Java 21⌉⌊Java 21⌉ ⌊Gradle⌉ ⌊Spring Boot 4.0⌉ ⌊SQLite⌉

A Spring Boot REST API for issuing and prolonging fixed-term loans. It uses SQLite
and sample data for local development. For another Java 21 learning project, see
the [Java blockchain project](https://ultimasolution.pl/blockchain-in-java/).

## Requirements

 * Java 21
 * Gradle Wrapper, included with the repository

Run the tests:

    ```EnlighterJSRAW
    ./gradlew test
    ```

Generate XML and HTML test coverage reports:

    ```EnlighterJSRAW
    ./gradlew jacocoTestReport
    ```

Open the HTML report at `build/reports/jacoco/test/html/index.html`.

Run the full verification used for continuous integration:

    ```EnlighterJSRAW
    ./gradlew check
    ```

The build requires at least 90% line coverage and 80% branch coverage.

Dependency versions are locked in `gradle.lockfile`. Refresh locks deliberately 
after dependency changes:

    ```EnlighterJSRAW
    ./gradlew dependencies --write-locks
    ```

GitHub Actions validates the Gradle Wrapper, then runs the same `check` and `bootJar`
tasks for every push and pull request. It publishes the JaCoCo report even when 
verification fails and the executable JAR after successful builds. New pushes cancel
outdated builds for the same branch or pull request. The job has a 15-minute timeout,
and artifacts are retained for 14 days.

Create the packaged executable JAR:

    ```EnlighterJSRAW
    ./gradlew bootJar
    ```

Start the application:

    ```EnlighterJSRAW
    ./gradlew bootRun --args='--spring.profiles.active=dev'
    ```

The API is available at `http://localhost:8080/api/loans`.

Interactive API documentation is available at `http://localhost:8080/swagger-ui/
index.html`. The generated OpenAPI document is available at `http://localhost:8080/
v3/api-docs`.

Operational health endpoints return `UP` or `DOWN` without exposing component details:

    ```EnlighterJSRAW
    http://localhost:8080/actuator/health
    http://localhost:8080/actuator/health/liveness
    http://localhost:8080/actuator/health/readiness
    ```

including database availability

When port `8080` is already in use, choose another port:

    ```EnlighterJSRAW
    ./gradlew bootRun --args='--spring.profiles.active=dev --server.port=8081'
    ```

Run the packaged application:

    ```EnlighterJSRAW
    java -jar build/libs/loan-rest-spring-boot-0.0.2-SNAPSHOT.jar --spring.profiles.active=dev --server.port=8081
    ```

## Profiles and Database Lifecycle

The application requires an explicit `dev` or `prod` Spring profile. Startup without
either profile fails before datasource initialization with a clear configuration
error. Flyway owns schema creation and applies migrations from `src/main/resources/
db/migration`; Hibernate validates the resulting schema and never creates or drops
it.

 * `dev` uses a disposable shared in-memory SQLite database and loads fifteen sample
   loans from `src/main/resources/db/dev/seed-loans.sql`. Seeding runs after Flyway
   completes and only when the `loans` table is empty, so restarts never duplicate
   sample data.
 * `prod` uses a persistent SQLite database at `./data/loan-rest.db`, applies pending
   Flyway migrations, and does not load sample data.

Loan timestamps are stored through Hibernate in UTC. The maximum-amount overnight
rule uses `loan.risk.time-zone`, which defaults to UTC and can be overridden with
the `LOAN_RISK_TIME_ZONE` environment variable using an IANA time-zone ID such as`
Europe/Warsaw`. An invalid time-zone ID prevents startup and reports the `loan.risk.
time-zone` configuration error.

Start the production-like profile:

    ```EnlighterJSRAW
    ./gradlew bootRun --args='--spring.profiles.active=prod'
    ```

On first startup, Flyway creates the schema. Later startups validate and reuse the
existing database; local files under `data/` are intentionally ignored by Git.

SQLite is an embedded file database and does not use database usernames or passwords.
The `prod` profile accepts a database-location override through `LOAN_DB_URL`. For
example, use a different persistent SQLite location:

    ```EnlighterJSRAW
    LOAN_DB_URL='jdbc:sqlite:/srv/loan-rest/loans.db' \ 
    ./gradlew bootRun --args='--spring.profiles.active=prod'
    ```

To create the default local persistent catalog:

    ```EnlighterJSRAW
    make run-prod
    ```

To reset that local catalog, first stop the application, then run `make reset-local-
db`. This permanently removes the SQLite database and its journal files. Start `
make run-prod` again to recreate the catalog and apply Flyway migrations.

## Logging

The `dev` profile logs application messages from `pl.ultimasolution.loan` at `DEBUG`
for local diagnostics. The `prod` profile logs at `INFO` and emits structured Logstash
JSON to standard output for collection by the deployment platform. Logs must not
be treated as a source of loan or audit data; use the audit-trail endpoint for lifecycle
history.

## Business Rules

 * Loan amounts must be between `0` and `1000` inclusive.
 * A maximum amount request (`1000`) is rejected from `00:00` through `05:59` in
   the server time zone.
 * The fourth request for the same client IP address and loan name is rejected.
 * Concurrent applications are isolated so the IP/name attempt limit cannot be bypassed
   by simultaneous requests.
 * New loans have a fixed 14-day term.
 * A loan can be prolonged once, extending its existing term by 14 days.
 * Deleted loans are retained for audit purposes but are excluded from normal loan
   reads and application-limit calculations.

The server derives the client IP address from the HTTP connection (`getRemoteAddr`)
and accepts IPv4 or IPv6 values up to 45 characters. The ID, client IP address, 
timestamps, calculated term, and prolongation state are server-managed. Create requests
cannot set or override these values.

## Request Validation

  |  Field |  Rules |  
   |  `loanName` |  Required, nonblank, 3 to 20 characters |  
 |  `amount` |  Required number from `0` through `1000` inclusive |

## API

All non-empty API responses use `application/json`. Requests with an incompatible`
Accept` header receive `406 Not Acceptable`.

### Create a loan

    ```EnlighterJSRAW
    POST /api/loans
    ```

The endpoint accepts `application/json` only.

Returns `201 Created` with the persisted loan:

The response includes a `Location` header for the created resource, for example `
http://localhost:8080/api/loans/4`.

    ```EnlighterJSRAW
    curl --request POST http://localhost:8080/api/loans \
     --header 'Content-Type: application/json' \
     --data ''
    ```

### List loans

`GET /api/loans` returns `200 OK` with a page of active loans. Use zero-based `page`,`
size`, and `sort` query parameters. The default is page `0`, size `20`, sorted by`
createdAt`; requested sizes are capped at `100`; deleted loans are excluded.

    ```EnlighterJSRAW
    curl 'http://localhost:8080/api/loans?page=0&size=20&sort=createdAt,asc'
    ```

    ```
    Get a loan
    ```

`GET /api/loans/` returns `200 OK` or `404 Not Found`.

    ```bash
    curl http://localhost:8080/api/loans/1
    ```

### Prolong a loan

`PUT /api/loans/prolong/` returns `200 OK` with the updated loan, `404 Not Found`
for an unknown loan, or `422 Unprocessable Entity` when the loan is expired or was
already prolonged.

    ```bash
    curl --request PUT http://localhost:8080/api/loans/prolong/1
    ```

### Delete a loan

This is an intentional CRUD extension beyond the original loan-issuing and prolongation
exercise.

`DELETE /api/loans/` returns `204 No Content` or `404 Not Found`. Deletion is a 
soft delete: the loan cannot be read or prolonged afterwards, but its immutable 
audit trail remains available.

    ```EnlighterJSRAW
    curl --request DELETE --include http://localhost:8080/api/loans/1
    ```

### Get loan audit trail

`GET /api/loans//audit-trail` returns `200 OK` and every lifecycle event for a loan,
including after deletion. Events are ordered by creation time and event ID. An unknown
loan returns `404 Not Found`.

    ```EnlighterJSRAW
    curl http://localhost:8080/api/loans/1/audit-trail

    [

    ]
    ```

All audit event timestamps are UTC. Each lifecycle event and its associated loan
change are committed in the same transaction, so failed operations leave no audit
record.

## Errors

Malformed JSON returns `400 Bad Request` with `MALFORMED_REQUEST`; invalid request
values return `400 Bad Request` with `VALIDATION_FAILED`; invalid path parameters
return `400 Bad Request` with `INVALID_PATH_PARAMETER`; incompatible response media
types return `406 Not Acceptable` with `NOT_ACCEPTABLE`; unsupported HTTP methods
return `405 Method Not Allowed` with `METHOD_NOT_ALLOWED`; unsupported request media
types return `415 Unsupported Media Type` with `UNSUPPORTED_MEDIA_TYPE`; unknown
endpoints return `404 Not Found` with `ENDPOINT_NOT_FOUND`; missing loans return`
404 Not Found` with `LOAN_NOT_FOUND`; business-rule violations return `422 Unprocessable
Entity`; concurrent updates return `409 Conflict` with `CONCURRENT_MODIFICATION`;
and unexpected failures return `500 Internal Server Error` with `INTERNAL_ERROR`.

  |  Code |  HTTP status |  Meaning |  
   |  `MALFORMED_REQUEST` |  `400` |  The request body is not valid JSON. |  
 |  `VALIDATION_FAILED` |  `400` |  `loanName` or `amount` violates request validation. |  
 |  `INVALID_PATH_PARAMETER` |  `400` |  A path parameter such as a loan ID is not a valid number. |  
 |  `NOT_ACCEPTABLE` |  `406` |  The request must accept an `application/json` response. |  
 |  `METHOD_NOT_ALLOWED` |  `405` |  The resource does not support the requested HTTP method; `Allow` lists supported methods. |  
 |  `UNSUPPORTED_MEDIA_TYPE` |  `415` |  The create request body is not `application/json`. |  
 |  `ENDPOINT_NOT_FOUND` |  `404` |  The requested endpoint does not exist. |  
 |  `LOAN_NOT_FOUND` |  `404` |  No active loan has the requested ID, or no loan exists for an audit-trail request. Deleted loans are not active. |  
 |  `LOAN_RULE_VIOLATION` |  `422` |  A loan amount, timing, attempt-limit, expiry, or one-time prolongation rule was violated. |  
 |  `CONCURRENT_MODIFICATION` |  `409` |  Another request changed the loan or transaction state; retry the request. |  
 |  `INTERNAL_ERROR` |  `500` |  An unexpected server error occurred; implementation details are not exposed. |

## Test Coverage

The suite covers Flyway-backed application startup with the `dev` and `prod` profiles;
list, get, create, prolong, delete, and audit endpoints; request parsing and validation;
amount and time boundaries; IP/name attempt limits; metadata ownership; retained
deletion behavior; missing resources; expiry; concurrency error handling; and one-
time prolongation.
