Skip to content

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

2 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

User Repos Service

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 }] }

Requirements

  • 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).


Quick start

# 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.jar

The service listens on http://localhost:8080.

curl http://localhost:8080/users/octocat

You 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"}

Architecture

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.


Design decisions

Concurrent dispatch to both GitHub endpoints (the key performance choice)

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:

  • UserReposClient returns Mono<RemoteUser> / Mono<List<RemoteRepo>> and does not block internally. A Mono is lazy — building it starts no work.
  • Mono.zip(...) subscribes to both up front, so WebClient fires 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 plain User for 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.zip cancels 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.

Error handling — be specific where it matters, generic everywhere else

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.

Response caching (rate-limit relief)

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.

Timeouts on outbound calls

The GitHub WebClient is built with two complementary timeouts:

  • a Reactor Netty responseTimeout (github.api.timeout, default 5s) — caps how long we wait for GitHub's response, and
  • a connection timeout (github.api.connect-timeout, default 3s, via ChannelOption.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.

Retry on transient failures

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.

Reactive WebClient for outbound calls

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.

Externalized configuration

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.

Response shape: explicit field order and date format

  • The outward-facing User and Repo are immutable Java records. Field order is pinned with @JsonPropertyOrder on User (Jackson doesn't guarantee declaration order otherwise) and each component maps to its snake_case wire name via @JsonProperty. The internal RemoteUser / RemoteRepo mirrors stay Lombok @Data beans (mutable, populated by Jackson on deserialization from GitHub).
  • created_at is formatted as RFC 1123 (Tue, 25 Jan 2011 18:44:36 GMT) via a DateTimeFormatter in the service, rather than GitHub's raw ISO-8601. A missing created_at is carried through as null rather than failing the request.

Logging

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.


Usage

Success

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" }
  ]
}

Errors

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" }

Configuration

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

Testing

./mvnw clean test     # clean run is authoritative

40 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 null created_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-based DynamicPropertyRegistrar (GitHubMockConfig), overriding github.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.)

Project layout

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}

Tech stack

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.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages