A Spring Boot REST service that, given a GitHub username, fetches the user's profile and their public repositories and returns them as a single combined JSON document.
GET /users/octocat ──► { user_name, display_name, avatar, geo_location,
email, url, created_at, repos:[{ name, url }] }
- Java 21
- Maven 3.9+ — either a local install (
mvn) or the bundled wrapper (./mvnw). The wrapper downloads Maven automatically on first run (needs internet access and a populated.mvn/wrapper/maven-wrapper.properties).
No GitHub credentials are required. Calls go to the public GitHub REST API, so they are subject to the unauthenticated rate limit (~60 requests/hour/IP).
# clone the repo
git clone https://github.com/qgambit2/branchcode.git && cd branchcode
# run directly
./mvnw spring-boot:run
# ...or build a jar and run it
./mvnw clean package
java -jar target/userrepos-0.0.1-SNAPSHOT.jarThe service listens on http://localhost:8080.
curl http://localhost:8080/users/octocatYou can also open http://localhost:8080/users/octocat in a browser — it returns JSON, which most browsers render with a built-in viewer.
A health endpoint is exposed by Spring Boot Actuator:
curl http://localhost:8080/actuator/health # {"status":"UP"}The service is a thin, layered HTTP facade over the GitHub API.
Each layer calls only the next one down. Only UserReposClient talks to GitHub —
the service reaches GitHub through the client, never directly.
client (curl / browser)
│ GET /users/{username}
▼
┌─────────────────────┐
│ UserReposController │ validates the username (@Pattern)
└──────────┬──────────┘
│ getUserWithRepos(username)
▼
┌─────────────────────┐
│ UserReposService │ maps the result into the User response
└──────────┬──────────┘
│ Mono.zip(getRemoteUser(u), getRemoteRepos(u))
│ (both calls issued together → concurrent)
▼
┌─────────────────────┐
│ UserReposClient │ the only layer that calls GitHub
└──────────┬──────────┘
│ HTTPS
▼
GitHub REST API
/users/{u} and /users/{u}/repos
Cross-cutting: GlobalExceptionHandler
(@RestControllerAdvice) catches anything thrown above and turns it into a JSON
{ status, error, message } response.
| Layer | Class | Responsibility |
|---|---|---|
| Web | UserReposController | Expose GET /users/{username}, validate the username |
| Service | UserReposService | Run the two lookups concurrently, combine into the User response |
| Client | UserReposClient | Talk to GitHub via WebClient, map upstream errors |
| Cross-cutting | GlobalExceptionHandler | Uniform error responses |
| Config | GitHubProperties | Type-safe GitHub endpoint config |
The internal RemoteUser / RemoteRepo models mirror GitHub's payloads, while
the outward-facing User
and Repo models are
decoupled from them — so GitHub's schema can change without breaking our API
contract.
Producing the response requires two independent GitHub calls — the profile
(/users/{u}) and the repos (/users/{u}/repos). Neither depends on the
other's result, so running them sequentially would waste time: total latency
would be user + repos.
Instead, the service issues both concurrently and combines them when both
complete, so latency is roughly max(user, repos) — close to half:
return Mono.zip(
userReposClient.getRemoteUser(username),
userReposClient.getRemoteRepos(username))
.map(results -> toUser(results.getT1(), results.getT2()))
.block();How it works:
UserReposClientreturnsMono<RemoteUser>/Mono<List<RemoteRepo>>and does not block internally. AMonois lazy — building it starts no work.Mono.zip(...)subscribes to both up front, soWebClientfires both HTTP requests immediately and they proceed concurrently on Reactor Netty's event loop. No worker thread is parked waiting on I/O.- A single
.block()at the service boundary turns the combined result back into a plainUserfor the (servlet-based) MVC controller.
This is deliberately the only place that blocks. Because the client returns
Monos rather than blocking internally, no thread-offload (e.g.
Schedulers.boundedElastic()) is needed — the two calls overlap on the event
loop, which is the cleaner, more thread-efficient approach.
Trade-off: because both calls fire together, a request for a non-existent user also starts the repos call. In practice that's harmless: when the profile call errors (404),
Mono.zipcancels the other source, and the profile call's "user not found" is what surfaces. Even if the repos call has already completed, its own 404 is translated to an empty list and discarded.
Handled centrally in GlobalExceptionHandler,
which returns a consistent { status, error, message } body:
| Situation | Response |
|---|---|
| User does not exist (profile call 404) | 404 User not found: <username> |
| User has no repos | 200 with repos: [] |
| GitHub rate limit hit (upstream 429, or 403 once the budget is spent) | 429 GitHub API rate limit exceeded; please retry later |
| Username fails validation | 400 (invalid GitHub username format) |
| Anything else (upstream 4xx/5xx, bugs, etc.) | 500 generic |
A note on the empty-repos row: an existing user with no repositories gets a
200 [] from /users/{u}/repos, not a 404. The repos endpoint only 404s
when the user itself doesn't exist — and that case is already caught by the
profile call. So the onErrorResume(NotFound → []) in UserReposClient is
really a guard for the user-missing path, not the no-repos path.
The 429 mapping matters because rate limiting is the most likely real-world failure here (unauthenticated GitHub allows only ~60 req/hour/IP, and each response costs two). Reporting it as a generic 500 would blame us for an upstream throttle; surfacing 429 lets the caller tell "back off and retry" apart from "the service is broken." For these unauthenticated public reads a 403 is effectively always throttling, so it maps to 429 as well.
Rationale for the catch-all 500 (rather than, say, 502): we don't presume the
failure is GitHub's fault. A 502 Bad Gateway claims "upstream misbehaved," but an
upstream 400 usually means we sent a bad request — labeling our own bug as the
dependency's is misleading. So any error we don't explicitly model is reported
honestly as an internal 500. The handler is scoped to Exception but the
specific handlers (404/400) take precedence via Spring's most-specific-match
rule.
The unauthenticated GitHub API allows only ~60 requests/hour/IP, and each of our
responses costs two of those. getUserWithRepos is annotated @Cacheable
(backed by Caffeine), so repeated lookups of the same username are served from an
in-memory cache for a short TTL (expireAfterWrite=5m) instead of re-hitting
GitHub. Only successful responses are cached — a thrown 404/5xx is not — so
failures are always retried against the upstream. TTL and size are configurable
via spring.cache.*.
GitHub usernames are case-insensitive (octocat == OctoCat), so the cache key
is lower-cased (key = "#username.toLowerCase()"). Without this, the same user
requested under different casing would occupy separate cache entries and burn
extra calls against the rate limit. The original casing is still sent upstream —
GitHub resolves it either way.
Note the TTL is not on the annotation: @Cacheable("usersWithRepos") only names
the cache. The 5-minute lifetime comes entirely from the Caffeine spec
(expireAfterWrite=5m) in application.yaml,
which Spring Boot parses when it auto-configures the Caffeine cache manager. So
the TTL is owned by the cache configuration, not the service code.
The GitHub WebClient is built with two complementary timeouts:
- a Reactor Netty
responseTimeout(github.api.timeout, default5s) — caps how long we wait for GitHub's response, and - a connection timeout (
github.api.connect-timeout, default3s, viaChannelOption.CONNECT_TIMEOUT_MILLIS) — caps how long we wait to establish the TCP connection in the first place.
Without these, a slow or hung GitHub (or an unreachable host) would tie up the
request indefinitely — and because the service bridges to a synchronous result
with .block(), that means a parked servlet thread. The timeouts cap that
exposure; a timed-out call surfaces as a 500.
Outbound calls are retried (github.api.max-retries, default 1) on transient
failures only — upstream 5xx and connect/read timeouts — with a short backoff.
Retries are deliberately not applied to 4xx, not-found, or rate-limit (429/403)
responses: those won't succeed on a retry, and retrying a rate-limited call would
only burn more of the budget. The count is configurable (set it to 0 to
disable). Retry is implemented with Reactor's Retry.backoff(...).filter(...),
where the filter encodes the "transient only" rule.
WebClient (not RestTemplate) is used for the GitHub calls because it's
non-blocking and composes naturally with Mono.zip for the concurrent dispatch
above. The app runs on the MVC/servlet stack (it also pulls in spring-web), so
we bridge back to a synchronous result with one .block() — appropriate here,
and it keeps the controller and tests simple.
GitHub endpoints are not hard-coded; they're bound to a type-safe
GitHubProperties
record (@ConfigurationProperties(prefix = "github.api")) from
application.yaml. This makes the base URL
trivially overridable — which the integration tests exploit to point the app at a
local mock server.
- The outward-facing
UserandRepoare immutable Java records. Field order is pinned with@JsonPropertyOrderonUser(Jackson doesn't guarantee declaration order otherwise) and each component maps to its snake_case wire name via@JsonProperty. The internalRemoteUser/RemoteRepomirrors stay Lombok@Databeans (mutable, populated by Jackson on deserialization from GitHub). created_atis formatted as RFC 1123 (Tue, 25 Jan 2011 18:44:36 GMT) via aDateTimeFormatterin the service, rather than GitHub's raw ISO-8601. A missingcreated_atis carried through asnullrather than failing the request.
GlobalExceptionHandler
uses Lombok @Slf4j (SLF4J → Logback, Spring Boot's default backend). Unexpected
errors are logged at ERROR with the stack trace; expected client errors
(404/400) at WARN.
curl http://localhost:8080/users/octocat{
"user_name": "octocat",
"display_name": "The Octocat",
"avatar": "https://avatars.githubusercontent.com/u/583231?v=4",
"geo_location": "San Francisco",
"email": null,
"url": "https://api.github.com/users/octocat",
"created_at": "Tue, 25 Jan 2011 18:44:36 GMT",
"repos": [
{ "name": "boysenberry-repo-1", "url": "https://api.github.com/repos/octocat/boysenberry-repo-1" },
{ "name": "git-consortium", "url": "https://api.github.com/repos/octocat/git-consortium" }
]
}| Request | Status | Body |
|---|---|---|
GET /users/no-such-user-xyz |
404 |
{ "status":404, "error":"Not Found", "message":"User not found: no-such-user-xyz" } |
GET /users/bad_name (invalid username) |
400 |
{ "status":400, "error":"Bad Request", "message":"invalid username format" } |
| GitHub rate limit exceeded | 429 |
{ "status":429, "error":"Too Many Requests", "message":"GitHub API rate limit exceeded; please retry later" } |
| GitHub unavailable / unexpected failure | 500 |
{ "status":500, "error":"Internal Server Error", "message":"An unexpected error occurred" } |
src/main/resources/application.yaml:
spring:
cache:
cache-names: usersWithRepos
caffeine:
spec: maximumSize=1000,expireAfterWrite=5m # response cache (see below)
github:
api:
base-url: https://api.github.com
user-path: /users/{username}
user-repos-path: /users/{username}/repos
timeout: 5s # per-request response timeout
connect-timeout: 3s # TCP connection timeout
max-retries: 1 # retries on transient 5xx/timeouts (0 = off)Any property can be overridden at runtime, e.g.:
java -jar target/userrepos-0.0.1-SNAPSHOT.jar --github.api.base-url=https://api.github.com
SERVER_PORT=9090 ./mvnw spring-boot:run # change the listen port./mvnw clean test # clean run is authoritative40 tests, split into two styles:
- Unit tests — no mocking framework. The HTTP client is tested against a real
in-process JDK
HttpServer(including rate-limit 429/403 mapping and a transient-5xx retry-then-succeed case); the service and controller use small hand-written stubs (the service test also covers a nullcreated_at). Models, config, and the exception handler are tested directly. - Integration test —
UserReposApplicationTests
boots the whole app (
@SpringBootTest(RANDOM_PORT)) with a local OkHttp MockWebServer standing in for GitHub. Its URL is injected via a bean-basedDynamicPropertyRegistrar(GitHubMockConfig), overridinggithub.api.base-url. It covers the happy path, user/repos 404s, rate limiting (429/403 → 429), a slow upstream (response timeout → 500), upstream 4xx/5xx → 500, and username validation — end to end. (It runs with a short timeout and retries disabled so the timeout case stays fast.)
src/main/java/com/branch/userrepos
├── UserReposApplication.java # Spring Boot entry point
├── controller/UserReposController # GET /users/{username}, validation
├── service/UserReposService # concurrent dispatch + mapping + caching
├── client/UserReposClient # WebClient calls to GitHub
├── model/{User,Repo,RemoteUser,RemoteRepo}
├── config/GitHubProperties # externalized GitHub config
└── exception/{GlobalExceptionHandler,UserNotFoundException}
Java 21 · Spring Boot 3.5 (Spring Web MVC + Spring WebFlux WebClient) ·
Project Reactor · Spring Cache + Caffeine · Spring Boot Actuator · Lombok ·
Maven · JUnit 5 / AssertJ / OkHttp MockWebServer.