JavaSpring BootTutorialGeolocationAPI

IP Geolocation in Spring Boot: Country Detection with RestTemplate and Caffeine Cache

ApogeoAPI6 min read

Java Spring Boot is the backbone of countless enterprise and SaaS applications. Adding IP geolocation often means integrating a GeoIP2 database — but for most use cases, a hosted REST API is simpler: no local database, no weekly update jobs, and live exchange rates included.

Dependencies

<!-- pom.xml -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
    <groupId>com.github.ben-manes.caffeine</groupId>
    <artifactId>caffeine</artifactId>
    <version>3.1.8</version>
</dependency>
<dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-databind</artifactId>
</dependency>

Configuration

# application.yml
apogeoapi:
  key: ${APOGEOAPI_KEY:}
  base-url: https://api.apogeoapi.com/v1

spring:
  cache:
    type: caffeine
    caffeine:
      spec: maximumSize=5000,expireAfterWrite=4h  # 4h matches FX refresh cadence
// src/main/java/com/example/config/GeoConfig.java
@Configuration
@EnableCaching
public class GeoConfig {

    @Bean
    public RestTemplate restTemplate() {
        var factory = new SimpleClientHttpRequestFactory();
        factory.setConnectTimeout(3000);
        factory.setReadTimeout(3000);
        return new RestTemplate(factory);
    }
}

GeoData Record

// GeoData.java
public record GeoData(
    String countryCode,
    String countryName,
    String currencyCode,
    String city,
    String timezone
) {
    public static GeoData fallback() {
        return new GeoData("US", "United States", "USD", null, null);
    }
}

GeoService

// GeoService.java
@Service
@Slf4j
public class GeoService {

    private final RestTemplate restTemplate;
    private final String apiBase;
    private final HttpHeaders headers;

    public GeoService(RestTemplate restTemplate,
                      @Value("${apogeoapi.key}") String apiKey,
                      @Value("${apogeoapi.base-url}") String apiBase) {
        this.restTemplate = restTemplate;
        this.apiBase = apiBase;
        this.headers = new HttpHeaders();
        this.headers.set("X-API-Key", apiKey);
    }

    /**
     * Use /24 subnet as cache key — reduces unique keys 250x for office IPs.
     * e.g. 203.0.113.45 → 203.0.113.0
     */
    private String subnetKey(String ip) {
        String[] parts = ip.split("\\.");
        if (parts.length == 4) {
            return parts[0] + "." + parts[1] + "." + parts[2] + ".0";
        }
        return ip; // IPv6: use full IP
    }

    @Cacheable(value = "geo", key = "#root.target.subnetKey(#ip)")
    public GeoData geolocate(String ip) {
        // Skip loopback/private IPs
        if (ip.equals("127.0.0.1") || ip.equals("::1") ||
            ip.startsWith("192.168.") || ip.startsWith("10.")) {
            return GeoData.fallback();
        }

        try {
            var entity = new HttpEntity<>(headers);
            var response = restTemplate.exchange(
                apiBase + "/ip/" + ip,
                HttpMethod.GET,
                entity,
                GeoData.class
            );
            return response.getBody() != null ? response.getBody() : GeoData.fallback();
        } catch (RestClientException ex) {
            log.warn("GeoService: failed for IP {}: {}", ip, ex.getMessage());
            return GeoData.fallback();
        }
    }

    @Cacheable(value = "fx", key = "#currency")
    public double getExchangeRate(String currency) {
        if ("USD".equals(currency)) return 1.0;

        try {
            var entity = new HttpEntity<>(headers);
            var response = restTemplate.exchange(
                apiBase + "/currencies/" + currency + "/rate",
                HttpMethod.GET,
                entity,
                Map.class
            );
            var body = response.getBody();
            if (body != null && body.containsKey("usdRate")) {
                return ((Number) body.get("usdRate")).doubleValue();
            }
        } catch (RestClientException ex) {
            log.warn("FX rate fetch failed for {}: {}", currency, ex.getMessage());
        }
        return 1.0;
    }

    public LocalizedPrice localizePrice(double usdAmount, String currency) {
        double rate = getExchangeRate(currency);
        long local = Math.round(usdAmount * rate);
        Map<String, String> symbols = Map.of(
            "EUR", "€", "GBP", "£", "BRL", "R$", "ARS", "$",
            "MXN", "$", "CLP", "$", "JPY", "¥", "CNY", "¥", "INR", "₹"
        );
        String symbol = symbols.getOrDefault(currency, currency + " ");
        return new LocalizedPrice(local, currency, symbol, symbol + String.format("%,d", local));
    }

    public record LocalizedPrice(long amount, String currency, String symbol, String formatted) {}
}

WebFilter — Per-Request Geo Injection

// GeoFilter.java
@Component
@Order(Ordered.HIGHEST_PRECEDENCE + 10)
public class GeoFilter implements Filter {

    private static final String GEO_ATTR = "visitorGeo";
    private final GeoService geoService;

    public GeoFilter(GeoService geoService) {
        this.geoService = geoService;
    }

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {

        if (request instanceof HttpServletRequest httpReq) {
            String ip = resolveClientIp(httpReq);
            GeoData geo = geoService.geolocate(ip);
            httpReq.setAttribute(GEO_ATTR, geo);

            // Also set response headers for downstream logging
            if (response instanceof HttpServletResponse httpRes) {
                httpRes.setHeader("X-Visitor-Country", geo.countryCode());
            }
        }

        chain.doFilter(request, response);
    }

    private String resolveClientIp(HttpServletRequest request) {
        // Cloudflare → load balancer → direct
        String cf = request.getHeader("CF-Connecting-IP");
        if (cf != null && !cf.isBlank()) return cf.trim();

        String forwarded = request.getHeader("X-Forwarded-For");
        if (forwarded != null && !forwarded.isBlank()) {
            return forwarded.split(",")[0].trim();
        }

        return request.getRemoteAddr();
    }
}

Controller Usage

// PricingController.java
@Controller
public class PricingController {

    private final GeoService geoService;

    public PricingController(GeoService geoService) {
        this.geoService = geoService;
    }

    @GetMapping("/pricing")
    public String pricing(HttpServletRequest request, Model model) {
        GeoData geo = (GeoData) request.getAttribute("visitorGeo");
        if (geo == null) geo = GeoData.fallback();

        List<Map<String, Object>> plans = List.of(
            Map.of("name", "Basic",        "usd", 19),
            Map.of("name", "Starter",      "usd", 29),
            Map.of("name", "Professional", "usd", 79)
        );

        String currency = geo.currencyCode();
        List<Map<String, Object>> localized = plans.stream().map(plan -> {
            double usd = ((Number) plan.get("usd")).doubleValue();
            GeoService.LocalizedPrice price = geoService.localizePrice(usd, currency);
            var m = new java.util.HashMap<>(plan);
            m.put("local", price);
            return (Map<String, Object>) m;
        }).toList();

        model.addAttribute("plans", localized);
        model.addAttribute("country", geo.countryCode());
        model.addAttribute("currency", currency);
        return "pricing";
    }
}

Thymeleaf Template

<!-- src/main/resources/templates/pricing.html -->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<body>

<p th:text="|Prices for ${country} in ${currency}|" class="text-gray-500"></p>

<div th:each="plan : ${plans}" class="plan-card">
    <h3 th:text="${plan.name}"></h3>
    <p class="price" th:text="${plan.local.formatted}"></p>
    <p class="usd-note" th:text="|${plan.usd} USD|"></p>
</div>

</body>
</html>

REST API Endpoint (Spring MVC)

For SPA frontends, expose a /api/visitor-geo endpoint:

// GeoApiController.java
@RestController
@RequestMapping("/api")
public class GeoApiController {

    @GetMapping("/visitor-geo")
    public ResponseEntity<Map<String, String>> visitorGeo(HttpServletRequest request) {
        GeoData geo = (GeoData) request.getAttribute("visitorGeo");
        if (geo == null) geo = GeoData.fallback();
        return ResponseEntity.ok(Map.of(
            "countryCode", geo.countryCode(),
            "currencyCode", geo.currencyCode(),
            "countryName", geo.countryName()
        ));
    }
}

Cache Configuration — Caffeine

For fine-grained TTL control per cache:

// CacheConfig.java
@Configuration
@EnableCaching
public class CacheConfig {

    @Bean
    public CacheManager cacheManager() {
        CaffeineCacheManager manager = new CaffeineCacheManager();
        manager.setCaffeine(Caffeine.newBuilder()
            .maximumSize(10_000)
            .expireAfterWrite(4, TimeUnit.HOURS)  // 4h: matches FX refresh cadence
            .recordStats());
        return manager;
    }
}

Testing

// GeoServiceTest.java
@SpringBootTest
@TestPropertySource(properties = {
    "apogeoapi.key=test-key",
    "apogeoapi.base-url=http://localhost:8090"
})
class GeoServiceTest {

    @Autowired private GeoService geoService;

    @Test
    void geolocate_returnsCountry() {
        // Mock server returns { countryCode: "US", currencyCode: "USD" }
        GeoData result = geoService.geolocate("8.8.8.8");
        assertThat(result.countryCode()).isEqualTo("US");
    }

    @Test
    void geolocate_fallbackOnLocalIp() {
        GeoData result = geoService.geolocate("127.0.0.1");
        assertThat(result).isEqualTo(GeoData.fallback());
    }
}

Summary

  • GeoService: @Cacheable with Caffeine at /24 subnet level; separate cache for FX rates
  • GeoFilter: injects visitorGeo attribute into every HttpServletRequest
  • PricingController: reads the attribute, localizes all plan prices before rendering
  • REST endpoint: /api/visitor-geo for SPA frontends
  • Graceful fallback: all paths fall back to USD/US on any error

API docs: api.apogeoapi.com/api/docs — free tier at app.apogeoapi.com/register (no credit card required).

Try ApogeoAPI free

1,000 requests/month forever. 14-day full-access trial. No credit card.

Get your free API key