Loan Rest API

A Spring Boot REST API for issuing and prolonging fixed-term loans. It uses SQLite and sample data for local development.

A Spring Boot REST API for issuing and prolonging fixed-term loans. It uses SQLite and sample data for local development.
Photo by Shahadat Rahman

Loan REST API

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

Requirements

  • Java 21
  • Gradle Wrapper, included with the repository

Run the tests:

./gradlew test

Generate XML and HTML test coverage reports:

./gradlew jacocoTestReport

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

Run the full verification used for continuous integration:

./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:

./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:

./gradlew bootJar

Start the application:

./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:

  • 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:

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

Run the packaged application:

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:

./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:

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

To create the default local persistent catalog:

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

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.

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.

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.

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.

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.

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.

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.