refactor: dependency injection instead of package globals (#57)

* refactor: export Settings type, Setup returns value

* refactor: resolver.Setup takes explicit Settings struct

* refactor: GetHeadersWithoutTrustedHeaders takes explicit header params

* refactor: server constructors take narrow config

* refactor: Router struct with handler methods, remove geoSvc global

* refactor: wire DI through main, remove setting.App references

* refactor: remove App global, use returned Settings

* refactor: update router tests for DI

* chore: fix lint issues — rename ServerTimeouts to Timeouts, fix shadowed variables
This commit is contained in:
2026-07-21 18:37:21 +02:00
committed by GitHub
parent 3874f6af3f
commit 2e32a20f60
19 changed files with 298 additions and 253 deletions
+4 -3
View File
@@ -7,6 +7,7 @@ import (
"strings"
validator "github.com/dcarrillo/whatismyip/internal/validator/uuid"
"github.com/dcarrillo/whatismyip/service"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/patrickmn/go-cache"
@@ -27,7 +28,7 @@ type dnsData struct {
// TODO
// Implement a proper vhost manager instead of using a middleware
func GetDNSDiscoveryHandler(store *cache.Cache, domain string, redirectPort string) gin.HandlerFunc {
func GetDNSDiscoveryHandler(store *cache.Cache, geoSvc *service.Geo, domain string, redirectPort string) gin.HandlerFunc {
return func(ctx *gin.Context) {
host := hostWithoutPort(ctx.Request.Host)
if host != domain && !strings.HasSuffix(host, "."+domain) {
@@ -41,7 +42,7 @@ func GetDNSDiscoveryHandler(store *cache.Cache, domain string, redirectPort stri
return
}
handleDNS(ctx, store)
handleDNS(ctx, store, geoSvc)
ctx.Abort()
}
}
@@ -53,7 +54,7 @@ func hostWithoutPort(host string) string {
return host
}
func handleDNS(ctx *gin.Context, store *cache.Cache) {
func handleDNS(ctx *gin.Context, store *cache.Cache, geoSvc *service.Geo) {
d := strings.Split(ctx.Request.Host, ".")[0]
if !validator.IsValid(d) {
ctx.String(http.StatusNotFound, http.StatusText(http.StatusNotFound))
+3 -3
View File
@@ -16,7 +16,7 @@ import (
func TestGetDNSDiscoveryHandler(t *testing.T) {
store := cache.New(cache.NoExpiration, cache.NoExpiration)
handler := GetDNSDiscoveryHandler(store, domain, "")
handler := GetDNSDiscoveryHandler(store, rt.geo, domain, "")
t.Run("calls next if host does not have domain suffix", func(t *testing.T) {
req, _ := http.NewRequest("GET", "/", nil)
@@ -106,7 +106,7 @@ func TestHandleDNS(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = req
handleDNS(c, store)
handleDNS(c, store, rt.geo)
assert.Equal(t, http.StatusNotFound, w.Code)
})
}
@@ -144,7 +144,7 @@ func TestAcceptDNSRequest(t *testing.T) {
c.Request = req
store.Set(u, testIP.ipv4, cache.DefaultExpiration)
handleDNS(c, store)
handleDNS(c, store, rt.geo)
assert.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, tt.want, w.Body.String())
+25 -26
View File
@@ -6,7 +6,6 @@ import (
"path/filepath"
"github.com/dcarrillo/whatismyip/internal/httputils"
"github.com/dcarrillo/whatismyip/internal/setting"
"github.com/gin-gonic/gin"
)
@@ -31,31 +30,31 @@ type JSONResponse struct {
GeoResponse
}
func getRoot(ctx *gin.Context) {
func (rt *Router) getRoot(ctx *gin.Context) {
switch ctx.NegotiateFormat(gin.MIMEPlain, gin.MIMEHTML, gin.MIMEJSON) {
case gin.MIMEHTML:
name := "home"
if setting.App.TemplatePath != "" {
name = filepath.Base(setting.App.TemplatePath)
if rt.templatePath != "" {
name = filepath.Base(rt.templatePath)
}
ctx.HTML(http.StatusOK, name, jsonOutput(ctx))
ctx.HTML(http.StatusOK, name, rt.jsonOutput(ctx))
case gin.MIMEJSON:
getJSON(ctx)
rt.getJSON(ctx)
default:
ctx.String(http.StatusOK, ctx.ClientIP()+"\n")
}
}
func getClientPort(ctx *gin.Context) string {
func (rt *Router) getClientPort(ctx *gin.Context) string {
var port string
if setting.App.TrustedPortHeader == "" {
if setting.App.TrustedHeader != "" {
if rt.trustedPortHeader == "" {
if rt.trustedHeader != "" {
port = "unknown"
} else {
_, port, _ = net.SplitHostPort(ctx.Request.RemoteAddr)
}
} else {
port = ctx.GetHeader(setting.App.TrustedPortHeader)
port = ctx.GetHeader(rt.trustedPortHeader)
if port == "" {
port = "unknown"
}
@@ -64,37 +63,37 @@ func getClientPort(ctx *gin.Context) string {
return port
}
func getClientPortAsString(ctx *gin.Context) {
ctx.String(http.StatusOK, getClientPort(ctx)+"\n")
func (rt *Router) getClientPortAsString(ctx *gin.Context) {
ctx.String(http.StatusOK, rt.getClientPort(ctx)+"\n")
}
func getAllAsString(ctx *gin.Context) {
func (rt *Router) getAllAsString(ctx *gin.Context) {
ip := net.ParseIP(ctx.ClientIP())
output := "IP: " + ip.String() + "\n"
output += "Client Port: " + getClientPort(ctx) + "\n"
output += "Client Port: " + rt.getClientPort(ctx) + "\n"
if geoSvc != nil {
if cityRecord := geoSvc.LookUpCity(ip); cityRecord != nil {
if rt.geo != nil {
if cityRecord := rt.geo.LookUpCity(ip); cityRecord != nil {
output += geoCityRecordToString(cityRecord) + "\n"
}
if asnRecord := geoSvc.LookUpASN(ip); asnRecord != nil {
if asnRecord := rt.geo.LookUpASN(ip); asnRecord != nil {
output += geoASNRecordToString(asnRecord) + "\n"
}
}
h := httputils.GetHeadersWithoutTrustedHeaders(ctx)
h := httputils.GetHeadersWithoutTrustedHeaders(ctx, rt.trustedHeader, rt.trustedPortHeader)
h.Set("Host", ctx.Request.Host)
output += httputils.HeadersToSortedString(h)
ctx.String(http.StatusOK, output)
}
func getJSON(ctx *gin.Context) {
ctx.JSON(http.StatusOK, jsonOutput(ctx))
func (rt *Router) getJSON(ctx *gin.Context) {
ctx.JSON(http.StatusOK, rt.jsonOutput(ctx))
}
func jsonOutput(ctx *gin.Context) JSONResponse {
func (rt *Router) jsonOutput(ctx *gin.Context) JSONResponse {
ip := net.ParseIP(ctx.ClientIP())
var version byte = 4
@@ -103,8 +102,8 @@ func jsonOutput(ctx *gin.Context) JSONResponse {
}
geoResp := GeoResponse{}
if geoSvc != nil {
if cityRecord := geoSvc.LookUpCity(ip); cityRecord != nil {
if rt.geo != nil {
if cityRecord := rt.geo.LookUpCity(ip); cityRecord != nil {
geoResp.Country = cityRecord.Country.Names["en"]
geoResp.CountryCode = cityRecord.Country.ISOCode
geoResp.City = cityRecord.City.Names["en"]
@@ -113,7 +112,7 @@ func jsonOutput(ctx *gin.Context) JSONResponse {
geoResp.PostalCode = cityRecord.Postal.Code
geoResp.TimeZone = cityRecord.Location.TimeZone
}
if asnRecord := geoSvc.LookUpASN(ip); asnRecord != nil {
if asnRecord := rt.geo.LookUpASN(ip); asnRecord != nil {
geoResp.ASN = asnRecord.AutonomousSystemNumber
geoResp.ASNOrganization = asnRecord.AutonomousSystemOrganization
}
@@ -122,9 +121,9 @@ func jsonOutput(ctx *gin.Context) JSONResponse {
return JSONResponse{
IP: ip.String(),
IPVersion: version,
ClientPort: getClientPort(ctx),
ClientPort: rt.getClientPort(ctx),
Host: ctx.Request.Host,
Headers: httputils.GetHeadersWithoutTrustedHeaders(ctx),
Headers: httputils.GetHeadersWithoutTrustedHeaders(ctx, rt.trustedHeader, rt.trustedPortHeader),
GeoResponse: geoResp,
}
}
+29 -41
View File
@@ -1,12 +1,14 @@
package router
import (
"context"
"net"
"net/http"
"net/http/httptest"
"testing"
"github.com/dcarrillo/whatismyip/internal/setting"
"github.com/dcarrillo/whatismyip/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
)
@@ -99,8 +101,9 @@ func TestHost(t *testing.T) {
func TestClientPort(t *testing.T) {
type args struct {
params []string
headers map[string][]string
trustedHeader string
trustedPortHeader string
headers map[string][]string
}
tests := []struct {
name string
@@ -114,35 +117,23 @@ func TestClientPort(t *testing.T) {
{
name: "Trusted header only set",
args: args{
params: []string{
"-geoip2-city", "city",
"-geoip2-asn", "asn",
"-trusted-header", trustedHeader,
},
trustedHeader: trustedHeader,
},
expected: "unknown\n",
},
{
name: "Trusted and port header set but not included in headers",
args: args{
params: []string{
"-geoip2-city", "city",
"-geoip2-asn", "asn",
"-trusted-header", trustedHeader,
"-trusted-port-header", trustedPortHeader,
},
trustedHeader: trustedHeader,
trustedPortHeader: trustedPortHeader,
},
expected: "unknown\n",
},
{
name: "Trusted and port header set and included in headers",
args: args{
params: []string{
"-geoip2-city", "city",
"-geoip2-asn", "asn",
"-trusted-header", trustedHeader,
"-trusted-port-header", trustedPortHeader,
},
trustedHeader: trustedHeader,
trustedPortHeader: trustedPortHeader,
headers: map[string][]string{
trustedHeader: {testIP.ipv4},
trustedPortHeader: {"1001"},
@@ -153,19 +144,22 @@ func TestClientPort(t *testing.T) {
}
for _, tt := range tests {
_, _ = setting.Setup(tt.args.params)
t.Run(tt.name, func(t *testing.T) {
engine := gin.Default()
engine.TrustedPlatform = tt.args.trustedHeader
r := NewRouter(nil, tt.args.trustedHeader, tt.args.trustedPortHeader, "", false)
Setup(engine, r)
req, _ := http.NewRequest("GET", "/client-port", nil)
req.RemoteAddr = net.JoinHostPort(testIP.ipv4, "1000")
req.Header = tt.args.headers
w := httptest.NewRecorder()
app.ServeHTTP(w, req)
engine.ServeHTTP(w, req)
assert.Equal(t, 200, w.Code)
assert.Equal(t, contentType.text, w.Header().Get("Content-Type"))
assert.Equal(t, tt.expected, w.Body.String())
t.Log(w.Header())
})
}
}
@@ -181,14 +175,11 @@ func TestNotFound(t *testing.T) {
}
func TestJSON(t *testing.T) {
_, _ = setting.Setup(
[]string{
"-geoip2-city", "city",
"-geoip2-asn", "asn",
"-trusted-header", trustedHeader,
"-trusted-port-header", trustedPortHeader,
},
)
svc, _ := service.NewGeo(context.Background(), "../test/GeoIP2-City-Test.mmdb", "../test/GeoLite2-ASN-Test.mmdb")
engine := gin.Default()
engine.TrustedPlatform = trustedHeader
r := NewRouter(svc, trustedHeader, trustedPortHeader, "", false)
Setup(engine, r)
type args struct {
ip string
@@ -222,7 +213,7 @@ func TestJSON(t *testing.T) {
req.Header.Set(trustedPortHeader, "1001")
w := httptest.NewRecorder()
app.ServeHTTP(w, req)
engine.ServeHTTP(w, req)
assert.Equal(t, 200, w.Code)
assert.Equal(t, contentType.json, w.Header().Get("Content-Type"))
@@ -248,14 +239,11 @@ ASN Organization:
Header1: one
Host: test
`
_, _ = setting.Setup(
[]string{
"-geoip2-city", "city",
"-geoip2-asn", "asn",
"-trusted-header", trustedHeader,
"-trusted-port-header", trustedPortHeader,
},
)
svc, _ := service.NewGeo(context.Background(), "../test/GeoIP2-City-Test.mmdb", "../test/GeoLite2-ASN-Test.mmdb")
engine := gin.Default()
engine.TrustedPlatform = trustedHeader
r := NewRouter(svc, trustedHeader, trustedPortHeader, "", false)
Setup(engine, r)
req, _ := http.NewRequest("GET", "/all", nil)
req.RemoteAddr = net.JoinHostPort(testIP.ipv4, "1000")
@@ -265,7 +253,7 @@ Host: test
req.Header.Set("Header1", "one")
w := httptest.NewRecorder()
app.ServeHTTP(w, req)
engine.ServeHTTP(w, req)
assert.Equal(t, 200, w.Code)
assert.Equal(t, contentType.text, w.Header().Get("Content-Type"))
+6 -6
View File
@@ -81,13 +81,13 @@ var asnOutput = map[string]asnDataFormatter{
},
}
func getGeoAsString(ctx *gin.Context) {
if geoSvc == nil {
func (rt *Router) getGeoAsString(ctx *gin.Context) {
if rt.geo == nil {
ctx.String(http.StatusNotFound, http.StatusText(http.StatusNotFound))
return
}
record := geoSvc.LookUpCity(net.ParseIP(ctx.ClientIP()))
record := rt.geo.LookUpCity(net.ParseIP(ctx.ClientIP()))
if record == nil {
ctx.String(http.StatusNotFound, http.StatusText(http.StatusNotFound))
return
@@ -107,13 +107,13 @@ func getGeoAsString(ctx *gin.Context) {
ctx.String(http.StatusOK, g.format(record))
}
func getASNAsString(ctx *gin.Context) {
if geoSvc == nil {
func (rt *Router) getASNAsString(ctx *gin.Context) {
if rt.geo == nil {
ctx.String(http.StatusNotFound, http.StatusText(http.StatusNotFound))
return
}
record := geoSvc.LookUpASN(net.ParseIP(ctx.ClientIP()))
record := rt.geo.LookUpASN(net.ParseIP(ctx.ClientIP()))
if record == nil {
ctx.String(http.StatusNotFound, http.StatusText(http.StatusNotFound))
return
+4 -5
View File
@@ -9,15 +9,14 @@ import (
"github.com/gin-gonic/gin"
)
func getHeadersAsSortedString(ctx *gin.Context) {
h := httputils.GetHeadersWithoutTrustedHeaders(ctx)
func (rt *Router) getHeadersAsSortedString(ctx *gin.Context) {
h := httputils.GetHeadersWithoutTrustedHeaders(ctx, rt.trustedHeader, rt.trustedPortHeader)
h.Set("Host", ctx.Request.Host)
ctx.String(http.StatusOK, httputils.HeadersToSortedString(h))
}
func getHeaderAsString(ctx *gin.Context) {
headers := httputils.GetHeadersWithoutTrustedHeaders(ctx)
func (rt *Router) getHeaderAsString(ctx *gin.Context) {
headers := httputils.GetHeadersWithoutTrustedHeaders(ctx, rt.trustedHeader, rt.trustedPortHeader)
h := ctx.Params.ByName("header")
if v := headers.Get(ctx.Params.ByName("header")); v != "" {
+9 -8
View File
@@ -1,11 +1,13 @@
package router
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/dcarrillo/whatismyip/internal/setting"
"github.com/dcarrillo/whatismyip/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
)
@@ -27,12 +29,11 @@ Header2: value22
Header3: value3
Host:
`
_, _ = setting.Setup([]string{
"-geoip2-city", "city",
"-geoip2-asn", "asn",
"-trusted-header", trustedHeader,
"-trusted-port-header", trustedPortHeader,
})
svc, _ := service.NewGeo(context.Background(), "../test/GeoIP2-City-Test.mmdb", "../test/GeoLite2-ASN-Test.mmdb")
engine := gin.Default()
engine.TrustedPlatform = trustedHeader
r := NewRouter(svc, trustedHeader, trustedPortHeader, "", false)
Setup(engine, r)
req, _ := http.NewRequest("GET", "/headers", nil)
req.Header = map[string][]string{
"Header1": {"value1"},
@@ -43,7 +44,7 @@ Host:
req.Header.Set(trustedPortHeader, "1025")
w := httptest.NewRecorder()
app.ServeHTTP(w, req)
engine.ServeHTTP(w, req)
assert.Equal(t, 200, w.Code)
assert.Equal(t, contentType.text, w.Header().Get("Content-Type"))
+1 -1
View File
@@ -17,7 +17,7 @@ type JSONScanResponse struct {
Reason string `json:"reason"`
}
func scanTCPPort(ctx *gin.Context) {
func (rt *Router) scanTCPPort(ctx *gin.Context) {
port, err := strconv.Atoi(ctx.Params.ByName("port"))
if err == nil && (port < 1 || port > 65535) {
err = fmt.Errorf("%d is not a valid port number", port)
+34 -20
View File
@@ -4,35 +4,49 @@ import (
"html/template"
"log"
"github.com/dcarrillo/whatismyip/internal/setting"
"github.com/dcarrillo/whatismyip/service"
"github.com/gin-gonic/gin"
)
var geoSvc *service.Geo
type Router struct {
geo *service.Geo
trustedHeader string
trustedPortHeader string
templatePath string
disableScan bool
}
func SetupTemplate(r *gin.Engine) {
if setting.App.TemplatePath == "" {
func NewRouter(geo *service.Geo, trustedHeader, trustedPortHeader, templatePath string, disableScan bool) *Router {
return &Router{
geo: geo,
trustedHeader: trustedHeader,
trustedPortHeader: trustedPortHeader,
templatePath: templatePath,
disableScan: disableScan,
}
}
func SetupTemplate(r *gin.Engine, templatePath string) {
if templatePath == "" {
r.SetHTMLTemplate(template.Must(template.New("home").Parse(home)))
} else {
log.Printf("Template %s has been loaded", setting.App.TemplatePath)
r.LoadHTMLFiles(setting.App.TemplatePath)
log.Printf("Template %s has been loaded", templatePath)
r.LoadHTMLFiles(templatePath)
}
}
func Setup(r *gin.Engine, geo *service.Geo) {
geoSvc = geo
r.GET("/", getRoot)
if !setting.App.DisableTCPScan {
r.GET("/scan/tcp/:port", scanTCPPort)
func Setup(r *gin.Engine, rt *Router) {
r.GET("/", rt.getRoot)
if !rt.disableScan {
r.GET("/scan/tcp/:port", rt.scanTCPPort)
}
r.GET("/client-port", getClientPortAsString)
r.GET("/geo", getGeoAsString)
r.GET("/geo/:field", getGeoAsString)
r.GET("/asn", getASNAsString)
r.GET("/asn/:field", getASNAsString)
r.GET("/headers", getHeadersAsSortedString)
r.GET("/all", getAllAsString)
r.GET("/json", getJSON)
r.GET("/:header", getHeaderAsString)
r.GET("/client-port", rt.getClientPortAsString)
r.GET("/geo", rt.getGeoAsString)
r.GET("/geo/:field", rt.getGeoAsString)
r.GET("/asn", rt.getASNAsString)
r.GET("/asn/:field", rt.getASNAsString)
r.GET("/headers", rt.getHeadersAsSortedString)
r.GET("/all", rt.getAllAsString)
r.GET("/json", rt.getJSON)
r.GET("/:header", rt.getHeaderAsString)
}
+4 -1
View File
@@ -47,11 +47,14 @@ const (
domain = "dns.example.com"
)
var rt *Router
func TestMain(m *testing.M) {
app = gin.Default()
app.TrustedPlatform = trustedHeader
svc, _ := service.NewGeo(context.Background(), "../test/GeoIP2-City-Test.mmdb", "../test/GeoLite2-ASN-Test.mmdb")
Setup(app, svc)
rt = NewRouter(svc, trustedHeader, "", "", false)
Setup(app, rt)
os.Exit(m.Run())
}