Skip to content

feat(geo): add IP geolocation via ipapi.co - #32

Merged
GRACENOBLE merged 2 commits into
mainfrom
23-feat-ip-geolocation
Jun 23, 2026
Merged

GRACENOBLE merged 2 commits into
mainfrom
23-feat-ip-geolocation

Conversation

@GRACENOBLE

@GRACENOBLE GRACENOBLE commented Jun 23, 2026 •

Copy link
Copy Markdown
Member

Summary

  • Integrates ipapi.co to resolve requester IPs to geographic metadata (country, region, city, timezone, currency, EU flag)
  • Always initialised — free tier works without a key; Redis caching (24 h TTL per IP) used when REDIS_URL is set
  • GeoFromRequest middleware attaches *domain.GeoLocation to the Gin context; best-effort — never aborts the request on failure
  • Reworks the partial ipgeo implementation that only returned lat/lon into the full feature

Changes

New files

  • internal/domain/geolocation.go — GeoLocation entity
  • internal/usecase/geolocation.go — GeoLocator interface
  • internal/transport/middleware/geo.go — GeoFromRequest middleware + RealIP helper (XFF → X-Real-IP → RemoteAddr)
  • backend/docs/geo.md — full topic doc

Updated files

  • infrastructure/ipgeo/ipapi_client.go — rewritten: New/NewWithBaseURL constructors, private-IP short-circuit, cache-aside with 24 h TTL, optional ?key= param, User-Agent: template-backend/1.0
  • Bootstrap: GeoLocator always wired in Run(); IPAPIKey in Config
  • Handler: geoLocator as 10th field; GeoFromRequest applied to /api/v1 group
  • backend/.env.example: IPAPI_KEY= (optional)
  • Docs: environment.md, bootstrap.md, middleware.md updated

Test plan

  • go test ./internal/infrastructure/ipgeo/... — 6 unit tests (valid IP, private IP, API error, non-200, cache hit, API key param)
  • go test ./internal/transport/middleware/... — 6 tests (geo attach, skip on error, skip on private IP, RealIP variants)
  • Manual: make a request to any /api/v1 route from a public IP and read the geo_location key from context
  • Manual: verify no regression when IPAPI_KEY is unset (free tier)
  • Manual: verify no regression when REDIS_URL is unset (no caching, still works)

Closes #23

Summary by CodeRabbit

  • New Features

    • Added IP geolocation that resolves request IP to geographic metadata (country, region, city, timezone, currency) and flags EU status.
    • Geo lookup runs best-effort per request and is available to API handlers via request context when successful.
    • Optional caching with a 24-hour TTL improves performance when caching is available.
    • Added robust client IP extraction, with forwarding headers honored only when the direct client IP is trusted.
  • Documentation

    • Updated docs for the IP geolocation feature, configuration, and middleware behavior.
    • Added IPAPI_KEY as an optional environment variable (free tier works without it).
  • Tests

    • Expanded coverage for geolocation lookup, caching behavior, and client IP extraction rules.

Integrates ipapi.co to resolve requester IPs to geographic metadata.
The client is always initialised (free tier works without a key); Redis
caching (24 h TTL) is used when REDIS_URL is set.

- domain/geolocation.go: GeoLocation entity (IP, CountryCode,
  CountryName, Region, City, Timezone, Currency, IsEU)
- usecase/geolocation.go: GeoLocator interface
- infrastructure/ipgeo/ipapi_client.go: full rewrite — New/NewWithBaseURL
  constructors, private-IP short-circuit, cache-aside lookup, optional
  API key (?key= param), User-Agent: template-backend/1.0
- transport/middleware/geo.go: GeoFromRequest best-effort middleware +
  exported RealIP helper (XFF → X-Real-IP → RemoteAddr precedence)
- Bootstrap: GeoLocator always wired in Run(); IPAPIKey added to Config
- Handler: geoLocator field (10th param); applied to /api/v1 group
- .env.example: IPAPI_KEY= (optional, free tier comment)
- Docs: geo.md created; environment.md, bootstrap.md, middleware.md updated

Closes #23
@github-actions github-actions Bot added the area: backend Go REST API label Jun 23, 2026
@coderabbitai

coderabbitai Bot commented Jun 23, 2026 •

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: fb1289dc-3e4d-425d-ac6e-8180792f7d25

📥 Commits

Reviewing files that changed from the base of the PR and between 90b6e47 and 11094ca.

📒 Files selected for processing (4)
  • backend/internal/infrastructure/ipgeo/ipapi_client.go
  • backend/internal/infrastructure/ipgeo/ipapi_client_test.go
  • backend/internal/transport/middleware/geo.go
  • backend/internal/transport/middleware/geo_test.go
✅ Files skipped from review due to trivial changes (1)
  • backend/internal/transport/middleware/geo_test.go
🚧 Files skipped from review as they are similar to previous changes (3)
  • backend/internal/transport/middleware/geo.go
  • backend/internal/infrastructure/ipgeo/ipapi_client.go
  • backend/internal/infrastructure/ipgeo/ipapi_client_test.go

📝 Walkthrough

Walkthrough

Adds end-to-end IP geolocation support via ipapi.co. Introduces domain.GeoLocation, a usecase.GeoLocator interface, and an ipgeo.Client that caches lookups in Redis for 24 hours. A new GeoFromRequest Gin middleware and RealIP helper attach geo metadata to request contexts. Bootstrap wires the client unconditionally; the IPAPI_KEY env var is optional.

Changes

IP Geolocation Feature

Layer / File(s) Summary
Domain entity and GeoLocator interface
backend/internal/domain/geolocation.go, backend/internal/usecase/geolocation.go
Introduces domain.GeoLocation struct (IP, country, region, city, timezone, currency, IsEU) and the usecase.GeoLocator interface with a single Lookup(ctx, ip) method.
ipgeo.Client implementation with caching
backend/internal/infrastructure/ipgeo/ipapi_client.go
Refactors IPAPIClient into Client with New/NewWithBaseURL constructors, updated ipapi.co JSON schema, cacheKey helper, Redis read-through/write-back at 24h TTL, early ErrPrivateIP exit for private/loopback IPs, and fetch with optional API-key query param.
ipgeo.Client tests
backend/internal/infrastructure/ipgeo/ipapi_client_test.go
Replaces TestIPAPIClient_Locate_* with TestClient_Lookup_* cases covering valid IP, private IP short-circuit, API error body, non-200 status, cache hit (HTTP call counter), and API key query param using mockCacheService and httptest.
GeoFromRequest middleware and RealIP helper
backend/internal/transport/middleware/geo.go, backend/internal/transport/middleware/geo_test.go
Adds GeoLocationKey constant, GeoFromRequest middleware (stores result in Gin context, always calls Next), and RealIP helper (X-Forwarded-For → X-Real-IP → RemoteAddr precedence with public-IP spoofing defense). Tests verify success, error-skip, private-IP-skip, and all three IP extraction paths.
Bootstrap, handler wiring, and route registration
backend/internal/bootstrap/bootstrap.go, backend/internal/server/server.go, backend/internal/transport/handlers/handler.go, backend/internal/transport/handlers/routes.go, backend/internal/transport/handlers/health_handler_test.go
bootstrap.Run creates ipgeo.Client from IPAPI_KEY and logs cache status; App.GeoLocator is always non-nil after a successful run. NewHandler gains a geoLocator param; RegisterRoutes conditionally installs GeoFromRequest on /api/v1. Health tests updated for the new constructor signature.
Environment config and documentation
backend/.env.example, backend/docs/geo.md, backend/docs/...
Adds IPAPI_KEY to .env.example and environment.md; introduces geo.md covering feature overview, domain model, client API, middleware semantics, bootstrap wiring, env config, and testing patterns; updates _index.md, bootstrap.md, and middleware.md with new entries and sections.

Sequence Diagram(s)

sequenceDiagram
  participant Client as HTTP Client
  participant GeoFromRequest
  participant ipgeo_Client as ipgeo.Client
  participant Redis
  participant ipapi_co as ipapi.co

  Client->>GeoFromRequest: HTTP request to /api/v1/...
  GeoFromRequest->>GeoFromRequest: RealIP (X-Forwarded-For / X-Real-IP / RemoteAddr)
  GeoFromRequest->>ipgeo_Client: Lookup(ctx, ip)
  ipgeo_Client->>ipgeo_Client: isPrivateIP? → ErrPrivateIP (skip cache/HTTP)
  ipgeo_Client->>Redis: Get("geo:<ip>")
  alt cache hit
    Redis-->>ipgeo_Client: cached GeoLocation JSON
    ipgeo_Client-->>GeoFromRequest: *domain.GeoLocation
  else cache miss
    Redis-->>ipgeo_Client: miss
    ipgeo_Client->>ipapi_co: GET /<ip>/json[?key=IPAPI_KEY]
    ipapi_co-->>ipgeo_Client: JSON (country/region/city/...)
    ipgeo_Client->>Redis: Set("geo:<ip>", json, 24h) [best-effort]
    ipgeo_Client-->>GeoFromRequest: *domain.GeoLocation
  end
  GeoFromRequest->>GeoFromRequest: c.Set(GeoLocationKey, geo)
  GeoFromRequest->>Client: c.Next() → handler response
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • GRACENOBLE/fullstack-template#24: Both PRs modify RegisterRoutes middleware wiring on the /api/v1 Gin group to conditionally attach request middleware and extend Handler/NewHandler with a new dependency injection parameter.
  • GRACENOBLE/fullstack-template#30: Both PRs extend Handler struct and NewHandler constructor to inject an additional dependency, modifying the same handler construction layer.

Suggested labels

area: backend, type: chore

🐇 A rabbit once wandered through IPs galore,
Checking each country, each region, each shore.
With Redis to cache and ipapi to call,
Now GeoFromRequest can locate them all!
No private IPs — just the world's open door. 🌍

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat(geo): add IP geolocation via ipapi.co' accurately summarizes the primary change: integrating IP geolocation functionality using ipapi.co.
Linked Issues check ✅ Passed PR implementation addresses all core requirements from #23: GeoLocator interface, GeoLocation entity, ipapi HTTP integration, Redis caching, RealIP middleware, IPAPI_KEY configuration, input validation, cache error logging, IP spoofing prevention, and comprehensive tests. Acceptance criteria met.
Out of Scope Changes check ✅ Passed All changes are directly related to IP geolocation integration per #23 scope. Documentation updates, middleware enhancements (metrics, local network), and test infrastructure changes all support the core geolocation feature implementation.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch 23-feat-ip-geolocation

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 golangci-lint (2.12.2)

level=error msg="[linters_context] typechecking error: pattern ./...: directory prefix . does not contain main module or its selected dependencies"


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (2)
backend/internal/infrastructure/ipgeo/ipapi_client_test.go (2)

98-121: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Add a malformed-IP case that asserts no outbound HTTP call.

Please add a test for an invalid IP (for example "not-an-ip") and verify lookup fails before any server call. This protects the no-network short-circuit behavior against regressions.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/internal/infrastructure/ipgeo/ipapi_client_test.go` around lines 98 -
121, Add a test case for malformed IP addresses (such as "not-an-ip") within the
TestClient_Lookup_PrivateIP function or create a separate test function
TestClient_Lookup_MalformedIP. Similar to the private IP test, verify that when
client.Lookup is called with an invalid IP string, it returns an appropriate
error before making any HTTP requests, and confirm that the HTTP server callback
(the called flag) remains false to ensure the lookup short-circuits invalid
input validation before attempting network calls.

30-33: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Assert cache TTL to lock the 24h contract.

mockCacheService.Set currently ignores the TTL argument, so TestClient_Lookup_CacheHit can’t detect TTL regressions. Capture the duration and assert 24*time.Hour.

🔧 Suggested test improvement
 type mockCacheService struct {
 	store map[string]string
+	lastTTL time.Duration
 }
@@
 func (m *mockCacheService) Set(_ context.Context, key string, value string, _ time.Duration) error {
 	m.store[key] = value
+	m.lastTTL = ttl
 	return nil
 }
@@
 	if callCount != 1 {
 		t.Errorf("expected still 1 HTTP call after cache hit, got %d", callCount)
 	}
+	if cache.lastTTL != 24*time.Hour {
+		t.Errorf("cache TTL: got %v, want %v", cache.lastTTL, 24*time.Hour)
+	}

Also applies to: 155-199

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/internal/infrastructure/ipgeo/ipapi_client_test.go` around lines 30 -
33, In the mockCacheService.Set method, the TTL duration parameter is currently
ignored using the blank identifier. Modify the mock struct to add a field to
store the duration argument, then update the Set method to capture the duration
parameter into this field instead of ignoring it. Finally, add an assertion in
TestClient_Lookup_CacheHit (and any other relevant tests) to verify that the
captured TTL equals 24*time.Hour to ensure the cache TTL contract is maintained
and detect potential regressions.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@backend/internal/infrastructure/ipgeo/ipapi_client.go`:
- Around line 76-79: Add IP format validation at the beginning of the Lookup
method before the isPrivateIP check to reject malformed IP strings early and
prevent unnecessary external calls. Use Go's net package to validate the IP
format (e.g., net.ParseIP) and return an appropriate error if the IP is invalid,
ensuring that untrusted header input cannot trigger external API calls and
rate-limit pressure on the downstream service.
- Around line 83-84: In the `Lookup` method of the `ipapi_client.go` file,
handle cache read and write errors instead of silently ignoring them. When
calling `c.cache.Get` on line 83-84, if the error is not nil, return the wrapped
error to the caller instead of continuing execution. Similarly, for the cache
write operations around lines 99-101, capture and return any errors from cache
write calls instead of discarding them. This ensures infrastructure faults in
Redis are properly surfaced and prevents non-deterministic behavior under cache
failures.

In `@backend/internal/transport/middleware/geo.go`:
- Around line 34-38: The IP extraction logic in the function containing lines 34
and 37 unconditionally trusts the X-Forwarded-For and X-Real-IP headers without
validating that they come from a trusted proxy, allowing clients to forge these
headers and spoof their IP address. Modify the logic to only extract IPs from
these forwarding headers when the request originates from a trusted proxy or
reverse proxy. This typically means checking if the immediate upstream
connection is from a known trusted source before using the forwarding headers;
otherwise, fall back to extracting the IP directly from the request's remote
address to prevent IP spoofing attacks.

---

Nitpick comments:
In `@backend/internal/infrastructure/ipgeo/ipapi_client_test.go`:
- Around line 98-121: Add a test case for malformed IP addresses (such as
"not-an-ip") within the TestClient_Lookup_PrivateIP function or create a
separate test function TestClient_Lookup_MalformedIP. Similar to the private IP
test, verify that when client.Lookup is called with an invalid IP string, it
returns an appropriate error before making any HTTP requests, and confirm that
the HTTP server callback (the called flag) remains false to ensure the lookup
short-circuits invalid input validation before attempting network calls.
- Around line 30-33: In the mockCacheService.Set method, the TTL duration
parameter is currently ignored using the blank identifier. Modify the mock
struct to add a field to store the duration argument, then update the Set method
to capture the duration parameter into this field instead of ignoring it.
Finally, add an assertion in TestClient_Lookup_CacheHit (and any other relevant
tests) to verify that the captured TTL equals 24*time.Hour to ensure the cache
TTL contract is maintained and detect potential regressions.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 78595098-b9f3-4aa6-aa0d-b5560efb5470

📥 Commits

Reviewing files that changed from the base of the PR and between 658e0ec and 90b6e47.

📒 Files selected for processing (17)
  • backend/.env.example
  • backend/docs/_index.md
  • backend/docs/bootstrap.md
  • backend/docs/environment.md
  • backend/docs/geo.md
  • backend/docs/middleware.md
  • backend/internal/bootstrap/bootstrap.go
  • backend/internal/domain/geolocation.go
  • backend/internal/infrastructure/ipgeo/ipapi_client.go
  • backend/internal/infrastructure/ipgeo/ipapi_client_test.go
  • backend/internal/server/server.go
  • backend/internal/transport/handlers/handler.go
  • backend/internal/transport/handlers/health_handler_test.go
  • backend/internal/transport/handlers/routes.go
  • backend/internal/transport/middleware/geo.go
  • backend/internal/transport/middleware/geo_test.go
  • backend/internal/usecase/geolocation.go

Comment thread backend/internal/infrastructure/ipgeo/ipapi_client.go
Comment thread backend/internal/infrastructure/ipgeo/ipapi_client.go Outdated
Comment thread backend/internal/transport/middleware/geo.go Outdated
- Reject malformed IP strings before isPrivateIP/fetch — net.ParseIP
  returning nil now returns an error immediately, preventing unnecessary
  outbound calls from untrusted header input
- Log cache read/write errors via slog.WarnContext instead of silently
  swallowing them; errors are non-fatal (cache miss falls through to HTTP,
  cache write failure still returns valid geo data)
- RealIP now only trusts X-Forwarded-For / X-Real-IP when RemoteAddr is
  a private or loopback address (i.e. the connection came through a
  trusted proxy); direct clients with public RemoteAddr cannot spoof the
  originating IP via forwarding headers
- Add TestClient_Lookup_InvalidIP and TestRealIP_XForwardedFor_IgnoredFromPublicAddr
@GRACENOBLE
GRACENOBLE merged commit daae38b into main Jun 23, 2026
3 checks passed
@GRACENOBLE
GRACENOBLE deleted the 23-feat-ip-geolocation branch June 23, 2026 05:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: backend Go REST API

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: add IP geolocation via ipapi

1 participant