A Spring Boot backend API that aggregates weather data from multiple external sources, with automatic fallback, a circuit breaker, JWT authentication and normalized error handling β designed to keep working even when an external source fails.
Clients built on this API: Web (Next.js) (live) Β· iOS (Swift/SwiftUI) Β· Android (Kotlin/Compose) β none of them talk to Open-Meteo/OpenWeatherMap directly, every request goes through this API.
Live API: weather-api-production-68ff.up.railway.app (Swagger UI is disabled on this deployment β see Notes; run locally to explore it interactively)
Weather API Aggregator queries a primary weather provider (Open-Meteo) and falls back automatically to a secondary one (OpenWeatherMap) if the first is down, each call protected by a Resilience4j circuit breaker and retry with exponential backoff. On top of that sits a full per-user layer β JWT authentication with refresh tokens, search history, favorite cities and unit preferences backed by PostgreSQL β plus an in-memory cache, per-user rate limiting and role-based access (regular users vs. admins) for aggregate stats and user management.
- π Current weather lookup by city, with unit normalization (Celsius/km-h or Fahrenheit/mph)
- π Hourly and daily forecast lookup by city (Open-Meteo), cached the same way as current weather
- π€ City search/autocomplete endpoint (Open-Meteo geocoding), for typeahead search boxes in the clients
- π§© Providers decoupled behind a Strategy/Adapter interface β swapping or adding a provider never touches the controller or the API contract
- π Automatic fallback between providers (Open-Meteo β OpenWeatherMap): if the primary fails, the request is served by the secondary one transparently
- β‘ Circuit breaker + retry with exponential backoff (Resilience4j) per provider β a provider that's systematically failing stops being called for a few seconds instead of piling up load, and transient errors are retried before giving up on that provider
- π‘οΈ Unit normalization across providers β OpenWeatherMap natively returns Kelvin and m/s; the conversion to Celsius/Fahrenheit and km/h/mph happens in the application, never on the provider's side
- β‘ In-memory cache (Caffeine), configurable TTL, with an explicit
fromCacheflag showing whether a response came from cache - π Aggregate stats endpoint (admin only) β total users, searches, favorites, the most-searched city and live cache hit/miss counts
- π JWT authentication (register/login) with refresh token rotation and logout/revocation, passwords hashed with BCrypt
- π€ Role-based access (
USER/ADMIN) β admin-only endpoints for aggregate stats and user management, gated at the Spring Security config level - π Per-user search history and favorite cities, addable and removable (PostgreSQL, Flyway migrations)
- βοΈ Per-user unit preference β omitting
unitson a search falls back to the saved preference - π GPS-based lookup β current weather for the caller's coordinates, reverse-geocoded to a city
- π Marine conditions (water temperature, wave height/direction/period) and derived insights (moon phase, UV risk, outdoor-activity and fishing-condition scores) for coastal/any cities
- π§ Rate limiting (Bucket4j), configurable per bucket type β per authenticated user, and per IP for both
/auth/**and any other unauthenticated request β with a normalized429response - π¦ Normalized errors that never leak the raw external provider error:
404city not found,502provider unavailable,429quota/rate limit exceeded,400invalid input,401unauthenticated,409conflict (duplicate email/favorite) - πΊοΈ Weather descriptions translated from Open-Meteo's WMO weather codes (OpenWeatherMap already returns its own description)
- π Interactive API documentation via Swagger/OpenAPI
- β Unit, integration (WireMock + a real PostgreSQL instance) and end-to-end tests β including one that forces a real circuit breaker trip β at ~97% line coverage
weather-api/
βββ src/main/java/com/vidi/weather/
β βββ controller/ # Weather (+ forecast/marine/insights/nearby/history/favorites), User, Auth, Admin, Stats
β βββ service/ # cache/provider/fallback orchestration, resilience, users, history, favorites, admin
β βββ provider/ # Strategy/Adapter interface + Open-Meteo + OpenWeatherMap
β βββ security/ # JWT, refresh tokens, filters, rate limiting, roles, UserDetails
β βββ entity/ # JPA entities (User with Role, SearchHistoryEntry, Favorite, RefreshToken)
β βββ repository/ # Spring Data JPA
β βββ model/ # internal domain (immutable)
β βββ dto/ # API contract (responses, errors, auth, preferences)
β βββ config/ # cache, RestTemplate, security, rate limit, properties
β βββ exception/ # domain exceptions + global handler
β βββ util/ # weather code mapping + unit conversion
βββ src/main/resources/db/migration/ # Flyway migrations
βββ src/test/java/ # unit, repository (real Postgres), WireMock, MockMvc, security, fallback/circuit breaker tests
βββ LICENSE
βββ pom.xml
- Strategy/Adapter for providers:
WeatherProvideris the only contract the rest of the app knows about. Open-Meteo and OpenWeatherMap each normalize their own response shape into the sameWeatherData, so adding a third provider later is additive, not a rewrite. - Caffeine over Redis: for a single-instance API, an in-memory cache is enough and avoids standing up extra infrastructure. Redis is the natural next step once the app runs on more than one instance and needs a shared cache.
- Open-Meteo as the primary provider: free, no API key required, so the project runs out of the box with zero setup friction. OpenWeatherMap is the secondary/fallback provider, which does need a free API key (see How to Run).
- Resilience4j circuit breaker + retry, not a hand-rolled fallback loop: each provider gets its own breaker and retry policy configured declaratively in
application.yml, so a systematically failing provider is skipped instead of retried forever, while transient errors (a single dropped request) still get absorbed before falling back. - PostgreSQL + Flyway over JPA auto-DDL:
ddl-auto: validateplus a versioned migration means the schema is explicit and reviewable, not implicitly inferred from entity annotations. - Stateless JWT over sessions: no server-side session store to scale, and CSRF protection is correctly disabled for this reason β it protects cookie-based sessions, which this API doesn't use.
- Immutable entities: JPA entities have no public setters; updates (e.g. changing a user's preferred units) go through a
withXcopy method andrepository.save(...), keeping the "never mutate in place" rule even inside Hibernate-managed objects.
POST /api/v1/auth/register β register (returns a JWT + refresh token)
POST /api/v1/auth/login β log in (returns a JWT + refresh token)
POST /api/v1/auth/refresh β exchange a refresh token for a new access + refresh token pair
POST /api/v1/auth/logout β revoke a refresh token
GET /api/v1/weather?city=&units= β current weather, with automatic fallback (authenticated)
GET /api/v1/weather/nearby?lat=&lon= β current weather for the caller's GPS coordinates, reverse-geocoded to a city
GET /api/v1/weather/forecast?city=&units= β hourly + daily forecast (Open-Meteo only, cached)
GET /api/v1/weather/marine?city=&units= β sea conditions (water temp, wave height/direction/period) for a coastal city
GET /api/v1/weather/insights?city=&units= β derived insights: moon phase, UV risk, outdoor-activity score, fishing conditions
GET /api/v1/weather/history β search history
DELETE /api/v1/weather/history/{id} β remove a single search history entry
DELETE /api/v1/weather/history β clear the caller's entire search history
GET /api/v1/weather/favorites β list favorites
POST /api/v1/weather/favorites β add a favorite
DELETE /api/v1/weather/favorites?city= β remove a favorite
GET /api/v1/geocoding?query=&limit= β city search/autocomplete (Open-Meteo geocoding, cached)
GET /api/v1/user/me β the authenticated user's profile, including role
GET /api/v1/user/preferences β get preferences
POST /api/v1/user/preferences β update preferences
GET /api/v1/admin/users β list every registered user (admin only)
DELETE /api/v1/admin/users/{id} β delete a user account (admin only)
GET /api/v1/stats β aggregate usage stats (users, searches, favorites, cache hit rate) (admin only)
| Scenario | Status |
|---|---|
| Success | 200 / 201 |
| Success, no response body (logout, delete favorite/user) | 204 |
| Unauthenticated | 401 |
| Insufficient role (non-admin hitting an admin-only endpoint) | 403 |
| City / favorite / user not found | 404 |
| Conflict (duplicate email/favorite) | 409 |
| External provider unavailable | 502 |
| Provider quota or rate limit exceeded | 429 |
| Invalid or missing parameter | 400 |
Prerequisites: Java 21, Maven and a local PostgreSQL instance.
# 1. Clone the repository
git clone https://github.com/VidiPT89/WeatherAPI.git
cd WeatherAPI
# 2. Make sure `java`/`mvn` resolve to Java 21
# (skip this if `java -version` already reports 21; on macOS with Homebrew,
# versioned JDKs are installed keg-only and aren't on PATH by default)
export JAVA_HOME="/opt/homebrew/opt/openjdk@21"
export PATH="$JAVA_HOME/bin:$PATH"
# 3. Create the database (one time)
createuser weather_api --pwprompt
createdb weather_api -O weather_api
# 4. Run the application (Flyway applies migrations automatically)
mvn spring-boot:runThe database connection, JWT secret/expiration, rate limits and the OpenWeatherMap API key are all configurable via environment variables (DB_URL, DB_USERNAME, DB_PASSWORD, JWT_SECRET, JWT_EXPIRATION_MINUTES, JWT_REFRESH_EXPIRATION_DAYS, RATE_LIMIT_REQUESTS_PER_MINUTE, RATE_LIMIT_AUTH_REQUESTS_PER_MINUTE, RATE_LIMIT_UNAUTHENTICATED_REQUESTS_PER_MINUTE, OPENWEATHERMAP_API_KEY, SWAGGER_ENABLED) β the values in application.yml are local-development defaults only and must be overridden in any real deployment. JWT_SECRET has no default and must always be set; every other variable falls back to a sensible local default if left unset.
Without OPENWEATHERMAP_API_KEY set, the second provider fails with 401 on every real call β that's expected, not a bug: the app keeps working normally because fallback always lands on Open-Meteo. To exercise the second provider for real, grab a free OpenWeatherMap key and export it as OPENWEATHERMAP_API_KEY.
The API is available at http://localhost:8080, with Swagger documentation at http://localhost:8080/swagger-ui/index.html.
mvn testRepository tests and the end-to-end security/fallback tests run against a real PostgreSQL database (weather_api_test), not H2, so constraints (unique email, unique favorite per user) are verified the same way they'll behave in production. The circuit breaker test forces a real transition into the OPEN state (via WireMock) and confirms the provider stops being called while it's open.
- Swagger UI / OpenAPI docs (
/swagger-ui.html,/v3/api-docs) are on by default (SWAGGER_ENABLEDunset ortrue) but disabled on the live Railway deployment (SWAGGER_ENABLED=false) β the routes it documents don't leak anything on their own, but publishing the full endpoint map to anyone unauthenticated isn't worth it on a real deployment; run the app locally to browse it interactively. - Open-Meteo's geocoding picks the most relevant result by name; ambiguous city names can return the wrong location (no country/coordinate disambiguation yet).
- Rate limiting, circuit breaker state and cached data are all in-memory and per instance (Caffeine); none of it is shared across multiple application instances yet.
- Forecast is Open-Meteo-only β OpenWeatherMap has no forecast call wired up in this codebase, so there's no fallback for
/weather/forecast(a provider outage there surfaces as502, unlike/weather, which falls back to the secondary provider).
MIT β see LICENSE.
Developed by David ArsΓ©nio Martins π ividi.dev Β· π» github.com/VidiPT89