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) — 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), Coast, 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/coast/spots?lat=&lon= — curated Portuguese beach spots (with the direction each faces), nearest first when lat/lon are given (anonymous)
GET /api/v1/coast/outlook?spot=&units= — 4-day hourly swell/wind/tide/water outlook for a spot (or any point via lat=&lon=&name=), with offshore/onshore wind, swell shelter and the best surf, fishing and beach-day window per day (anonymous)
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_IDSorMICROSOFT_OAUTH_CLIENT_IDSdisable that provider instead of accepting tokens issued to unrelated applications. This enforces OpenID Connect audience validation. The iOS app's bundle ID (dev.ividi.weatherapp), the audience of native Sign in with Apple tokens, is always accepted;APPLE_OAUTH_CLIENT_IDSonly adds more audiences, such as the web Services ID when web Apple login is enabled. -
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
