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: weatherapi-4r5x.onrender.com (Render free tier, so the first request after a quiet period can take up to a minute while the instance wakes up. Swagger UI is disabled on this deployment, see Notes; run locally to explore it interactively)
Weather API Aggregator queries a primary weather provider (OpenWeatherMap) and falls back automatically to a secondary one (Open-Meteo) 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, with OpenWeatherMap's 5-day forecast as fallback), cached the same way as current weather, plus the city's UTC offset so clients can tell day from night in any time zone
- 🔤 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 (OpenWeatherMap → Open-Meteo): 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 ~93% 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.
- OpenWeatherMap as the primary provider: it uses a registered API key with a quota dedicated to this app. Open-Meteo's key-less free tier shares its daily quota with every app on the same egress IP, and on a shared free-tier host that quota ran out from other people's traffic. Open-Meteo stays as the fallback, so the project still runs out of the box without any 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 (anonymous or authenticated; searches are only recorded to history when called with a token)
GET /api/v1/weather/nearby?lat=&lon= — current weather for the caller's GPS coordinates, reverse-geocoded to a city (anonymous or authenticated)
GET /api/v1/weather/forecast?city=&units= — hourly + daily forecast (Open-Meteo, OpenWeatherMap fallback, cached) (anonymous or authenticated)
GET /api/v1/weather/marine?city=&units= — sea conditions (water temp, wave height/direction/period) for a coastal city (anonymous or authenticated)
GET /api/v1/weather/insights?city=&units= — derived insights: moon phase, UV risk, outdoor-activity score, fishing conditions (anonymous or authenticated)
GET /api/v1/weather/history — search history (authenticated)
DELETE /api/v1/weather/history/{id} — remove a single search history entry (authenticated)
DELETE /api/v1/weather/history — clear the caller's entire search history (authenticated)
GET /api/v1/weather/favorites — list favorites (authenticated)
POST /api/v1/weather/favorites — add a favorite (authenticated)
DELETE /api/v1/weather/favorites?city= — remove a favorite (authenticated)
GET /api/v1/geocoding?query=&limit= — city search/autocomplete (Open-Meteo geocoding, cached)
GET /api/v1/health — 200 when the API and its database are up, 503 when the database is unreachable (public)
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, ADMIN_EMAIL, GOOGLE_OAUTH_CLIENT_IDS, APPLE_OAUTH_CLIENT_IDS, MICROSOFT_OAUTH_CLIENT_IDS) — 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 primary 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 use OpenWeatherMap 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.
-
Social login requires configured client IDs for each enabled provider. Empty
GOOGLE_OAUTH_CLIENT_IDS,APPLE_OAUTH_CLIENT_IDSorMICROSOFT_OAUTH_CLIENT_IDSdisable that provider instead of accepting tokens issued to unrelated applications. This enforces OpenID Connect audience validation. -
Local registration always creates a regular user. Automatic admin assignment requires a verified Google/Apple email. Microsoft accounts are identified by their provider subject; matching email addresses alone never link an existing account or grant admin access, following Microsoft's claim guidance. Existing explicit account links continue to work.
-
Refresh-token rotation locks the original row while issuing its successor. A replay within the grace window reuses only the cached, still-active successor; if that cache was lost or the successor was revoked, sign-in is required again.
-
Swagger UI / OpenAPI docs (
/swagger-ui.html,/v3/api-docs) are on by default (SWAGGER_ENABLEDunset ortrue) but disabled on the live Render 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. -
Same-named cities are disambiguated by country: the clients send "City, Country" and the API geocodes that pair to exact coordinates before looking up the weather. A bare city name, or a country that matches no candidate, still resolves to the provider's most relevant match.
-
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.
-
A database outage only takes down what needs the database: the app still starts (Flyway is skipped until the database is reachable again), weather lookups keep working with or without a token, and accounts/history/favorites answer
503 DATABASE_UNAVAILABLEinstead of a401that would end the clients' sessions. Covered byDatabaseOutageIntegrationTest. -
A daily GitHub Actions workflow (
.github/workflows/health-check.yml) probes/api/v1/healthon the live deployment, opens an issue assigned to the owner while it fails and closes it once it passes again, so an outage doesn't go unnoticed. -
The database pool is allowed to drain to zero idle connections (
spring.datasource.hikari.minimum-idle: 0), so a serverless Postgres such as Neon can suspend between requests instead of burning its compute quota.
MIT — see LICENSE.
Developed by David Arsénio Martins 🌐 ividi.dev · 💻 github.com/VidiPT89
