Skip to content

Latest commit

 

History

58 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

🌦️ Weather API Aggregator

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.

CI

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.

The Next.js client on the live API: current weather for Lisbon, the hourly forecast, sea conditions and today's estimated tides

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.

📦 What's Inside

  • 🔎 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 fromCache flag 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 units on 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 normalized 429 response
  • 🚦 Normalized errors that never leak the raw external provider error: 404 city not found, 502 provider unavailable, 429 quota/rate limit exceeded, 400 invalid input, 401 unauthenticated, 409 conflict (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

🛠️ Tech Stack

Java Spring Boot Spring Security PostgreSQL Flyway JWT Resilience4j Maven Caffeine Bucket4j JUnit5 WireMock OpenAPI

🏗️ Architecture

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

Why these choices

  • Strategy/Adapter for providers: WeatherProvider is the only contract the rest of the app knows about. Open-Meteo and OpenWeatherMap each normalize their own response shape into the same WeatherData, 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: validate plus 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 withX copy method and repository.save(...), keeping the "never mutate in place" rule even inside Hibernate-managed objects.

🌐 API

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

🚀 How to Run

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:run

The 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.

✅ Tests

mvn test

Repository 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.

📝 Notes

  • Social login requires configured client IDs for each enabled provider. Empty GOOGLE_OAUTH_CLIENT_IDS, APPLE_OAUTH_CLIENT_IDS or MICROSOFT_OAUTH_CLIENT_IDS disable 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_ENABLED unset or true) 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_UNAVAILABLE instead of a 401 that would end the clients' sessions. Covered by DatabaseOutageIntegrationTest.

  • A daily GitHub Actions workflow (.github/workflows/health-check.yml) probes /api/v1/health on 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.

📄 License

MIT — see LICENSE.


Developed by David Arsénio Martins 🌐 ividi.dev · 💻 github.com/VidiPT89

About

Weather aggregator API with automatic multi-provider fallback, circuit breaker, JWT auth and per-user history/favorites, built with Spring Boot, PostgreSQL and Resilience4j.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages