Skip to main content

Command Palette

Search for a command to run...

πŸ”API Gateway to Utilize OpenWeather APIs

Updated
β€’13 min read
πŸ”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

  1. Introduction

  2. Quick Start (Just Use the Gateway)

  3. Understanding the Problem

  4. Architecture Overview

  5. Building the Gateway from Scratch

  6. Deployment

  7. Frontend Integration

  8. Repository Links


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

EndpointMethodDescription
/weather?lat={lat}&lon={lon}GETCurrent weather + forecast
/geocode?location={city}&limit=1GETSearch for locations
/air_pollution?lat={lat}&lon={lon}GETAir quality data
/map_layer/{type}/{z}/{x}/{y}GETWeather 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:

  1. Clone: git clone https://github.com/solo-exe/weather_board_api_service.git

  2. Get an OpenWeatherMap API key

  3. Deploy to Render, Railway, or any Java hosting

See deployment section β†’


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:

  1. Receives the request

  2. Adds the API key (stored securely in environment variables)

  3. Forwards to OpenWeatherMap

  4. 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 environment

  • apiKey.orElse(internalApiKey) - Uses provided key or falls back to internal

  • Daily 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)

  1. Push your code to GitHub

  2. Create a new Web Service on render.com

  3. Connect your repository

  4. Configure build settings:

    • Runtime: Docker

    • Docker Command: Auto-detected from Dockerfile

  5. Add environment variables:

     OPENWEATHERMAP_API_KEY=your_key_here
     PORT=10000
    
  6. Deploy!

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='&copy; 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>
    )
}

πŸ–₯️ 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:

  1. Build an API gateway that keeps your API keys secure

  2. Inject environment variables into your Spring Boot application

  3. Add rate limiting to prevent abuse

  4. Cache responses for better performance

  5. Migrate from SQL to JSON files for simpler deployments

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