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:
@Cacheablewith Caffeine at /24 subnet level; separate cache for FX rates - GeoFilter: injects
visitorGeoattribute into everyHttpServletRequest - PricingController: reads the attribute, localizes all plan prices before rendering
- REST endpoint:
/api/visitor-geofor 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