refactor: move access log port to boundaries/out, fix hex arch violations
- Move AccessLogEntry and AccessLogWriter interface to internal/boundaries/out/ (driven-port interfaces belong in the ports layer, not in adapters) - Move HealthCheckUserAgentPrefix constant to boundaries/out/accesslog.go (was in domain/ which is wrong; it's an HTTP infrastructure detail) - Update adapters/out/accesslog to implement the boundary interface - Update middleware/accesslog.go to depend on the port, not the output adapter - Update app/run.go to use out.AccessLogWriter instead of middleware.AccessLogWriter - Fixes adapter-to-adapter coupling (input adapter importing output adapter)
- commit
- 6b418bd1c15c599cf3cf86dbc806094d049bd0db
- author
- brice · · 2026-04-03 17:44 UTC
- parent
- 7121811
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
11 files · +69 -56
M internal/adapters/in/http/middleware/accesslog.go +4 -11
| @@ -8,16 +8,9 @@ import ( | |||
| 8 | 8 | ||
| 9 | 9 | "github.com/bnema/zerowrap" | |
| 10 | 10 | ||
| 11 | - | "github.com/bnema/gordon/internal/adapters/out/accesslog" | |
| 12 | - | "github.com/bnema/gordon/internal/domain" | |
| 11 | + | out "github.com/bnema/gordon/internal/boundaries/out" | |
| 13 | 12 | ) | |
| 14 | 13 | ||
| 15 | - | // AccessLogWriter is the interface the middleware needs from the output adapter. | |
| 16 | - | // *accesslog.Writer satisfies this interface. | |
| 17 | - | type AccessLogWriter interface { | |
| 18 | - | Write(entry accesslog.Entry) error | |
| 19 | - | } | |
| 20 | - | ||
| 21 | 14 | // AccessLogger is a middleware that writes one access-log entry per HTTP request | |
| 22 | 15 | // to the provided writer. It runs alongside (not instead of) RequestLogger. | |
| 23 | 16 | // | |
| @@ -26,7 +19,7 @@ type AccessLogWriter interface { | |||
| 26 | 19 | // | |
| 27 | 20 | // Write failures are reported as warnings through the application logger and | |
| 28 | 21 | // never fail the HTTP response. | |
| 29 | - | func AccessLogger(writer AccessLogWriter, excludeHealthChecks bool, log zerowrap.Logger, trustedNets []*net.IPNet) func(http.Handler) http.Handler { | |
| 22 | + | func AccessLogger(writer out.AccessLogWriter, excludeHealthChecks bool, log zerowrap.Logger, trustedNets []*net.IPNet) func(http.Handler) http.Handler { | |
| 30 | 23 | return func(next http.Handler) http.Handler { | |
| 31 | 24 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 32 | 25 | start := time.Now() | |
| @@ -50,7 +43,7 @@ func AccessLogger(writer AccessLogWriter, excludeHealthChecks bool, log zerowrap | |||
| 50 | 43 | // filter can never drift. Loopback check reuses localhostNets from | |
| 51 | 44 | // cidr.go (same package) — no separate loopback definition needed. | |
| 52 | 45 | if excludeHealthChecks { | |
| 53 | - | if strings.HasPrefix(r.UserAgent(), domain.HealthCheckUserAgentPrefix) || | |
| 46 | + | if strings.HasPrefix(r.UserAgent(), out.HealthCheckUserAgentPrefix) || | |
| 54 | 47 | IsTrustedProxy(clientIP, localhostNets) { | |
| 55 | 48 | return | |
| 56 | 49 | } | |
| @@ -61,7 +54,7 @@ func AccessLogger(writer AccessLogWriter, excludeHealthChecks bool, log zerowrap | |||
| 61 | 54 | end := time.Now() | |
| 62 | 55 | durationMS := float64(end.Sub(start).Microseconds()) / 1000.0 | |
| 63 | 56 | ||
| 64 | - | entry := accesslog.Entry{ | |
| 57 | + | entry := out.AccessLogEntry{ | |
| 65 | 58 | Time: end.UTC(), | |
| 66 | 59 | ClientIP: clientIP, | |
| 67 | 60 | Method: r.Method, | |
| @@ -8,16 +8,9 @@ import ( | |||||
| 8 | 8 | ||||
| 9 | "github.com/bnema/zerowrap" | 9 | "github.com/bnema/zerowrap" | ||
| 10 | 10 | ||||
| 11 | - | "github.com/bnema/gordon/internal/adapters/out/accesslog" | 11 | + | out "github.com/bnema/gordon/internal/boundaries/out" |
| 12 | - | "github.com/bnema/gordon/internal/domain" | |||
| 13 | ) | 12 | ) | ||
| 14 | 13 | ||||
| 15 | - | // AccessLogWriter is the interface the middleware needs from the output adapter. | |||
| 16 | - | // *accesslog.Writer satisfies this interface. | |||
| 17 | - | type AccessLogWriter interface { | |||
| 18 | - | Write(entry accesslog.Entry) error | |||
| 19 | - | } | |||
| 20 | - | ||||
| 21 | // AccessLogger is a middleware that writes one access-log entry per HTTP request | 14 | // AccessLogger is a middleware that writes one access-log entry per HTTP request | ||
| 22 | // to the provided writer. It runs alongside (not instead of) RequestLogger. | 15 | // to the provided writer. It runs alongside (not instead of) RequestLogger. | ||
| 23 | // | 16 | // | ||
| @@ -26,7 +19,7 @@ type AccessLogWriter interface { | |||||
| 26 | // | 19 | // | ||
| 27 | // Write failures are reported as warnings through the application logger and | 20 | // Write failures are reported as warnings through the application logger and | ||
| 28 | // never fail the HTTP response. | 21 | // never fail the HTTP response. | ||
| 29 | - | func AccessLogger(writer AccessLogWriter, excludeHealthChecks bool, log zerowrap.Logger, trustedNets []*net.IPNet) func(http.Handler) http.Handler { | 22 | + | func AccessLogger(writer out.AccessLogWriter, excludeHealthChecks bool, log zerowrap.Logger, trustedNets []*net.IPNet) func(http.Handler) http.Handler { |
| 30 | return func(next http.Handler) http.Handler { | 23 | return func(next http.Handler) http.Handler { | ||
| 31 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | 24 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| 32 | start := time.Now() | 25 | start := time.Now() | ||
| @@ -50,7 +43,7 @@ func AccessLogger(writer AccessLogWriter, excludeHealthChecks bool, log zerowrap | |||||
| 50 | // filter can never drift. Loopback check reuses localhostNets from | 43 | // filter can never drift. Loopback check reuses localhostNets from | ||
| 51 | // cidr.go (same package) — no separate loopback definition needed. | 44 | // cidr.go (same package) — no separate loopback definition needed. | ||
| 52 | if excludeHealthChecks { | 45 | if excludeHealthChecks { | ||
| 53 | - | if strings.HasPrefix(r.UserAgent(), domain.HealthCheckUserAgentPrefix) || | 46 | + | if strings.HasPrefix(r.UserAgent(), out.HealthCheckUserAgentPrefix) || |
| 54 | IsTrustedProxy(clientIP, localhostNets) { | 47 | IsTrustedProxy(clientIP, localhostNets) { | ||
| 55 | return | 48 | return | ||
| 56 | } | 49 | } | ||
| @@ -61,7 +54,7 @@ func AccessLogger(writer AccessLogWriter, excludeHealthChecks bool, log zerowrap | |||||
| 61 | end := time.Now() | 54 | end := time.Now() | ||
| 62 | durationMS := float64(end.Sub(start).Microseconds()) / 1000.0 | 55 | durationMS := float64(end.Sub(start).Microseconds()) / 1000.0 | ||
| 63 | 56 | ||||
| 64 | - | entry := accesslog.Entry{ | 57 | + | entry := out.AccessLogEntry{ |
| 65 | Time: end.UTC(), | 58 | Time: end.UTC(), | ||
| 66 | ClientIP: clientIP, | 59 | ClientIP: clientIP, | ||
| 67 | Method: r.Method, | 60 | Method: r.Method, | ||
M internal/adapters/in/http/middleware/accesslog_test.go +3 -3
| @@ -9,16 +9,16 @@ import ( | |||
| 9 | 9 | "github.com/stretchr/testify/assert" | |
| 10 | 10 | "github.com/stretchr/testify/require" | |
| 11 | 11 | ||
| 12 | - | "github.com/bnema/gordon/internal/adapters/out/accesslog" | |
| 12 | + | out "github.com/bnema/gordon/internal/boundaries/out" | |
| 13 | 13 | ) | |
| 14 | 14 | ||
| 15 | 15 | // mockAccessLogWriter records written entries and optionally returns an error. | |
| 16 | 16 | type mockAccessLogWriter struct { | |
| 17 | - | entries []accesslog.Entry | |
| 17 | + | entries []out.AccessLogEntry | |
| 18 | 18 | err error | |
| 19 | 19 | } | |
| 20 | 20 | ||
| 21 | - | func (m *mockAccessLogWriter) Write(entry accesslog.Entry) error { | |
| 21 | + | func (m *mockAccessLogWriter) Write(entry out.AccessLogEntry) error { | |
| 22 | 22 | m.entries = append(m.entries, entry) | |
| 23 | 23 | return m.err | |
| 24 | 24 | } | |
| @@ -9,16 +9,16 @@ import ( | |||||
| 9 | "github.com/stretchr/testify/assert" | 9 | "github.com/stretchr/testify/assert" | ||
| 10 | "github.com/stretchr/testify/require" | 10 | "github.com/stretchr/testify/require" | ||
| 11 | 11 | ||||
| 12 | - | "github.com/bnema/gordon/internal/adapters/out/accesslog" | 12 | + | out "github.com/bnema/gordon/internal/boundaries/out" |
| 13 | ) | 13 | ) | ||
| 14 | 14 | ||||
| 15 | // mockAccessLogWriter records written entries and optionally returns an error. | 15 | // mockAccessLogWriter records written entries and optionally returns an error. | ||
| 16 | type mockAccessLogWriter struct { | 16 | type mockAccessLogWriter struct { | ||
| 17 | - | entries []accesslog.Entry | 17 | + | entries []out.AccessLogEntry |
| 18 | err error | 18 | err error | ||
| 19 | } | 19 | } | ||
| 20 | 20 | ||||
| 21 | - | func (m *mockAccessLogWriter) Write(entry accesslog.Entry) error { | 21 | + | func (m *mockAccessLogWriter) Write(entry out.AccessLogEntry) error { |
| 22 | m.entries = append(m.entries, entry) | 22 | m.entries = append(m.entries, entry) | ||
| 23 | return m.err | 23 | return m.err | ||
| 24 | } | 24 | } | ||
M internal/adapters/out/accesslog/formatter.go +5 -3
| @@ -4,6 +4,8 @@ import ( | |||
| 4 | 4 | "encoding/json" | |
| 5 | 5 | "fmt" | |
| 6 | 6 | "strings" | |
| 7 | + | ||
| 8 | + | out "github.com/bnema/gordon/internal/boundaries/out" | |
| 7 | 9 | ) | |
| 8 | 10 | ||
| 9 | 11 | // jsonEntry is the stable JSON schema for access log entries. | |
| @@ -34,7 +36,7 @@ const jsonTimestamp = "2006-01-02T15:04:05.000Z07:00" | |||
| 34 | 36 | // formatJSON serializes entry as a single-line JSON object. | |
| 35 | 37 | // Timestamps are RFC3339 with millisecond precision in UTC. | |
| 36 | 38 | // time.Time's default JSON marshaling is NOT used to ensure stable precision. | |
| 37 | - | func formatJSON(e Entry) (string, error) { | |
| 39 | + | func formatJSON(e out.AccessLogEntry) (string, error) { | |
| 38 | 40 | je := jsonEntry{ | |
| 39 | 41 | Time: e.Time.UTC().Format(jsonTimestamp), | |
| 40 | 42 | ClientIP: e.ClientIP, | |
| @@ -60,7 +62,7 @@ func formatJSON(e Entry) (string, error) { | |||
| 60 | 62 | // formatCLF serializes entry in Common Log Format (NCSA). | |
| 61 | 63 | // | |
| 62 | 64 | // <ip> - - [timestamp] "METHOD target PROTO" status bytes | |
| 63 | - | func formatCLF(e Entry) (string, error) { | |
| 65 | + | func formatCLF(e out.AccessLogEntry) (string, error) { | |
| 64 | 66 | ts := e.Time.UTC().Format(clfTimestamp) | |
| 65 | 67 | target := buildRequestTarget(e.Path, e.Query) | |
| 66 | 68 | return fmt.Sprintf(`%s - - [%s] "%s %s %s" %d %d`, | |
| @@ -77,7 +79,7 @@ func formatCLF(e Entry) (string, error) { | |||
| 77 | 79 | // formatCombined serializes entry in Combined Log Format (CLF + referer + UA). | |
| 78 | 80 | // | |
| 79 | 81 | // <ip> - - [timestamp] "METHOD target PROTO" status bytes "referer" "ua" | |
| 80 | - | func formatCombined(e Entry) (string, error) { | |
| 82 | + | func formatCombined(e out.AccessLogEntry) (string, error) { | |
| 81 | 83 | ts := e.Time.UTC().Format(clfTimestamp) | |
| 82 | 84 | target := buildRequestTarget(e.Path, e.Query) | |
| 83 | 85 | referer := e.Referer | |
| @@ -4,6 +4,8 @@ import ( | |||||
| 4 | "encoding/json" | 4 | "encoding/json" | ||
| 5 | "fmt" | 5 | "fmt" | ||
| 6 | "strings" | 6 | "strings" | ||
| 7 | + | ||||
| 8 | + | out "github.com/bnema/gordon/internal/boundaries/out" | |||
| 7 | ) | 9 | ) | ||
| 8 | 10 | ||||
| 9 | // jsonEntry is the stable JSON schema for access log entries. | 11 | // jsonEntry is the stable JSON schema for access log entries. | ||
| @@ -34,7 +36,7 @@ const jsonTimestamp = "2006-01-02T15:04:05.000Z07:00" | |||||
| 34 | // formatJSON serializes entry as a single-line JSON object. | 36 | // formatJSON serializes entry as a single-line JSON object. | ||
| 35 | // Timestamps are RFC3339 with millisecond precision in UTC. | 37 | // Timestamps are RFC3339 with millisecond precision in UTC. | ||
| 36 | // time.Time's default JSON marshaling is NOT used to ensure stable precision. | 38 | // time.Time's default JSON marshaling is NOT used to ensure stable precision. | ||
| 37 | - | func formatJSON(e Entry) (string, error) { | 39 | + | func formatJSON(e out.AccessLogEntry) (string, error) { |
| 38 | je := jsonEntry{ | 40 | je := jsonEntry{ | ||
| 39 | Time: e.Time.UTC().Format(jsonTimestamp), | 41 | Time: e.Time.UTC().Format(jsonTimestamp), | ||
| 40 | ClientIP: e.ClientIP, | 42 | ClientIP: e.ClientIP, | ||
| @@ -60,7 +62,7 @@ func formatJSON(e Entry) (string, error) { | |||||
| 60 | // formatCLF serializes entry in Common Log Format (NCSA). | 62 | // formatCLF serializes entry in Common Log Format (NCSA). | ||
| 61 | // | 63 | // | ||
| 62 | // <ip> - - [timestamp] "METHOD target PROTO" status bytes | 64 | // <ip> - - [timestamp] "METHOD target PROTO" status bytes | ||
| 63 | - | func formatCLF(e Entry) (string, error) { | 65 | + | func formatCLF(e out.AccessLogEntry) (string, error) { |
| 64 | ts := e.Time.UTC().Format(clfTimestamp) | 66 | ts := e.Time.UTC().Format(clfTimestamp) | ||
| 65 | target := buildRequestTarget(e.Path, e.Query) | 67 | target := buildRequestTarget(e.Path, e.Query) | ||
| 66 | return fmt.Sprintf(`%s - - [%s] "%s %s %s" %d %d`, | 68 | return fmt.Sprintf(`%s - - [%s] "%s %s %s" %d %d`, | ||
| @@ -77,7 +79,7 @@ func formatCLF(e Entry) (string, error) { | |||||
| 77 | // formatCombined serializes entry in Combined Log Format (CLF + referer + UA). | 79 | // formatCombined serializes entry in Combined Log Format (CLF + referer + UA). | ||
| 78 | // | 80 | // | ||
| 79 | // <ip> - - [timestamp] "METHOD target PROTO" status bytes "referer" "ua" | 81 | // <ip> - - [timestamp] "METHOD target PROTO" status bytes "referer" "ua" | ||
| 80 | - | func formatCombined(e Entry) (string, error) { | 82 | + | func formatCombined(e out.AccessLogEntry) (string, error) { |
| 81 | ts := e.Time.UTC().Format(clfTimestamp) | 83 | ts := e.Time.UTC().Format(clfTimestamp) | ||
| 82 | target := buildRequestTarget(e.Path, e.Query) | 84 | target := buildRequestTarget(e.Path, e.Query) | ||
| 83 | referer := e.Referer | 85 | referer := e.Referer | ||
M internal/adapters/out/accesslog/formatter_test.go +3 -1
| @@ -8,11 +8,13 @@ import ( | |||
| 8 | 8 | ||
| 9 | 9 | "github.com/stretchr/testify/assert" | |
| 10 | 10 | "github.com/stretchr/testify/require" | |
| 11 | + | ||
| 12 | + | out "github.com/bnema/gordon/internal/boundaries/out" | |
| 11 | 13 | ) | |
| 12 | 14 | ||
| 13 | 15 | var testTime = time.Date(2026, 4, 3, 14, 20, 54, 167_000_000, time.UTC) | |
| 14 | 16 | ||
| 15 | - | var baseEntry = Entry{ | |
| 17 | + | var baseEntry = out.AccessLogEntry{ | |
| 16 | 18 | Time: testTime, | |
| 17 | 19 | ClientIP: "135.181.213.219", | |
| 18 | 20 | Method: "GET", | |
| @@ -8,11 +8,13 @@ import ( | |||||
| 8 | 8 | ||||
| 9 | "github.com/stretchr/testify/assert" | 9 | "github.com/stretchr/testify/assert" | ||
| 10 | "github.com/stretchr/testify/require" | 10 | "github.com/stretchr/testify/require" | ||
| 11 | + | ||||
| 12 | + | out "github.com/bnema/gordon/internal/boundaries/out" | |||
| 11 | ) | 13 | ) | ||
| 12 | 14 | ||||
| 13 | var testTime = time.Date(2026, 4, 3, 14, 20, 54, 167_000_000, time.UTC) | 15 | var testTime = time.Date(2026, 4, 3, 14, 20, 54, 167_000_000, time.UTC) | ||
| 14 | 16 | ||||
| 15 | - | var baseEntry = Entry{ | 17 | + | var baseEntry = out.AccessLogEntry{ |
| 16 | Time: testTime, | 18 | Time: testTime, | ||
| 17 | ClientIP: "135.181.213.219", | 19 | ClientIP: "135.181.213.219", | ||
| 18 | Method: "GET", | 20 | Method: "GET", | ||
M internal/adapters/out/accesslog/writer.go +6 -22
| @@ -8,9 +8,10 @@ import ( | |||
| 8 | 8 | "os" | |
| 9 | 9 | "path/filepath" | |
| 10 | 10 | "sync" | |
| 11 | - | "time" | |
| 12 | 11 | ||
| 13 | 12 | "gopkg.in/natefinch/lumberjack.v2" | |
| 13 | + | ||
| 14 | + | out "github.com/bnema/gordon/internal/boundaries/out" | |
| 14 | 15 | ) | |
| 15 | 16 | ||
| 16 | 17 | // Config holds configuration for the access log writer. | |
| @@ -32,23 +33,6 @@ type Config struct { | |||
| 32 | 33 | SyslogIdentifier string | |
| 33 | 34 | } | |
| 34 | 35 | ||
| 35 | - | // Entry holds the data for a single HTTP access log record. | |
| 36 | - | type Entry struct { | |
| 37 | - | Time time.Time | |
| 38 | - | ClientIP string | |
| 39 | - | Method string | |
| 40 | - | Host string | |
| 41 | - | Path string | |
| 42 | - | Query string | |
| 43 | - | Status int | |
| 44 | - | BytesSent int | |
| 45 | - | DurationMS float64 | |
| 46 | - | UserAgent string | |
| 47 | - | Referer string | |
| 48 | - | RequestID string | |
| 49 | - | Proto string | |
| 50 | - | } | |
| 51 | - | ||
| 52 | 36 | // sink is the internal interface for output backends. | |
| 53 | 37 | type sink interface { | |
| 54 | 38 | WriteLine(line string) error | |
| @@ -56,9 +40,9 @@ type sink interface { | |||
| 56 | 40 | } | |
| 57 | 41 | ||
| 58 | 42 | // Writer serializes access log entries and writes them to a configured sink. | |
| 59 | - | // It is safe for concurrent use. | |
| 43 | + | // It is safe for concurrent use. It implements out.AccessLogWriter. | |
| 60 | 44 | type Writer struct { | |
| 61 | - | formatter func(Entry) (string, error) | |
| 45 | + | formatter func(out.AccessLogEntry) (string, error) | |
| 62 | 46 | sink sink | |
| 63 | 47 | mu sync.Mutex | |
| 64 | 48 | } | |
| @@ -85,7 +69,7 @@ func New(cfg Config) (*Writer, error) { | |||
| 85 | 69 | // Write serializes entry and writes it to the configured output. It is safe | |
| 86 | 70 | // for concurrent use. A write failure is returned as an error; callers should | |
| 87 | 71 | // log the error and continue — Write never panics. | |
| 88 | - | func (w *Writer) Write(entry Entry) error { | |
| 72 | + | func (w *Writer) Write(entry out.AccessLogEntry) error { | |
| 89 | 73 | line, err := w.formatter(entry) | |
| 90 | 74 | if err != nil { | |
| 91 | 75 | return fmt.Errorf("accesslog: format entry: %w", err) | |
| @@ -108,7 +92,7 @@ func (w *Writer) Close() error { | |||
| 108 | 92 | } | |
| 109 | 93 | ||
| 110 | 94 | // newFormatter returns the format function for the given format name. | |
| 111 | - | func newFormatter(format string) (func(Entry) (string, error), error) { | |
| 95 | + | func newFormatter(format string) (func(out.AccessLogEntry) (string, error), error) { | |
| 112 | 96 | switch format { | |
| 113 | 97 | case "json": | |
| 114 | 98 | return formatJSON, nil | |
| @@ -8,9 +8,10 @@ import ( | |||||
| 8 | "os" | 8 | "os" | ||
| 9 | "path/filepath" | 9 | "path/filepath" | ||
| 10 | "sync" | 10 | "sync" | ||
| 11 | - | "time" | |||
| 12 | 11 | ||||
| 13 | "gopkg.in/natefinch/lumberjack.v2" | 12 | "gopkg.in/natefinch/lumberjack.v2" | ||
| 13 | + | ||||
| 14 | + | out "github.com/bnema/gordon/internal/boundaries/out" | |||
| 14 | ) | 15 | ) | ||
| 15 | 16 | ||||
| 16 | // Config holds configuration for the access log writer. | 17 | // Config holds configuration for the access log writer. | ||
| @@ -32,23 +33,6 @@ type Config struct { | |||||
| 32 | SyslogIdentifier string | 33 | SyslogIdentifier string | ||
| 33 | } | 34 | } | ||
| 34 | 35 | ||||
| 35 | - | // Entry holds the data for a single HTTP access log record. | |||
| 36 | - | type Entry struct { | |||
| 37 | - | Time time.Time | |||
| 38 | - | ClientIP string | |||
| 39 | - | Method string | |||
| 40 | - | Host string | |||
| 41 | - | Path string | |||
| 42 | - | Query string | |||
| 43 | - | Status int | |||
| 44 | - | BytesSent int | |||
| 45 | - | DurationMS float64 | |||
| 46 | - | UserAgent string | |||
| 47 | - | Referer string | |||
| 48 | - | RequestID string | |||
| 49 | - | Proto string | |||
| 50 | - | } | |||
| 51 | - | ||||
| 52 | // sink is the internal interface for output backends. | 36 | // sink is the internal interface for output backends. | ||
| 53 | type sink interface { | 37 | type sink interface { | ||
| 54 | WriteLine(line string) error | 38 | WriteLine(line string) error | ||
| @@ -56,9 +40,9 @@ type sink interface { | |||||
| 56 | } | 40 | } | ||
| 57 | 41 | ||||
| 58 | // Writer serializes access log entries and writes them to a configured sink. | 42 | // Writer serializes access log entries and writes them to a configured sink. | ||
| 59 | - | // It is safe for concurrent use. | 43 | + | // It is safe for concurrent use. It implements out.AccessLogWriter. |
| 60 | type Writer struct { | 44 | type Writer struct { | ||
| 61 | - | formatter func(Entry) (string, error) | 45 | + | formatter func(out.AccessLogEntry) (string, error) |
| 62 | sink sink | 46 | sink sink | ||
| 63 | mu sync.Mutex | 47 | mu sync.Mutex | ||
| 64 | } | 48 | } | ||
| @@ -85,7 +69,7 @@ func New(cfg Config) (*Writer, error) { | |||||
| 85 | // Write serializes entry and writes it to the configured output. It is safe | 69 | // Write serializes entry and writes it to the configured output. It is safe | ||
| 86 | // for concurrent use. A write failure is returned as an error; callers should | 70 | // for concurrent use. A write failure is returned as an error; callers should | ||
| 87 | // log the error and continue — Write never panics. | 71 | // log the error and continue — Write never panics. | ||
| 88 | - | func (w *Writer) Write(entry Entry) error { | 72 | + | func (w *Writer) Write(entry out.AccessLogEntry) error { |
| 89 | line, err := w.formatter(entry) | 73 | line, err := w.formatter(entry) | ||
| 90 | if err != nil { | 74 | if err != nil { | ||
| 91 | return fmt.Errorf("accesslog: format entry: %w", err) | 75 | return fmt.Errorf("accesslog: format entry: %w", err) | ||
| @@ -108,7 +92,7 @@ func (w *Writer) Close() error { | |||||
| 108 | } | 92 | } | ||
| 109 | 93 | ||||
| 110 | // newFormatter returns the format function for the given format name. | 94 | // newFormatter returns the format function for the given format name. | ||
| 111 | - | func newFormatter(format string) (func(Entry) (string, error), error) { | 95 | + | func newFormatter(format string) (func(out.AccessLogEntry) (string, error), error) { |
| 112 | switch format { | 96 | switch format { | ||
| 113 | case "json": | 97 | case "json": | ||
| 114 | return formatJSON, nil | 98 | return formatJSON, nil | ||
M internal/adapters/out/accesslog/writer_test.go +3 -1
| @@ -13,9 +13,11 @@ import ( | |||
| 13 | 13 | ||
| 14 | 14 | "github.com/stretchr/testify/assert" | |
| 15 | 15 | "github.com/stretchr/testify/require" | |
| 16 | + | ||
| 17 | + | out "github.com/bnema/gordon/internal/boundaries/out" | |
| 16 | 18 | ) | |
| 17 | 19 | ||
| 18 | - | var sampleEntry = Entry{ | |
| 20 | + | var sampleEntry = out.AccessLogEntry{ | |
| 19 | 21 | Time: time.Date(2026, 4, 3, 14, 20, 54, 167_000_000, time.UTC), | |
| 20 | 22 | ClientIP: "10.0.0.1", | |
| 21 | 23 | Method: "POST", | |
| @@ -13,9 +13,11 @@ import ( | |||||
| 13 | 13 | ||||
| 14 | "github.com/stretchr/testify/assert" | 14 | "github.com/stretchr/testify/assert" | ||
| 15 | "github.com/stretchr/testify/require" | 15 | "github.com/stretchr/testify/require" | ||
| 16 | + | ||||
| 17 | + | out "github.com/bnema/gordon/internal/boundaries/out" | |||
| 16 | ) | 18 | ) | ||
| 17 | 19 | ||||
| 18 | - | var sampleEntry = Entry{ | 20 | + | var sampleEntry = out.AccessLogEntry{ |
| 19 | Time: time.Date(2026, 4, 3, 14, 20, 54, 167_000_000, time.UTC), | 21 | Time: time.Date(2026, 4, 3, 14, 20, 54, 167_000_000, time.UTC), | ||
| 20 | ClientIP: "10.0.0.1", | 22 | ClientIP: "10.0.0.1", | ||
| 21 | Method: "POST", | 23 | Method: "POST", | ||
M internal/adapters/out/httpprober/prober.go +2 -2
| @@ -8,7 +8,7 @@ import ( | |||
| 8 | 8 | "net/http" | |
| 9 | 9 | "time" | |
| 10 | 10 | ||
| 11 | - | "github.com/bnema/gordon/internal/domain" | |
| 11 | + | out "github.com/bnema/gordon/internal/boundaries/out" | |
| 12 | 12 | ) | |
| 13 | 13 | ||
| 14 | 14 | // DefaultTimeout is the default timeout for HTTP probes. | |
| @@ -79,7 +79,7 @@ func (p *Prober) Probe(ctx context.Context, url string) (int, int64, error) { | |||
| 79 | 79 | } | |
| 80 | 80 | ||
| 81 | 81 | // Set a recognisable user agent so access-log filters can exclude probe traffic. | |
| 82 | - | req.Header.Set("User-Agent", domain.HealthCheckUserAgentPrefix+"1.0") | |
| 82 | + | req.Header.Set("User-Agent", out.HealthCheckUserAgentPrefix+"1.0") | |
| 83 | 83 | ||
| 84 | 84 | resp, err := p.client.Do(req) | |
| 85 | 85 | if err != nil { | |
| @@ -8,7 +8,7 @@ import ( | |||||
| 8 | "net/http" | 8 | "net/http" | ||
| 9 | "time" | 9 | "time" | ||
| 10 | 10 | ||||
| 11 | - | "github.com/bnema/gordon/internal/domain" | 11 | + | out "github.com/bnema/gordon/internal/boundaries/out" |
| 12 | ) | 12 | ) | ||
| 13 | 13 | ||||
| 14 | // DefaultTimeout is the default timeout for HTTP probes. | 14 | // DefaultTimeout is the default timeout for HTTP probes. | ||
| @@ -79,7 +79,7 @@ func (p *Prober) Probe(ctx context.Context, url string) (int, int64, error) { | |||||
| 79 | } | 79 | } | ||
| 80 | 80 | ||||
| 81 | // Set a recognisable user agent so access-log filters can exclude probe traffic. | 81 | // Set a recognisable user agent so access-log filters can exclude probe traffic. | ||
| 82 | - | req.Header.Set("User-Agent", domain.HealthCheckUserAgentPrefix+"1.0") | 82 | + | req.Header.Set("User-Agent", out.HealthCheckUserAgentPrefix+"1.0") |
| 83 | 83 | ||||
| 84 | resp, err := p.client.Do(req) | 84 | resp, err := p.client.Do(req) | ||
| 85 | if err != nil { | 85 | if err != nil { | ||
M internal/app/run.go +2 -2
| @@ -1660,7 +1660,7 @@ func loopbackOnly(next http.Handler, log zerowrap.Logger) http.Handler { | |||
| 1660 | 1660 | ||
| 1661 | 1661 | // createHTTPHandlers creates HTTP handlers with middleware. | |
| 1662 | 1662 | // Returns three handlers: registry, HTTP proxy (with CIDR + onboarding), and HTTPS proxy. | |
| 1663 | - | func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger, accessWriter middleware.AccessLogWriter) (http.Handler, http.Handler, http.Handler) { | |
| 1663 | + | func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger, accessWriter out.AccessLogWriter) (http.Handler, http.Handler, http.Handler) { | |
| 1664 | 1664 | // Parse trusted proxies once for all middleware chains. | |
| 1665 | 1665 | // This ensures consistent IP extraction across logging, rate limiting, and auth. | |
| 1666 | 1666 | trustedNets := middleware.ParseTrustedProxies(cfg.API.RateLimit.TrustedProxies) | |
| @@ -2055,7 +2055,7 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, svc *services, | |||
| 2055 | 2055 | } | |
| 2056 | 2056 | // Convert to interface only when non-nil to avoid the Go nil-interface pitfall | |
| 2057 | 2057 | // where a typed nil pointer becomes a non-nil interface value. | |
| 2058 | - | var accessWriter middleware.AccessLogWriter | |
| 2058 | + | var accessWriter out.AccessLogWriter | |
| 2059 | 2059 | if accessWriterConcrete != nil { | |
| 2060 | 2060 | accessWriter = accessWriterConcrete | |
| 2061 | 2061 | } | |
| @@ -1660,7 +1660,7 @@ func loopbackOnly(next http.Handler, log zerowrap.Logger) http.Handler { | |||||
| 1660 | 1660 | ||||
| 1661 | // createHTTPHandlers creates HTTP handlers with middleware. | 1661 | // createHTTPHandlers creates HTTP handlers with middleware. | ||
| 1662 | // Returns three handlers: registry, HTTP proxy (with CIDR + onboarding), and HTTPS proxy. | 1662 | // Returns three handlers: registry, HTTP proxy (with CIDR + onboarding), and HTTPS proxy. | ||
| 1663 | - | func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger, accessWriter middleware.AccessLogWriter) (http.Handler, http.Handler, http.Handler) { | 1663 | + | func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger, accessWriter out.AccessLogWriter) (http.Handler, http.Handler, http.Handler) { |
| 1664 | // Parse trusted proxies once for all middleware chains. | 1664 | // Parse trusted proxies once for all middleware chains. | ||
| 1665 | // This ensures consistent IP extraction across logging, rate limiting, and auth. | 1665 | // This ensures consistent IP extraction across logging, rate limiting, and auth. | ||
| 1666 | trustedNets := middleware.ParseTrustedProxies(cfg.API.RateLimit.TrustedProxies) | 1666 | trustedNets := middleware.ParseTrustedProxies(cfg.API.RateLimit.TrustedProxies) | ||
| @@ -2055,7 +2055,7 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, svc *services, | |||||
| 2055 | } | 2055 | } | ||
| 2056 | // Convert to interface only when non-nil to avoid the Go nil-interface pitfall | 2056 | // Convert to interface only when non-nil to avoid the Go nil-interface pitfall | ||
| 2057 | // where a typed nil pointer becomes a non-nil interface value. | 2057 | // where a typed nil pointer becomes a non-nil interface value. | ||
| 2058 | - | var accessWriter middleware.AccessLogWriter | 2058 | + | var accessWriter out.AccessLogWriter |
| 2059 | if accessWriterConcrete != nil { | 2059 | if accessWriterConcrete != nil { | ||
| 2060 | accessWriter = accessWriterConcrete | 2060 | accessWriter = accessWriterConcrete | ||
| 2061 | } | 2061 | } | ||
M internal/app/run_http_handlers_test.go +7 -7
| @@ -15,29 +15,29 @@ import ( | |||
| 15 | 15 | "github.com/stretchr/testify/require" | |
| 16 | 16 | ||
| 17 | 17 | adminhttp "github.com/bnema/gordon/internal/adapters/in/http/admin" | |
| 18 | - | "github.com/bnema/gordon/internal/adapters/out/accesslog" | |
| 19 | 18 | pkiadapter "github.com/bnema/gordon/internal/adapters/out/pki" | |
| 19 | + | out "github.com/bnema/gordon/internal/boundaries/out" | |
| 20 | 20 | ) | |
| 21 | 21 | ||
| 22 | 22 | // testAccessLogWriter is a thread-safe mock AccessLogWriter for tests. | |
| 23 | 23 | type testAccessLogWriter struct { | |
| 24 | 24 | mu sync.Mutex | |
| 25 | - | entries []accesslog.Entry | |
| 25 | + | entries []out.AccessLogEntry | |
| 26 | 26 | } | |
| 27 | 27 | ||
| 28 | - | func (w *testAccessLogWriter) Write(entry accesslog.Entry) error { | |
| 28 | + | func (w *testAccessLogWriter) Write(entry out.AccessLogEntry) error { | |
| 29 | 29 | w.mu.Lock() | |
| 30 | 30 | defer w.mu.Unlock() | |
| 31 | 31 | w.entries = append(w.entries, entry) | |
| 32 | 32 | return nil | |
| 33 | 33 | } | |
| 34 | 34 | ||
| 35 | - | func (w *testAccessLogWriter) snapshot() []accesslog.Entry { | |
| 35 | + | func (w *testAccessLogWriter) snapshot() []out.AccessLogEntry { | |
| 36 | 36 | w.mu.Lock() | |
| 37 | 37 | defer w.mu.Unlock() | |
| 38 | - | out := make([]accesslog.Entry, len(w.entries)) | |
| 39 | - | copy(out, w.entries) | |
| 40 | - | return out | |
| 38 | + | result := make([]out.AccessLogEntry, len(w.entries)) | |
| 39 | + | copy(result, w.entries) | |
| 40 | + | return result | |
| 41 | 41 | } | |
| 42 | 42 | ||
| 43 | 43 | func TestCreateAuthService_Disabled_ReturnsNilServices(t *testing.T) { | |
| @@ -15,29 +15,29 @@ import ( | |||||
| 15 | "github.com/stretchr/testify/require" | 15 | "github.com/stretchr/testify/require" | ||
| 16 | 16 | ||||
| 17 | adminhttp "github.com/bnema/gordon/internal/adapters/in/http/admin" | 17 | adminhttp "github.com/bnema/gordon/internal/adapters/in/http/admin" | ||
| 18 | - | "github.com/bnema/gordon/internal/adapters/out/accesslog" | |||
| 19 | pkiadapter "github.com/bnema/gordon/internal/adapters/out/pki" | 18 | pkiadapter "github.com/bnema/gordon/internal/adapters/out/pki" | ||
| 19 | + | out "github.com/bnema/gordon/internal/boundaries/out" | |||
| 20 | ) | 20 | ) | ||
| 21 | 21 | ||||
| 22 | // testAccessLogWriter is a thread-safe mock AccessLogWriter for tests. | 22 | // testAccessLogWriter is a thread-safe mock AccessLogWriter for tests. | ||
| 23 | type testAccessLogWriter struct { | 23 | type testAccessLogWriter struct { | ||
| 24 | mu sync.Mutex | 24 | mu sync.Mutex | ||
| 25 | - | entries []accesslog.Entry | 25 | + | entries []out.AccessLogEntry |
| 26 | } | 26 | } | ||
| 27 | 27 | ||||
| 28 | - | func (w *testAccessLogWriter) Write(entry accesslog.Entry) error { | 28 | + | func (w *testAccessLogWriter) Write(entry out.AccessLogEntry) error { |
| 29 | w.mu.Lock() | 29 | w.mu.Lock() | ||
| 30 | defer w.mu.Unlock() | 30 | defer w.mu.Unlock() | ||
| 31 | w.entries = append(w.entries, entry) | 31 | w.entries = append(w.entries, entry) | ||
| 32 | return nil | 32 | return nil | ||
| 33 | } | 33 | } | ||
| 34 | 34 | ||||
| 35 | - | func (w *testAccessLogWriter) snapshot() []accesslog.Entry { | 35 | + | func (w *testAccessLogWriter) snapshot() []out.AccessLogEntry { |
| 36 | w.mu.Lock() | 36 | w.mu.Lock() | ||
| 37 | defer w.mu.Unlock() | 37 | defer w.mu.Unlock() | ||
| 38 | - | out := make([]accesslog.Entry, len(w.entries)) | 38 | + | result := make([]out.AccessLogEntry, len(w.entries)) |
| 39 | - | copy(out, w.entries) | 39 | + | copy(result, w.entries) |
| 40 | - | return out | 40 | + | return result |
| 41 | } | 41 | } | ||
| 42 | 42 | ||||
| 43 | func TestCreateAuthService_Disabled_ReturnsNilServices(t *testing.T) { | 43 | func TestCreateAuthService_Disabled_ReturnsNilServices(t *testing.T) { | ||
A internal/boundaries/out/accesslog.go +34 -0
| @@ -0,0 +1,34 @@ | |||
| 1 | + | package out | |
| 2 | + | ||
| 3 | + | import "time" | |
| 4 | + | ||
| 5 | + | // AccessLogEntry represents a single HTTP access log record. | |
| 6 | + | // It is the neutral data contract between the access log middleware (adapters/in) | |
| 7 | + | // and the access log writer (adapters/out). Keeping it in the boundaries layer | |
| 8 | + | // prevents direct adapter-to-adapter coupling. | |
| 9 | + | type AccessLogEntry struct { | |
| 10 | + | Time time.Time | |
| 11 | + | ClientIP string | |
| 12 | + | Method string | |
| 13 | + | Host string | |
| 14 | + | Path string | |
| 15 | + | Query string | |
| 16 | + | Status int | |
| 17 | + | BytesSent int | |
| 18 | + | DurationMS float64 | |
| 19 | + | UserAgent string | |
| 20 | + | Referer string | |
| 21 | + | RequestID string | |
| 22 | + | Proto string | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | // AccessLogWriter writes HTTP access log entries to a configured sink. | |
| 26 | + | // *accesslog.Writer in internal/adapters/out/accesslog implements this interface. | |
| 27 | + | type AccessLogWriter interface { | |
| 28 | + | Write(entry AccessLogEntry) error | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | // HealthCheckUserAgentPrefix is the User-Agent prefix set by Gordon's internal | |
| 32 | + | // health-check prober. Access log middleware uses this to filter out health-check | |
| 33 | + | // noise; the prober uses it to construct the full User-Agent string. | |
| 34 | + | const HealthCheckUserAgentPrefix = "Gordon-HealthCheck/" | |
| @@ -0,0 +1,34 @@ | |||||
| 1 | + | package out | |||
| 2 | + | ||||
| 3 | + | import "time" | |||
| 4 | + | ||||
| 5 | + | // AccessLogEntry represents a single HTTP access log record. | |||
| 6 | + | // It is the neutral data contract between the access log middleware (adapters/in) | |||
| 7 | + | // and the access log writer (adapters/out). Keeping it in the boundaries layer | |||
| 8 | + | // prevents direct adapter-to-adapter coupling. | |||
| 9 | + | type AccessLogEntry struct { | |||
| 10 | + | Time time.Time | |||
| 11 | + | ClientIP string | |||
| 12 | + | Method string | |||
| 13 | + | Host string | |||
| 14 | + | Path string | |||
| 15 | + | Query string | |||
| 16 | + | Status int | |||
| 17 | + | BytesSent int | |||
| 18 | + | DurationMS float64 | |||
| 19 | + | UserAgent string | |||
| 20 | + | Referer string | |||
| 21 | + | RequestID string | |||
| 22 | + | Proto string | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | // AccessLogWriter writes HTTP access log entries to a configured sink. | |||
| 26 | + | // *accesslog.Writer in internal/adapters/out/accesslog implements this interface. | |||
| 27 | + | type AccessLogWriter interface { | |||
| 28 | + | Write(entry AccessLogEntry) error | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | // HealthCheckUserAgentPrefix is the User-Agent prefix set by Gordon's internal | |||
| 32 | + | // health-check prober. Access log middleware uses this to filter out health-check | |||
| 33 | + | // noise; the prober uses it to construct the full User-Agent string. | |||
| 34 | + | const HealthCheckUserAgentPrefix = "Gordon-HealthCheck/" | |||
M internal/domain/labels.go +0 -4
| @@ -1,9 +1,5 @@ | |||
| 1 | 1 | package domain | |
| 2 | 2 | ||
| 3 | - | // HealthCheckUserAgentPrefix is the User-Agent prefix set by Gordon's internal | |
| 4 | - | // health-check prober. Shared so access-log filtering and the prober stay in sync. | |
| 5 | - | const HealthCheckUserAgentPrefix = "Gordon-HealthCheck/" | |
| 6 | - | ||
| 7 | 3 | // Label keys used by Gordon for container and image metadata. | |
| 8 | 4 | const ( | |
| 9 | 5 | // Container labels | |
| @@ -1,9 +1,5 @@ | |||||
| 1 | package domain | 1 | package domain | ||
| 2 | 2 | ||||
| 3 | - | // HealthCheckUserAgentPrefix is the User-Agent prefix set by Gordon's internal | |||
| 4 | - | // health-check prober. Shared so access-log filtering and the prober stay in sync. | |||
| 5 | - | const HealthCheckUserAgentPrefix = "Gordon-HealthCheck/" | |||
| 6 | - | ||||
| 7 | // Label keys used by Gordon for container and image metadata. | 3 | // Label keys used by Gordon for container and image metadata. | ||
| 8 | const ( | 4 | const ( | ||
| 9 | // Container labels | 5 | // Container labels | ||