Implement IP API daemon with GeoIP database support
- Create modular architecture with separate packages for config, database, HTTP, logging, and state management - Implement Cobra CLI with daemon command - Set up Uber FX dependency injection - Add Chi router with health check and IP lookup endpoints - Implement GeoIP database downloader with automatic updates - Add state persistence for tracking database download times - Include comprehensive test coverage for all components - Configure structured logging with slog - Add Makefile with test, lint, and build targets - Support both IPv4 and IPv6 lookups - Return country, city, ASN, and location data in JSON format
This commit is contained in:
158
internal/http/router.go
Normal file
158
internal/http/router.go
Normal file
@@ -0,0 +1,158 @@
|
||||
// Package http provides the HTTP server and routing functionality.
|
||||
package http
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"git.eeqj.de/sneak/ipapi/internal/database"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
)
|
||||
|
||||
// IPInfo represents the API response for IP lookups.
|
||||
type IPInfo struct {
|
||||
IP string `json:"ip"`
|
||||
Country string `json:"country,omitempty"`
|
||||
CountryCode string `json:"countryCode,omitempty"`
|
||||
City string `json:"city,omitempty"`
|
||||
Region string `json:"region,omitempty"`
|
||||
PostalCode string `json:"postalCode,omitempty"`
|
||||
Latitude float64 `json:"latitude,omitempty"`
|
||||
Longitude float64 `json:"longitude,omitempty"`
|
||||
Timezone string `json:"timezone,omitempty"`
|
||||
ASN uint `json:"asn,omitempty"`
|
||||
ASNOrg string `json:"asnOrg,omitempty"`
|
||||
}
|
||||
|
||||
// NewRouter creates a new HTTP router with all endpoints configured.
|
||||
func NewRouter(logger *slog.Logger, db *database.Manager) (chi.Router, error) {
|
||||
r := chi.NewRouter()
|
||||
|
||||
// Middleware
|
||||
r.Use(middleware.RequestID)
|
||||
r.Use(middleware.RealIP)
|
||||
r.Use(middleware.Recoverer)
|
||||
const requestTimeout = 60
|
||||
r.Use(middleware.Timeout(requestTimeout))
|
||||
|
||||
// Logging middleware
|
||||
r.Use(func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := r.Context().Value(middleware.RequestIDKey).(string)
|
||||
logger.Debug("HTTP request",
|
||||
"method", r.Method,
|
||||
"path", r.URL.Path,
|
||||
"remote_addr", r.RemoteAddr,
|
||||
"request_id", start,
|
||||
)
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
})
|
||||
|
||||
// Health check
|
||||
r.Get("/health", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if _, err := w.Write([]byte("OK")); err != nil {
|
||||
logger.Error("Failed to write health response", "error", err)
|
||||
}
|
||||
})
|
||||
|
||||
// IP lookup endpoint
|
||||
r.Get("/api/{ip}", handleIPLookup(logger, db))
|
||||
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func handleIPLookup(logger *slog.Logger, db *database.Manager) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ipStr := chi.URLParam(r, "ip")
|
||||
|
||||
// Validate IP address
|
||||
ip := net.ParseIP(ipStr)
|
||||
if ip == nil {
|
||||
writeError(w, http.StatusBadRequest, "Invalid IP address")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
info := &IPInfo{
|
||||
IP: ipStr,
|
||||
}
|
||||
|
||||
// Look up in Country database
|
||||
if countryDB := db.GetCountryDB(); countryDB != nil {
|
||||
country, err := countryDB.Country(ip)
|
||||
if err == nil {
|
||||
info.Country = country.Country.Names["en"]
|
||||
info.CountryCode = country.Country.IsoCode
|
||||
}
|
||||
}
|
||||
|
||||
// Look up in City database
|
||||
if cityDB := db.GetCityDB(); cityDB != nil {
|
||||
city, err := cityDB.City(ip)
|
||||
if err == nil {
|
||||
info.City = city.City.Names["en"]
|
||||
if len(city.Subdivisions) > 0 {
|
||||
info.Region = city.Subdivisions[0].Names["en"]
|
||||
}
|
||||
info.PostalCode = city.Postal.Code
|
||||
info.Latitude = city.Location.Latitude
|
||||
info.Longitude = city.Location.Longitude
|
||||
info.Timezone = city.Location.TimeZone
|
||||
}
|
||||
}
|
||||
|
||||
// Look up in ASN database
|
||||
if asnDB := db.GetASNDB(); asnDB != nil {
|
||||
asn, err := asnDB.ASN(ip)
|
||||
if err == nil {
|
||||
info.ASN = asn.AutonomousSystemNumber
|
||||
info.ASNOrg = asn.AutonomousSystemOrganization
|
||||
}
|
||||
}
|
||||
|
||||
// Set content type and encode response
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(info); err != nil {
|
||||
logger.Error("Failed to encode response", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "Internal server error")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, code int, message string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(code)
|
||||
if err := json.NewEncoder(w).Encode(map[string]string{
|
||||
"error": message,
|
||||
}); err != nil {
|
||||
// Log error but don't try to write again
|
||||
_ = err
|
||||
}
|
||||
}
|
||||
|
||||
//nolint:unused // will be used in future for rate limiting
|
||||
func getClientIP(r *http.Request) string {
|
||||
// Check X-Forwarded-For header
|
||||
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
||||
ips := strings.Split(xff, ",")
|
||||
if len(ips) > 0 {
|
||||
return strings.TrimSpace(ips[0])
|
||||
}
|
||||
}
|
||||
|
||||
// Check X-Real-IP header
|
||||
if xri := r.Header.Get("X-Real-IP"); xri != "" {
|
||||
return xri
|
||||
}
|
||||
|
||||
// Fall back to RemoteAddr
|
||||
host, _, _ := net.SplitHostPort(r.RemoteAddr)
|
||||
|
||||
return host
|
||||
}
|
||||
151
internal/http/router_test.go
Normal file
151
internal/http/router_test.go
Normal file
@@ -0,0 +1,151 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"git.eeqj.de/sneak/ipapi/internal/config"
|
||||
"git.eeqj.de/sneak/ipapi/internal/database"
|
||||
"git.eeqj.de/sneak/ipapi/internal/state"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
func TestNewRouter(t *testing.T) {
|
||||
logger := slog.Default()
|
||||
cfg := &config.Config{
|
||||
StateDir: t.TempDir(),
|
||||
}
|
||||
stateManager, _ := state.New(cfg, logger)
|
||||
db, _ := database.New(cfg, logger, stateManager)
|
||||
|
||||
router, err := NewRouter(logger, db)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create router: %v", err)
|
||||
}
|
||||
|
||||
if router == nil {
|
||||
t.Fatal("expected router, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthEndpoint(t *testing.T) {
|
||||
logger := slog.Default()
|
||||
cfg := &config.Config{
|
||||
StateDir: t.TempDir(),
|
||||
}
|
||||
stateManager, _ := state.New(cfg, logger)
|
||||
db, _ := database.New(cfg, logger, stateManager)
|
||||
|
||||
router, _ := NewRouter(logger, db)
|
||||
|
||||
req := httptest.NewRequest("GET", "/health", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("expected status 200, got %d", rec.Code)
|
||||
}
|
||||
|
||||
if rec.Body.String() != "OK" {
|
||||
t.Errorf("expected body 'OK', got %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestIPLookupEndpoint(t *testing.T) {
|
||||
logger := slog.Default()
|
||||
cfg := &config.Config{
|
||||
StateDir: t.TempDir(),
|
||||
}
|
||||
stateManager, _ := state.New(cfg, logger)
|
||||
db, _ := database.New(cfg, logger, stateManager)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
ip string
|
||||
expectedCode int
|
||||
}{
|
||||
{"valid IPv4", "8.8.8.8", http.StatusOK},
|
||||
{"valid IPv6", "2001:4860:4860::8888", http.StatusOK},
|
||||
{"invalid IP", "invalid", http.StatusBadRequest},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/api/"+tt.ip, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
// Create a new context with the URL param
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("ip", tt.ip)
|
||||
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
||||
|
||||
handleIPLookup(logger, db)(rec, req)
|
||||
|
||||
if rec.Code != tt.expectedCode {
|
||||
t.Errorf("expected status %d, got %d", tt.expectedCode, rec.Code)
|
||||
}
|
||||
|
||||
if tt.expectedCode == http.StatusOK {
|
||||
var info IPInfo
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &info); err != nil {
|
||||
t.Errorf("failed to unmarshal response: %v", err)
|
||||
}
|
||||
if info.IP != tt.ip {
|
||||
t.Errorf("expected IP %s, got %s", tt.ip, info.IP)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetClientIP(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
headers map[string]string
|
||||
remoteAddr string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "X-Forwarded-For",
|
||||
headers: map[string]string{
|
||||
"X-Forwarded-For": "1.2.3.4, 5.6.7.8",
|
||||
},
|
||||
remoteAddr: "9.10.11.12:1234",
|
||||
expected: "1.2.3.4",
|
||||
},
|
||||
{
|
||||
name: "X-Real-IP",
|
||||
headers: map[string]string{
|
||||
"X-Real-IP": "1.2.3.4",
|
||||
},
|
||||
remoteAddr: "9.10.11.12:1234",
|
||||
expected: "1.2.3.4",
|
||||
},
|
||||
{
|
||||
name: "RemoteAddr only",
|
||||
headers: map[string]string{},
|
||||
remoteAddr: "9.10.11.12:1234",
|
||||
expected: "9.10.11.12",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
req.RemoteAddr = tt.remoteAddr
|
||||
for k, v := range tt.headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
|
||||
ip := getClientIP(req)
|
||||
if ip != tt.expected {
|
||||
t.Errorf("expected IP %s, got %s", tt.expected, ip)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
66
internal/http/server.go
Normal file
66
internal/http/server.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.eeqj.de/sneak/ipapi/internal/config"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// Server manages the HTTP server lifecycle.
|
||||
type Server struct {
|
||||
config *config.Config
|
||||
logger *slog.Logger
|
||||
router chi.Router
|
||||
httpServer *http.Server
|
||||
}
|
||||
|
||||
// NewServer creates a new HTTP server instance.
|
||||
func NewServer(cfg *config.Config, logger *slog.Logger, router chi.Router) (*Server, error) {
|
||||
return &Server{
|
||||
config: cfg,
|
||||
logger: logger,
|
||||
router: router,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Start begins listening for HTTP requests.
|
||||
func (s *Server) Start(_ context.Context) error {
|
||||
addr := fmt.Sprintf(":%d", s.config.Port)
|
||||
s.httpServer = &http.Server{
|
||||
Addr: addr,
|
||||
Handler: s.router,
|
||||
ReadTimeout: 15 * time.Second, //nolint:mnd
|
||||
WriteTimeout: 15 * time.Second, //nolint:mnd
|
||||
IdleTimeout: 60 * time.Second, //nolint:mnd
|
||||
}
|
||||
|
||||
s.logger.Info("Starting HTTP server", "addr", addr)
|
||||
|
||||
go func() {
|
||||
if err := s.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
s.logger.Error("HTTP server error", "error", err)
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop gracefully shuts down the HTTP server.
|
||||
func (s *Server) Stop(ctx context.Context) error {
|
||||
if s.httpServer == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
s.logger.Info("Stopping HTTP server")
|
||||
|
||||
const shutdownTimeout = 30 * time.Second
|
||||
shutdownCtx, cancel := context.WithTimeout(ctx, shutdownTimeout)
|
||||
defer cancel()
|
||||
|
||||
return s.httpServer.Shutdown(shutdownCtx)
|
||||
}
|
||||
64
internal/http/server_test.go
Normal file
64
internal/http/server_test.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"testing"
|
||||
|
||||
"git.eeqj.de/sneak/ipapi/internal/config"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
func TestNewServer(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Port: 8080,
|
||||
}
|
||||
logger := slog.Default()
|
||||
router := chi.NewRouter()
|
||||
|
||||
server, err := NewServer(cfg, logger, router)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create server: %v", err)
|
||||
}
|
||||
|
||||
if server == nil {
|
||||
t.Fatal("expected server, got nil")
|
||||
}
|
||||
|
||||
if server.config != cfg {
|
||||
t.Error("config not set correctly")
|
||||
}
|
||||
|
||||
if server.logger != logger {
|
||||
t.Error("logger not set correctly")
|
||||
}
|
||||
|
||||
if server.router != router {
|
||||
t.Error("router not set correctly")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerStartStop(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Port: 0, // Use random port
|
||||
}
|
||||
logger := slog.Default()
|
||||
router := chi.NewRouter()
|
||||
|
||||
server, err := NewServer(cfg, logger, router)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create server: %v", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Start server
|
||||
if err := server.Start(ctx); err != nil {
|
||||
t.Fatalf("failed to start server: %v", err)
|
||||
}
|
||||
|
||||
// Stop server
|
||||
if err := server.Stop(ctx); err != nil {
|
||||
t.Fatalf("failed to stop server: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user