πAPI Gateway to Utilize OpenWeather APIs

A Step-by-Step Tutorial for Keeping Your API Keys Safe
πΊ Original Tutorial: This guide complements Austin Davies Tech's weather dashboard tutorial series. The API gateway enables learners to deploy their frontend work safely while keeping API keys hidden.
π Table of Contents
Introduction
When building frontend applications that consume external APIs like OpenWeatherMap, we face a common challenge: where do we store API keys?
If you embed your API key in frontend code, anyone can:
View it in browser dev tools
Steal it and use your quota
Run up charges on your account
This tutorial shows you how to build a Spring Boot API gateway that:
Stores your API key securely on the server
Proxies requests to OpenWeatherMap
Adds rate limiting and caching
Allows your frontend to be deployed anywhere (Vercel, Netlify, GitHub Pages)
Quick Start (Just Use the Gateway)
Don't want to learn Java? No problem! You can use the pre-deployed gateway immediately.
π Live API Gateway
https://weather-board-api-service.onrender.com/api/v1/openweather
Available Endpoints
| Endpoint | Method | Description |
/weather?lat={lat}&lon={lon} | GET | Current weather + forecast |
/geocode?location={city}&limit=1 | GET | Search for locations |
/air_pollution?lat={lat}&lon={lon} | GET | Air quality data |
/map_layer/{type}/{z}/{x}/{y} | GET | Weather map tiles |
Example Usage in JavaScript/TypeScript
const baseUrl = "https://weather-board-api-service.onrender.com/api/v1/openweather"
// Fetch weather data
const getWeather = async (lat: number, lon: number) => {
const res = await fetch(`${baseUrl}/weather?lat=${lat}&lon=${lon}`)
const data = await res.json()
return data.data // Weather data from OpenWeatherMap
}
// Search for a city
const searchLocation = async (city: string) => {
const res = await fetch(`${baseUrl}/geocode?location=${city}&limit=1`)
const data = await res.json()
return data.data // Array of matching locations
}
Map Tiles with React Leaflet
import { TileLayer } from "react-leaflet"
<TileLayer
url="https://weather-board-api-service.onrender.com/api/v1/openweather/map_layer/clouds_new/{z}/{x}/{y}"
opacity={0.7}
/>
Deploy Your Own Gateway
If the public gateway's rate limits don't work for you:
Clone:
git clonehttps://github.com/solo-exe/weather_board_api_service.gitGet an OpenWeatherMap API key
Deploy to Render, Railway, or any Java hosting
Understanding the Problem
β The Wrong Way: API Keys in Frontend
// DON'T DO THIS! Anyone can see this key in your browser's dev tools
const API_KEY = "your_secret_key_here"
fetch(`https://api.openweathermap.org/data/3.0/onecall?appid=${API_KEY}`)
Problems:
Key visible in Network tab
Key in your Git history
Key exposed in built JavaScript files
Anyone can extract and use your key
β The Right Way: API Gateway
// Frontend only talks to YOUR server, which has the API key
fetch(`https://your-gateway.com/api/weather?lat=51.5&lon=-0.12`)
Your gateway server then:
Receives the request
Adds the API key (stored securely in environment variables)
Forwards to OpenWeatherMap
Returns the response
Architecture Overview
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β USER'S BROWSER β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β β
β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β Frontend App (React/Vite) - Deployed on Vercel β β
β β β β
β β ββββββββββββββββββββββ βββββββββββββββββββββββ β β
β β β api.ts β β Map.tsx β β β
β β β β β β β β
β β β baseUrl = gateway β β TileLayer url = β β β
β β β β β gateway/map_layer β β β
β β ββββββββββββββββββββββ βββββββββββββββββββββββ β β
β β β β β β
β βββββββββββββΌβββββββββββββββββββββββββββΌββββββββββββββββββββββββββββββββ β
β β β β
ββββββββββββββββΌβββββββββββββββββββββββββββΌββββββββββββββββββββββββββββββββββββ
β β
βΌ βΌ
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β API Gateway (Spring Boot on Render) β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β β
β βββββββββββββββ ββββββββββββββββββββ βββββββββββββββββββββββββββ β
β β Controller βββββΆβ Rate Limiter βββββΆβ Weather Service β β
β β β β (Bucket4j) β β β β
β β /weather β β β β - Add API key β β
β β /geocode β β 2 req/sec/IP β β - Call OpenWeatherMap β β
β β /air_poll β ββββββββββββββββββββ β - Track daily usage β β
β β /map_layer β β β β
β βββββββββββββββ ββββββββββββββββββββ βββββββββββββββββββββββββββ β
β β Caffeine Cache β β β
β β β β β
β β Map tiles: β β β
β β 7 days TTL β βΌ β
β β 1000 max β βββββββββββββββββββββββββββ β
β ββββββββββββββββββββ β JSON File Storage β β
β β (daily_logs.json) β β
β β β β
β β Track API calls/day β β
β β 30-day retention β β
β βββββββββββββββββββββββββββ β
β β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β Environment Variables (.env) β β
β β βββ OPENWEATHERMAP_API_KEY=xxxxxxxxxxxxx (NEVER in code!) β β
β β βββ PORT=7002 β β
β β βββ ... β β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β OpenWeatherMap External API β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β /data/3.0/onecall - Weather data β
β /geo/1.0/direct - Geocoding β
β /data/2.5/air_pollution - Air quality β
β tile.openweathermap.org - Weather map tiles β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Building the Gateway from Scratch
1. Project Setup
Create a new Spring Boot project with these dependencies:
<!-- pom.xml -->
<dependencies>
<!-- Web framework -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<!-- Environment variable support -->
<dependency>
<groupId>me.paulschwarz</groupId>
<artifactId>spring-dotenv</artifactId>
<version>4.0.0</version>
</dependency>
<!-- Rate limiting -->
<dependency>
<groupId>com.bucket4j</groupId>
<artifactId>bucket4j-core</artifactId>
<version>8.10.1</version>
</dependency>
<!-- Caching -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>
<!-- Lombok for reducing boilerplate -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- JSON serialization for Java 8+ date types -->
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId>
</dependency>
</dependencies>
π Note on SQL Dependencies: You'll notice the project has commented-out JPA and MySQL dependencies. We'll explore why in the Data Storage Evolution section.
2. Configuration
application.yaml - Main application configuration:
spring:
application:
name: weather-board-api-service
config:
import: optional:file:.env[.properties]
openweathermap:
api:
apikey: ${OPENWEATHERMAP_API_KEY}
server:
port: ${PORT:8080}
servlet:
context-path: /api/v1 # All routes prefixed with /api/v1
logging:
level:
root: INFO
com.sollo_script.weather_board_api_service: DEBUG
.env file (never commit this!):
PORT=7002
OPENWEATHERMAP_API_KEY=your_actual_api_key_here
OPENWEATHERMAP_BASE_URL=https://api.openweathermap.org
OPENWEATHERMAP_TILE_BASE_URL=https://tile.openweathermap.org/map
3. API Client Configuration
Create REST clients for calling OpenWeatherMap:
// config/ApiClientConfig.java
package com.sollo_script.weather_board_api_service.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.web.client.RestClient;
@Configuration
public class ApiClientConfig {
@Bean("openWeatherMap")
public RestClient openWeatherClient() {
return RestClient.builder()
.baseUrl("https://api.openweathermap.org")
.requestFactory(new SimpleClientHttpRequestFactory() {
{
setConnectTimeout(10000);
setReadTimeout(10000);
}
})
.build();
}
@Bean("openWeatherMapTile")
public RestClient openWeatherMapTileClient() {
return RestClient.builder()
.baseUrl("https://tile.openweathermap.org/map")
.requestFactory(new SimpleClientHttpRequestFactory() {
{
setConnectTimeout(10000);
setReadTimeout(10000);
}
})
.build();
}
}
Why two clients? OpenWeatherMap uses different base URLs for data and map tiles.
4. Data Transfer Objects (DTOs)
Generic API Response Wrapper:
// dto/ApiResponse.java
package com.sollo_script.weather_board_api_service.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.http.HttpStatus;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ApiResponse<T> {
private String status;
private int code;
private T data;
public static <T> ApiResponse<T> success(T data) {
return ApiResponse.<T>builder()
.status("OK")
.code(HttpStatus.OK.value())
.data(data)
.build();
}
public static <T> ApiResponse<T> error(int code, T data) {
return ApiResponse.<T>builder()
.status("ERROR")
.code(code)
.data(data)
.build();
}
}
This wrapper gives consistent response formats for your frontend.
5. Service Layer
The service layer is where the magic happens - API key injection and external calls:
// service/WeatherService.java
package com.sollo_script.weather_board_api_service.service;
import java.util.List;
import java.util.Optional;
public interface WeatherService {
OpenWeatherResponse getWeather(double lat, double lon, Optional<String> apiKey);
List<GeocodeResponse> getGeocode(String location, Integer limit, Optional<String> apiKey);
AirPollutionResponse getAirPollution(double lat, double lon, Optional<String> apiKey);
byte[] getMapLayerImage(String mapType, int z, int x, int y, Optional<String> apiKey);
}
Implementation with API Key Injection:
// service/impl/WeatherServiceImpl.java
@Service
public class WeatherServiceImpl implements WeatherService {
@Value("${openweathermap.api.apikey}")
private String internalApiKey; // <-- From environment variable!
private final JsonDailyLogRepository dailyLogRepository;
private final RestClient openWeatherRestClient;
private final RestClient openWeatherTileRestClient;
// Constructor injection...
@Override
public OpenWeatherResponse getWeather(double lat, double lon, Optional<String> apiKey) {
var currentLog = this.fetchDayLog();
var currentCount = currentLog.getCallCount();
// Use internal key if none provided
if (apiKey.isEmpty() || apiKey.get().equals(internalApiKey)) {
// Limit to 900 calls per day
if (currentCount >= 900) {
throw new TooManyRequestsException("Daily API limit exceeded");
}
currentLog.setCallCount(currentCount + 1);
dailyLogRepository.save(currentLog);
}
// Call OpenWeatherMap with the API key
var response = openWeatherRestClient.get()
.uri(uriBuilder -> uriBuilder
.path("/data/3.0/onecall")
.queryParam("lat", lat)
.queryParam("lon", lon)
.queryParam("units", "metric")
.queryParam("exclude", "minutely,alerts")
.queryParam("appid", apiKey.orElse(internalApiKey)) // <-- Key added here!
.build())
.retrieve()
.body(String.class);
return new ObjectMapper().readValue(response, OpenWeatherResponse.class);
}
// Map tiles - note the different URL pattern
@Override
public byte[] getMapLayerImage(String mapType, int z, int x, int y, Optional<String> apiKey) {
return openWeatherTileRestClient.get()
.uri(uriBuilder -> uriBuilder
.path("/{mapType}/{z}/{x}/{y}.png")
.queryParam("appid", apiKey.orElse(internalApiKey))
.build(mapType, z, x, y))
.retrieve()
.body(byte[].class);
}
}
Key points:
@Value("${openweathermap.api.apikey}")- Loads from environmentapiKey.orElse(internalApiKey)- Uses provided key or falls back to internalDaily call tracking prevents quota exhaustion
6. Controller Layer
The controller exposes REST endpoints for the frontend:
// controller/WeatherController.java
@Controller
@RequestMapping("/openweather")
public class WeatherController {
private final WeatherService weatherService;
private final RateLimitService rateLimitService;
public WeatherController(WeatherService weatherService, RateLimitService rateLimitService) {
this.weatherService = weatherService;
this.rateLimitService = rateLimitService;
}
@GetMapping("/weather")
@ResponseBody
public ResponseEntity<ApiResponse<OpenWeatherResponse>> getWeather(
@RequestParam double lat,
@RequestParam double lon,
@RequestParam Optional<String> apiKey,
HttpServletRequest request) {
// Check rate limit
Bucket bucket = rateLimitService.resolveBucket(request.getRemoteAddr());
if (bucket.tryConsume(1)) {
return ResponseEntity.ok(
ApiResponse.success(weatherService.getWeather(lat, lon, apiKey))
);
} else {
throw new TooManyRequestsException("Rate limit exceeded");
}
}
@GetMapping("/geocode")
@ResponseBody
public ResponseEntity<ApiResponse<List<GeocodeResponse>>> getGeocode(
@RequestParam String location,
@RequestParam Integer limit,
@RequestParam Optional<String> apiKey) {
return ResponseEntity.ok(
ApiResponse.success(weatherService.getGeocode(location, limit, apiKey))
);
}
@GetMapping("/air_pollution")
@ResponseBody
public ResponseEntity<ApiResponse<AirPollutionResponse>> getAirPollution(
@RequestParam double lat,
@RequestParam double lon,
@RequestParam Optional<String> apiKey) {
return ResponseEntity.ok(
ApiResponse.success(weatherService.getAirPollution(lat, lon, apiKey))
);
}
// Map tiles with caching
@GetMapping(value = "/map_layer/{mapType}/{z}/{x}/{y}", produces = "image/png")
@ResponseBody
@Cacheable(value = "mapTiles", key = "#mapType + '_' + #z + '_' + #x + '_' + #y")
public ResponseEntity<byte[]> getMapLayer(
@PathVariable String mapType,
@PathVariable int z,
@PathVariable int x,
@PathVariable int y,
@RequestParam Optional<String> apiKey) {
byte[] imageBytes = weatherService.getMapLayerImage(mapType, z, x, y, apiKey);
return ResponseEntity.ok()
.contentType(MediaType.IMAGE_PNG)
.cacheControl(CacheControl.maxAge(7, TimeUnit.DAYS))
.contentLength(imageBytes.length)
.body(imageBytes);
}
}
7. Rate Limiting
Protect your API from abuse with Bucket4j:
// service/RateLimitService.java
@Service
public class RateLimitService {
private final Map<String, Bucket> buckets = new ConcurrentHashMap<>();
public Bucket resolveBucket(String ip) {
return buckets.computeIfAbsent(ip, this::createNewBucket);
}
private Bucket createNewBucket(String ip) {
Bandwidth limit = Bandwidth.builder()
.capacity(2) // Start with 2 tokens
.refillGreedy(5, Duration.ofSeconds(1)) // Refill 5 per second
.build();
return Bucket.builder()
.addLimit(limit)
.build();
}
}
This creates per-IP rate limiting with a burst capacity of 2 requests and 5 requests/second refill.
8. Caching
Map tiles are perfect for caching - they don't change often:
// config/CacheConfig.java
@Configuration
@EnableCaching
public class CacheConfig {
@Bean
public CacheManager cacheManager() {
CaffeineCacheManager cacheManager = new CaffeineCacheManager("mapTiles");
cacheManager.setCaffeine(caffeineCacheBuilder());
return cacheManager;
}
private Caffeine<Object, Object> caffeineCacheBuilder() {
return Caffeine
.newBuilder()
.maximumSize(1000) // Max 1000 tiles in cache
.expireAfterWrite(7, TimeUnit.DAYS) // Keep for 7 days
.recordStats();
}
}
Used with @Cacheable on the controller method, tiles are cached in memory.
9. Data Storage Evolution: SQL to JSON Files
Why We Changed It
This project was initially designed with a MySQL database for tracking API usage:
// ORIGINAL: entity/DailyLog.java with JPA annotations
@Table(name = "daily_calls")
@Entity
public class DailyLog {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "api_name")
private String apiName;
@Column(name = "usage_date")
private LocalDate usageDate;
@Column(name = "call_count")
private Long callCount;
}
The Problem:
Hosting a MySQL database adds cost and complexity
Free tiers often have limitations (sleeping databases, connection limits)
For a small project tracking daily API calls, it's overkill
Adds a dependency that can fail
The Solution: Simple JSON file storage
// CURRENT: entity/DailyLog.java - Just a POJO
@Data
@NoArgsConstructor
@AllArgsConstructor
// @Table(name = "daily_calls") <-- Commented out
// @Entity <-- Commented out
public class DailyLog {
// @Id <-- Commented out
// @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// @Column(name = "api_name")
private String apiName;
// @Column(name = "usage_date")
private LocalDate usageDate;
// @Column(name = "call_count")
private Long callCount;
}
The JSON Repository
// repository/JsonDailyLogRepository.java
@Repository
public class JsonDailyLogRepository implements DailyLogRepositoryInterface {
private static final String JSON_FILE_PATH = "data/daily_logs.json";
private static final int MAX_RECORD_AGE_DAYS = 30; // Auto-cleanup!
private final ObjectMapper objectMapper;
private final Path filePath;
private final AtomicLong idCounter;
@PostConstruct
public void init() {
// Create data directory if needed
Path parentDir = filePath.getParent();
if (parentDir != null && !Files.exists(parentDir)) {
Files.createDirectories(parentDir);
}
// Initialize empty JSON array if file doesn't exist
if (!Files.exists(filePath)) {
Files.writeString(filePath, "[]");
}
// Find max ID to continue sequence
List<DailyLog> existingLogs = readAllLogs();
long maxId = existingLogs.stream()
.filter(log -> log.getId() != null)
.mapToLong(DailyLog::getId)
.max().orElse(0L);
idCounter.set(maxId + 1);
// Clean up old records
removeOldRecords();
}
public Optional<DailyLog> findByApiNameAndUsageDate(String apiName, LocalDate usageDate) {
List<DailyLog> logs = readAllLogs();
return logs.stream()
.filter(log -> log.getApiName().equals(apiName)
&& (log.getUsageDate().isEqual(usageDate)
|| log.getUsageDate().isAfter(usageDate)))
.findFirst();
}
public DailyLog save(DailyLog dailyLog) {
List<DailyLog> logs = readAllLogs();
if (dailyLog.getId() == null) {
// New record - assign ID
dailyLog.setId(idCounter.getAndIncrement());
logs.add(dailyLog);
} else {
// Update existing
for (int i = 0; i < logs.size(); i++) {
if (logs.get(i).getId().equals(dailyLog.getId())) {
logs.set(i, dailyLog);
break;
}
}
}
logs = removeOldRecordsFromList(logs); // Auto-cleanup!
writeAllLogs(logs);
return dailyLog;
}
private List<DailyLog> readAllLogs() {
String content = Files.readString(filePath);
if (content.isBlank()) return new ArrayList<>();
return objectMapper.readValue(content, new TypeReference<List<DailyLog>>() {});
}
private void writeAllLogs(List<DailyLog> logs) {
String jsonContent = objectMapper.writeValueAsString(logs);
Files.writeString(filePath, jsonContent);
}
private List<DailyLog> removeOldRecordsFromList(List<DailyLog> logs) {
LocalDate cutOffDate = LocalDate.now().minusDays(MAX_RECORD_AGE_DAYS);
return logs.stream()
.filter(log -> !log.getUsageDate().isBefore(cutOffDate))
.toList();
}
}
Benefits of JSON File Storage:
β Zero external dependencies
β Works on any hosting platform
β Auto-cleanup of old records
β Human-readable data format
β Perfect for small-scale projects
The resulting daily_logs.json looks like:
[
{"id": 1, "apiName": "OpenWeather", "usageDate": "2025-01-25", "callCount": 142}
]
Deployment
Deploy to Render (Free Tier)
Push your code to GitHub
Create a new Web Service on render.com
Connect your repository
Configure build settings:
Runtime: Docker
Docker Command: Auto-detected from Dockerfile
Add environment variables:
OPENWEATHERMAP_API_KEY=your_key_here PORT=10000Deploy!
Dockerfile for reference:
# Stage 1: Build
FROM maven:3-eclipse-temurin-25 AS build
COPY . /app
WORKDIR /app
RUN mvn clean package -DskipTests
# Stage 2: Run
FROM eclipse-temurin:25-jre
COPY --from=build /app/target/*.jar /app.jar
ENTRYPOINT ["java", "-jar", "/app.jar"]
Frontend Integration
Here's how the Ultra Weather Board frontend uses the gateway:
API Service (src/api.ts)
const baseUrl = "https://weather-board-api-service.onrender.com/api/v1/openweather"
export const getWeather = async (data: { lat: number; lon: number; apiKey?: string }) => {
const { lat, lon, apiKey } = data
const params = new URLSearchParams({
lat: lat.toString(),
lon: lon.toString(),
...(apiKey ? { apiKey } : {}),
})
const res = await fetch(`${baseUrl}/weather?${params}`)
const weatherData = await res.json()
return weatherData.data
}
export const getGeocode = async (data: { location: string; apiKey?: string }) => {
const params = new URLSearchParams({
location: data.location,
limit: "1",
...(data.apiKey ? { apiKey: data.apiKey } : {}),
})
const res = await fetch(`${baseUrl}/geocode?${params}`)
const geocodeData = await res.json()
return geocodeData.data
}
export const getAirPollution = async (data: { lat: number; lon: number; apiKey?: string }) => {
const params = new URLSearchParams({
lat: data.lat.toString(),
lon: data.lon.toString(),
...(data.apiKey ? { apiKey: data.apiKey } : {}),
})
const res = await fetch(`${baseUrl}/air_pollution?${params}`)
return (await res.json()).data
}
Map Component (src/components/Map.tsx)
import { TileLayer } from "react-leaflet"
const Map = ({ coords, mapType, apiKey }) => {
return (
<MapContainer center={[coords.lat, coords.lon]} zoom={5}>
{/* Base map layer */}
<MapTileLayer />
{/* Weather overlay from our gateway! */}
<TileLayer
opacity={0.7}
attribution='© OpenStreetMap contributors'
url={`https://weather-board-api-service.onrender.com/api/v1/openweather/map_layer/${mapType}/{z}/{x}/{y}${apiKey ? `?apiKey=${apiKey}` : ""}`}
/>
<Marker position={[coords.lat, coords.lon]} />
</MapContainer>
)
}
Repository Links
π₯οΈ Frontend (React/TypeScript)
Repository: github.com/solo-exe/ultra-weather-board
Live Demo: Live app
βοΈ Backend API Gateway (Spring Boot)
Repository: github.com/solo-exe/weather_board_api_service
Live API: API health check
πΊ Original Tutorial
YouTube Channel: Austin Davies Tech
Summary
You've learned how to:
Build an API gateway that keeps your API keys secure
Inject environment variables into your Spring Boot application
Add rate limiting to prevent abuse
Cache responses for better performance
Migrate from SQL to JSON files for simpler deployments
Connect your React frontend to the gateway
Now you can deploy your weather dashboard anywhere and showcase it on your portfolio without worrying about API key exposure! π
Inspired by Austin Davies Tech tutorials



