docs(httpserver): correct tier from 4 to 3
httpserver depends on launcher (Tier 2), placing it at Tier 3. With launcher corrected from Tier 5 to Tier 2, httpserver's tier drops accordingly.
This commit is contained in:
26
.devcontainer/devcontainer.json
Normal file
26
.devcontainer/devcontainer.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "Go",
|
||||||
|
"image": "mcr.microsoft.com/devcontainers/go:2-1.25-trixie",
|
||||||
|
"features": {
|
||||||
|
"ghcr.io/devcontainers-extra/features/claude-code:1": {}
|
||||||
|
},
|
||||||
|
"forwardPorts": [],
|
||||||
|
"postCreateCommand": "go version",
|
||||||
|
"customizations": {
|
||||||
|
"vscode": {
|
||||||
|
"settings": {
|
||||||
|
"files.autoSave": "afterDelay",
|
||||||
|
"files.autoSaveDelay": 1000,
|
||||||
|
"explorer.compactFolders": false,
|
||||||
|
"explorer.showEmptyFolders": true
|
||||||
|
},
|
||||||
|
"extensions": [
|
||||||
|
"golang.go",
|
||||||
|
"eamodio.golang-postfix-completion",
|
||||||
|
"quicktype.quicktype",
|
||||||
|
"usernamehw.errorlens"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"remoteUser": "vscode"
|
||||||
|
}
|
||||||
38
.gitignore
vendored
Normal file
38
.gitignore
vendored
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# Binaries
|
||||||
|
*.exe
|
||||||
|
*.exe~
|
||||||
|
*.dll
|
||||||
|
*.so
|
||||||
|
*.dylib
|
||||||
|
|
||||||
|
# Test binary, built with go test -c
|
||||||
|
*.test
|
||||||
|
|
||||||
|
# Output of go build
|
||||||
|
*.out
|
||||||
|
|
||||||
|
# Dependency directory
|
||||||
|
vendor/
|
||||||
|
|
||||||
|
# Go workspace file
|
||||||
|
go.work
|
||||||
|
go.work.sum
|
||||||
|
|
||||||
|
# Environment files
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
|
||||||
|
# Editor / IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# VCS files
|
||||||
|
COMMIT.md
|
||||||
|
RELEASE.md
|
||||||
28
CHANGELOG.md
Normal file
28
CHANGELOG.md
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to this module will be documented in this file.
|
||||||
|
|
||||||
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
|
and this module adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [0.9.0] - 2026-03-18
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `Logger` interface — duck-typed; requires `Info(msg string, args ...any)` and `Error(msg string, err error, args ...any)`; satisfied directly by `logz.Logger` and by any two-method struct, with no import of `logz` required
|
||||||
|
- `Config` struct — holds HTTP server configuration with env-tag support: `Host` (`SERVER_HOST`, default `0.0.0.0`), `Port` (`SERVER_PORT`, default `8080`), `ReadTimeout` (`SERVER_READ_TIMEOUT`, default `5s`), `WriteTimeout` (`SERVER_WRITE_TIMEOUT`, default `10s`), `IdleTimeout` (`SERVER_IDLE_TIMEOUT`, default `120s`)
|
||||||
|
- `Option` functional option type for configuring the server at construction time
|
||||||
|
- `WithMiddleware(mw ...func(http.Handler) http.Handler) Option` — accumulates middleware applied to the root chi router during `OnInit`; order is preserved and caller-controlled; multiple calls to `WithMiddleware` append to the list
|
||||||
|
- `HttpServerComponent` interface — embeds both `launcher.Component` and `chi.Router`, giving callers the complete chi routing API (`Get`, `Post`, `Route`, `Mount`, `Use`, `With`, `Group`, etc.) on the same value that participates in the launcher lifecycle
|
||||||
|
- `New(logger Logger, cfg Config, opts ...Option) HttpServerComponent` — constructs the server component backed by `chi.NewRouter()`; no middleware is installed by default
|
||||||
|
- `OnInit` lifecycle method — applies all middleware registered via `WithMiddleware` to the root router; no-op if none were provided
|
||||||
|
- `OnStart` lifecycle method — constructs an `http.Server` with the configured timeouts and starts it in a background goroutine; logs the bind address on start and logs fatal errors if `ListenAndServe` exits unexpectedly
|
||||||
|
- `OnStop` lifecycle method — calls `http.Server.Shutdown` with a 10-second context timeout, giving in-flight requests up to 10 seconds to complete before the method returns
|
||||||
|
|
||||||
|
### Design Notes
|
||||||
|
|
||||||
|
- `HttpServerComponent` embeds `chi.Router` directly in the interface rather than delegating through wrapper methods, so callers register routes and manage the lifecycle on the same value with no extra indirection
|
||||||
|
- No middleware is installed by default; the full middleware stack is composed explicitly via `WithMiddleware` at construction time, keeping the stack visible and ordering unambiguous in the application source
|
||||||
|
- chi was chosen as the underlying router because it uses stdlib `http.Handler` throughout, making it fully compatible with `httpmw` middleware and `httputil` handler adapters without any wrapper code at the boundary
|
||||||
|
|
||||||
|
[0.9.0]: https://code.nochebuena.dev/go/httpserver/releases/tag/v0.9.0
|
||||||
85
CLAUDE.md
Normal file
85
CLAUDE.md
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
# httpserver
|
||||||
|
|
||||||
|
Lifecycle-managed HTTP server built on chi, implementing `launcher.Component` and embedding `chi.Router`.
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Provides an HTTP server that integrates with the `launcher` lifecycle (OnInit → OnStart → OnStop) while exposing the full chi routing API directly. Callers use a single value for both lifecycle management and route registration.
|
||||||
|
|
||||||
|
## Tier & Dependencies
|
||||||
|
|
||||||
|
**Tier 3** (transport layer). Depends on:
|
||||||
|
- `code.nochebuena.dev/go/launcher` — Component interface
|
||||||
|
- `github.com/go-chi/chi/v5` — router (part of the public API)
|
||||||
|
|
||||||
|
No application-level or business-logic dependencies. Do not import domain, service, or repository packages from this module.
|
||||||
|
|
||||||
|
## Key Design Decisions
|
||||||
|
|
||||||
|
- **chi over Echo** (ADR-001): chi uses stdlib `http.Handler` everywhere. Every middleware in the micro-lib stack (`httpmw`) and every handler adapter (`httputil`) uses `http.Handler`/`http.ResponseWriter`/`*http.Request`. Echo's custom context type would require wrapper code at every boundary.
|
||||||
|
- **Embedded `chi.Router`** (ADR-002): `HttpServerComponent` embeds `chi.Router` directly, so callers get the full routing API (`Get`, `Post`, `Route`, `Mount`, `Use`, `With`, `Group`, etc.) without a wrapper. The concrete `httpServer` struct embeds `chi.NewRouter()`.
|
||||||
|
- **No default middleware** (ADR-003): `New()` installs nothing. Middleware is composed explicitly with `WithMiddleware(...)`. Order is caller-controlled and visible in the application source.
|
||||||
|
- **Duck-typed Logger** (global ADR-001): The `Logger` interface requires only `Info(msg string, args ...any)` and `Error(msg string, err error, args ...any)`. Any struct with those two methods satisfies it — including `logz.Logger` and application-local adapters.
|
||||||
|
- **Graceful shutdown**: `OnStop()` calls `http.Server.Shutdown` with a 10-second timeout derived from `context.WithTimeout(context.Background(), 10*time.Second)`. In-flight requests have up to 10 seconds to complete before the server returns.
|
||||||
|
|
||||||
|
## Patterns
|
||||||
|
|
||||||
|
**Construction and wiring:**
|
||||||
|
|
||||||
|
```go
|
||||||
|
srv := httpserver.New(logger, httpserver.Config{Port: 3000},
|
||||||
|
httpserver.WithMiddleware(
|
||||||
|
httpmw.RequestID(uuid.NewString),
|
||||||
|
httpmw.Recover(),
|
||||||
|
httpmw.RequestLogger(logger),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
lc.Append(db, srv)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Route registration in BeforeStart (after db init, before port bind):**
|
||||||
|
|
||||||
|
```go
|
||||||
|
lc.BeforeStart(func() error {
|
||||||
|
srv.Get("/health", healthHandler)
|
||||||
|
srv.Route("/api/v1", func(r chi.Router) {
|
||||||
|
r.Get("/todos", todoHandler.FindAll)
|
||||||
|
r.Post("/todos", todoHandler.Create)
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**Middleware applied to a sub-group:**
|
||||||
|
|
||||||
|
```go
|
||||||
|
srv.Group(func(r chi.Router) {
|
||||||
|
r.Use(appMW.Auth(userRepo))
|
||||||
|
r.With(appMW.Require(permProvider, resource, perm)).Get("/protected", handler)
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**Config env vars:**
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `SERVER_HOST` | `0.0.0.0` | Bind address |
|
||||||
|
| `SERVER_PORT` | `8080` | Listen port |
|
||||||
|
| `SERVER_READ_TIMEOUT` | `5s` | Read timeout |
|
||||||
|
| `SERVER_WRITE_TIMEOUT` | `10s` | Write timeout |
|
||||||
|
| `SERVER_IDLE_TIMEOUT` | `120s` | Keep-alive idle timeout |
|
||||||
|
|
||||||
|
## What to Avoid
|
||||||
|
|
||||||
|
- Do not call `srv.Use(mw)` directly after `OnInit()` has run — middleware registered after `OnInit` may not behave as expected depending on chi's internal state. Register all middleware via `WithMiddleware(...)` at construction time.
|
||||||
|
- Do not register routes outside of a `BeforeStart` hook when using the launcher. Routes should be registered after dependent components (e.g., the database) have initialized.
|
||||||
|
- Do not add a `chi`-incompatible router behind this interface. The `chi.Router` embed is part of the public contract.
|
||||||
|
- Do not pass `nil` as the `Logger`. The logger is called on every start, stop, and fatal error.
|
||||||
|
- Do not use Echo, Gin, or any framework that wraps `http.Handler` — it will be incompatible with `httpmw` and `httputil`.
|
||||||
|
|
||||||
|
## Testing Notes
|
||||||
|
|
||||||
|
- `httpserver_test.go` runs as a white-box test (`package httpserver`) and uses `httptest.NewRequest` / `httptest.NewRecorder` to test routes without binding a port.
|
||||||
|
- `compliance_test.go` is a black-box test (`package httpserver_test`) containing only compile-time assertions that `httpserver.New(...)` satisfies both `launcher.Component` and `chi.Router`.
|
||||||
|
- Tests that need a real TCP listener use `freePort(t)` to find an available port and call `OnStart()` / `OnStop()` directly.
|
||||||
|
- The `Logger` interface is minimal enough that a two-method struct in the test file satisfies it without any imports from logz.
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 NOCHEBUENADEV
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
67
README.md
Normal file
67
README.md
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
# httpserver
|
||||||
|
|
||||||
|
Lifecycle-managed HTTP server built on [chi](https://github.com/go-chi/chi) that integrates with the launcher component model.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Implements `launcher.Component` (`OnInit` / `OnStart` / `OnStop`)
|
||||||
|
- Embeds `chi.Router` — full routing API exposed directly on the component
|
||||||
|
- Graceful shutdown with configurable timeouts
|
||||||
|
- No middleware installed by default — compose your stack with `WithMiddleware`
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```
|
||||||
|
require code.nochebuena.dev/go/httpserver v0.1.0
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```go
|
||||||
|
srv := httpserver.New(logger, cfg,
|
||||||
|
httpserver.WithMiddleware(
|
||||||
|
httpmw.Recover(),
|
||||||
|
httpmw.CORS([]string{"*"}),
|
||||||
|
httpmw.RequestID(uuid.NewString),
|
||||||
|
httpmw.RequestLogger(logger),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Register routes directly on srv — it is a chi.Router.
|
||||||
|
srv.Get("/health", health.NewHandler(logger, db, cache))
|
||||||
|
|
||||||
|
srv.Route("/api/v1", func(r chi.Router) {
|
||||||
|
r.Use(httpauth.AuthMiddleware(firebaseClient, nil))
|
||||||
|
r.Get("/orders/{id}", httputil.Handle(validator, svc.GetOrder))
|
||||||
|
})
|
||||||
|
|
||||||
|
lc := launcher.New(logger)
|
||||||
|
lc.Append(db, cache, srv)
|
||||||
|
if err := lc.Run(); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Config
|
||||||
|
|
||||||
|
| Field | Env var | Default | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `Host` | `SERVER_HOST` | `0.0.0.0` | Bind address |
|
||||||
|
| `Port` | `SERVER_PORT` | `8080` | Listen port |
|
||||||
|
| `ReadTimeout` | `SERVER_READ_TIMEOUT` | `5s` | Max time to read request |
|
||||||
|
| `WriteTimeout` | `SERVER_WRITE_TIMEOUT` | `10s` | Max time to write response |
|
||||||
|
| `IdleTimeout` | `SERVER_IDLE_TIMEOUT` | `120s` | Keep-alive idle timeout |
|
||||||
|
|
||||||
|
## Options
|
||||||
|
|
||||||
|
| Option | Description |
|
||||||
|
|---|---|
|
||||||
|
| `WithMiddleware(mw ...func(http.Handler) http.Handler)` | Applies middleware to the root router during `OnInit` |
|
||||||
|
|
||||||
|
## Lifecycle
|
||||||
|
|
||||||
|
| Phase | Action |
|
||||||
|
|---|---|
|
||||||
|
| `OnInit` | Applies registered middleware to the router |
|
||||||
|
| `OnStart` | Starts `net/http.Server` in a background goroutine |
|
||||||
|
| `OnStop` | Graceful shutdown — waits up to 10s for in-flight requests |
|
||||||
19
compliance_test.go
Normal file
19
compliance_test.go
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
package httpserver_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"code.nochebuena.dev/go/httpserver"
|
||||||
|
"code.nochebuena.dev/go/launcher"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
type testLogger struct{}
|
||||||
|
|
||||||
|
func (t *testLogger) Info(msg string, args ...any) {}
|
||||||
|
func (t *testLogger) Error(msg string, err error, args ...any) {}
|
||||||
|
|
||||||
|
// Compile-time checks.
|
||||||
|
var _ httpserver.Logger = (*testLogger)(nil)
|
||||||
|
|
||||||
|
// HttpServerComponent must satisfy both launcher.Component and chi.Router.
|
||||||
|
var _ launcher.Component = httpserver.New((*testLogger)(nil), httpserver.Config{})
|
||||||
|
var _ chi.Router = httpserver.New((*testLogger)(nil), httpserver.Config{})
|
||||||
19
doc.go
Normal file
19
doc.go
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
// Package httpserver provides a lifecycle-managed HTTP server built on chi.
|
||||||
|
//
|
||||||
|
// It implements [launcher.Component] so it integrates directly with the launcher
|
||||||
|
// lifecycle (OnInit → OnStart → OnStop). It also embeds [chi.Router], giving callers
|
||||||
|
// the full chi routing API: Get, Post, Route, Mount, Use, etc.
|
||||||
|
//
|
||||||
|
// No middleware is installed by default. Compose your stack with [WithMiddleware]:
|
||||||
|
//
|
||||||
|
// srv := httpserver.New(logger, cfg,
|
||||||
|
// httpserver.WithMiddleware(
|
||||||
|
// httpmw.Recover(),
|
||||||
|
// httpmw.CORS([]string{"*"}),
|
||||||
|
// httpmw.RequestID(uuid.NewString),
|
||||||
|
// httpmw.RequestLogger(logger),
|
||||||
|
// ),
|
||||||
|
// )
|
||||||
|
// srv.Get("/health", healthHandler)
|
||||||
|
// srv.Route("/api/v1", func(r chi.Router) { ... })
|
||||||
|
package httpserver
|
||||||
30
docs/adr/ADR-001-chi-over-echo.md
Normal file
30
docs/adr/ADR-001-chi-over-echo.md
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# ADR-001: chi over Echo
|
||||||
|
|
||||||
|
**Status:** Accepted
|
||||||
|
**Date:** 2026-03-18
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
httpserver needs an HTTP router. The two leading candidates were:
|
||||||
|
|
||||||
|
- **chi** (`github.com/go-chi/chi/v5`) — a lightweight, idiomatic router whose root type implements `http.Handler` and whose middleware signature is `func(http.Handler) http.Handler`
|
||||||
|
- **Echo** — a full web framework with its own `Context` type, its own middleware signature (`echo.MiddlewareFunc`), and handler signature (`echo.HandlerFunc`)
|
||||||
|
|
||||||
|
The project constraint is that all transport-layer modules must compose cleanly with the rest of the micro-lib stack, which is built entirely on stdlib `net/http` types. `httpmw` middleware, `httputil` handler adapters, and all application handlers use `http.Handler` and `http.ResponseWriter`/`*http.Request` directly.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Use chi. Every chi middleware is `func(http.Handler) http.Handler`, every chi handler is `http.Handler`, and `chi.Router` itself satisfies `http.Handler`. This means:
|
||||||
|
|
||||||
|
- Any middleware written for the stdlib works with chi without wrapping.
|
||||||
|
- Any `http.Handler` can be registered directly without conversion.
|
||||||
|
- `httputil.Handle` and `httputil.HandleNoBody` adapters slot in without adapter code.
|
||||||
|
- The server itself can be tested with `httptest.NewRecorder` against the router directly, without starting a process.
|
||||||
|
|
||||||
|
Echo would require wrapping every stdlib handler and middleware at the boundary, creating a permanent impedance mismatch between httpserver and the rest of the stack.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- All route handlers and middleware in this project must use stdlib `net/http` types. No Echo-style context is available.
|
||||||
|
- chi's URL parameter API (`chi.URLParam(r, "id")`) is used for path parameters rather than a framework-specific context method.
|
||||||
|
- The chi dependency is a direct, visible import in `httpserver.go` and in any application that calls `srv.Route(...)` with a `func(chi.Router)` callback. This is accepted as the deliberate, explicit contract of the embedded router design (see ADR-002).
|
||||||
52
docs/adr/ADR-002-embedded-chi-router.md
Normal file
52
docs/adr/ADR-002-embedded-chi-router.md
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
# ADR-002: Embedded chi.Router in HttpServerComponent
|
||||||
|
|
||||||
|
**Status:** Accepted
|
||||||
|
**Date:** 2026-03-18
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
`HttpServerComponent` must expose routing methods to callers so they can register routes before the server starts. There are two design options:
|
||||||
|
|
||||||
|
1. **Wrapper methods** — define `Get(pattern, handler)`, `Post(...)`, `Route(...)`, `Mount(...)`, `Use(...)`, etc. on `HttpServerComponent` explicitly, delegating each to an internal router.
|
||||||
|
2. **Embedded router** — embed `chi.Router` directly in the interface and the concrete struct, so all chi routing methods are promoted to the component surface automatically.
|
||||||
|
|
||||||
|
The application bootstrap pattern in this project registers routes in a `lc.BeforeStart(...)` hook, after `db.OnInit` has run but before `srv.OnStart` binds the port. This means the same value returned by `httpserver.New(...)` is used both as a `launcher.Component` (lifecycle) and as a router (route registration). Callers write:
|
||||||
|
|
||||||
|
```go
|
||||||
|
srv := httpserver.New(logger, cfg, httpserver.WithMiddleware(...))
|
||||||
|
lc.Append(db, srv)
|
||||||
|
lc.BeforeStart(func() error {
|
||||||
|
srv.Get("/health", healthHandler)
|
||||||
|
srv.Route("/api/v1", func(r chi.Router) { ... })
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Embed `chi.Router` directly in both the `HttpServerComponent` interface and the `httpServer` concrete struct. The interface is declared as:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type HttpServerComponent interface {
|
||||||
|
launcher.Component
|
||||||
|
chi.Router
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The struct embeds `chi.Router` as an anonymous field, initialized to `chi.NewRouter()` in `New()`.
|
||||||
|
|
||||||
|
This means callers receive the complete chi routing API — `Get`, `Post`, `Put`, `Delete`, `Patch`, `Head`, `Options`, `Route`, `Mount`, `Use`, `With`, `Group`, `Handle`, `HandleFunc`, `Method`, `MethodFunc`, `Connect`, `Trace`, `NotFound`, `MethodNotAllowed` — without any wrapper boilerplate and without any risk of an incomplete wrapper missing a method.
|
||||||
|
|
||||||
|
The compliance test (`compliance_test.go`) asserts at compile time:
|
||||||
|
|
||||||
|
```go
|
||||||
|
var _ launcher.Component = httpserver.New(...)
|
||||||
|
var _ chi.Router = httpserver.New(...)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- `chi` is a visible part of the `HttpServerComponent` API. Callers using `Route(...)` or `Mount(...)` must import `github.com/go-chi/chi/v5` for the callback type. This is intentional and explicit, not a leaky abstraction — httpserver is documented as chi-backed.
|
||||||
|
- A future swap to a different router would be a breaking change to `HttpServerComponent`. This is accepted: the router choice is a deliberate, long-term decision (see ADR-001).
|
||||||
|
- Writing wrapper methods would have to be updated every time chi adds a new method. Embedding avoids that maintenance burden permanently.
|
||||||
|
- Middleware is applied to the embedded router in `OnInit()` by calling `s.Router.Use(mw)` for each registered middleware, preserving chi's standard middleware chaining semantics.
|
||||||
41
docs/adr/ADR-003-no-default-middleware.md
Normal file
41
docs/adr/ADR-003-no-default-middleware.md
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# ADR-003: No Middleware Installed by Default
|
||||||
|
|
||||||
|
**Status:** Accepted
|
||||||
|
**Date:** 2026-03-18
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Many HTTP frameworks and server libraries install a default middleware stack — request logging, panic recovery, CORS headers, request ID injection — on the assumption that most applications will want these. The question is whether `httpserver.New(...)` should do the same.
|
||||||
|
|
||||||
|
Arguments for a default stack:
|
||||||
|
- Reduces boilerplate for the common case.
|
||||||
|
- Ensures recovery from panics is always in place.
|
||||||
|
|
||||||
|
Arguments against:
|
||||||
|
- Different applications need different middleware in different orders. Order matters: request ID must precede the logger so the logger can attach the ID; auth must precede RBAC; compression must follow auth.
|
||||||
|
- Installing middleware that the caller neither requested nor knows about makes the system harder to reason about and test.
|
||||||
|
- The micro-lib design principle is zero-surprise construction: `New()` produces a minimal, predictable value. Behavior is added explicitly.
|
||||||
|
- A middleware like `RequestLogger` needs a logger argument that `httpserver` would have to accept on behalf of `httpmw`. This couples configuration surface areas that should be independent.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
`httpserver.New(...)` installs no middleware. The router returned from `New()` is a bare `chi.NewRouter()`. Middleware is composed explicitly by the caller using `WithMiddleware(...)`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
srv := httpserver.New(logger, cfg,
|
||||||
|
httpserver.WithMiddleware(
|
||||||
|
httpmw.RequestID(uuid.NewString),
|
||||||
|
httpmw.Recover(),
|
||||||
|
httpmw.RequestLogger(&logAdapter{logger}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
`WithMiddleware` is a variadic functional option that appends middleware to an internal slice. In `OnInit()`, each middleware is applied to the router via `s.Router.Use(mw)` in registration order (first registered = outermost in the chain).
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Every application must explicitly compose its middleware stack. This is intentional: the stack is visible, ordered, and testable in the application source.
|
||||||
|
- Panic recovery is not automatic. If a caller omits `httpmw.Recover()`, unhandled panics will crash the process. This is a deliberate trade-off — it makes the stack explicit and avoids silent behavior.
|
||||||
|
- Adding middleware after `OnInit()` has run is permitted by chi but is not the intended usage pattern. Route registration and middleware composition should both happen in the `BeforeStart` hook (before `OnStart` binds the port).
|
||||||
|
- The `WithMiddleware` option appends, so multiple calls accumulate: `WithMiddleware(a, b)` followed by `WithMiddleware(c)` results in `[a, b, c]` applied in that order.
|
||||||
10
go.mod
Normal file
10
go.mod
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
module code.nochebuena.dev/go/httpserver
|
||||||
|
|
||||||
|
go 1.25
|
||||||
|
|
||||||
|
require (
|
||||||
|
code.nochebuena.dev/go/launcher v0.9.0
|
||||||
|
github.com/go-chi/chi/v5 v5.2.1
|
||||||
|
)
|
||||||
|
|
||||||
|
require code.nochebuena.dev/go/logz v0.9.0 // indirect
|
||||||
6
go.sum
Normal file
6
go.sum
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
code.nochebuena.dev/go/launcher v0.9.0 h1:dJHonA9Xm03AQKK0919FJaQn9ZKHZ+RZfB9yxjnx3TA=
|
||||||
|
code.nochebuena.dev/go/launcher v0.9.0/go.mod h1:IBtntmbnyddukjEhxlc7Ysdzz9nZsnd9+8FzAIHt77g=
|
||||||
|
code.nochebuena.dev/go/logz v0.9.0 h1:wfV7vtI4V/8ED7Hm31Fbql7Y5iOGrlHN4X8Z5ajTZZE=
|
||||||
|
code.nochebuena.dev/go/logz v0.9.0/go.mod h1:qODhSbKb+tWE7rdhHLcKweiP5CgwIaWoZxadCT3bQV8=
|
||||||
|
github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8=
|
||||||
|
github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
||||||
116
httpserver.go
Normal file
116
httpserver.go
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
package httpserver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"code.nochebuena.dev/go/launcher"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Logger is the minimal interface httpserver needs — satisfied by logz.Logger.
|
||||||
|
type Logger interface {
|
||||||
|
Info(msg string, args ...any)
|
||||||
|
Error(msg string, err error, args ...any)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config holds the HTTP server configuration.
|
||||||
|
type Config struct {
|
||||||
|
Host string `env:"SERVER_HOST" envDefault:"0.0.0.0"`
|
||||||
|
Port int `env:"SERVER_PORT" envDefault:"8080"`
|
||||||
|
ReadTimeout time.Duration `env:"SERVER_READ_TIMEOUT" envDefault:"5s"`
|
||||||
|
WriteTimeout time.Duration `env:"SERVER_WRITE_TIMEOUT" envDefault:"10s"`
|
||||||
|
IdleTimeout time.Duration `env:"SERVER_IDLE_TIMEOUT" envDefault:"120s"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// serverOpts holds functional options.
|
||||||
|
type serverOpts struct {
|
||||||
|
middleware []func(http.Handler) http.Handler
|
||||||
|
}
|
||||||
|
|
||||||
|
// Option configures an httpserver component.
|
||||||
|
type Option func(*serverOpts)
|
||||||
|
|
||||||
|
// WithMiddleware applies one or more middleware to the root chi router during OnInit.
|
||||||
|
func WithMiddleware(mw ...func(http.Handler) http.Handler) Option {
|
||||||
|
return func(o *serverOpts) {
|
||||||
|
o.middleware = append(o.middleware, mw...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HttpServerComponent is a launcher.Component that also exposes a chi.Router.
|
||||||
|
// Embedding chi.Router gives callers the full routing API: Get, Post, Route, Mount, Use, etc.
|
||||||
|
type HttpServerComponent interface {
|
||||||
|
launcher.Component
|
||||||
|
chi.Router
|
||||||
|
}
|
||||||
|
|
||||||
|
type httpServer struct {
|
||||||
|
chi.Router
|
||||||
|
logger Logger
|
||||||
|
cfg Config
|
||||||
|
opts serverOpts
|
||||||
|
srv *http.Server
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates an HttpServerComponent. No middleware is installed by default;
|
||||||
|
// use [WithMiddleware] to compose the middleware stack.
|
||||||
|
func New(logger Logger, cfg Config, opts ...Option) HttpServerComponent {
|
||||||
|
o := serverOpts{}
|
||||||
|
for _, opt := range opts {
|
||||||
|
opt(&o)
|
||||||
|
}
|
||||||
|
return &httpServer{
|
||||||
|
Router: chi.NewRouter(),
|
||||||
|
logger: logger,
|
||||||
|
cfg: cfg,
|
||||||
|
opts: o,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnInit applies registered middleware to the router. No-op if none provided.
|
||||||
|
func (s *httpServer) OnInit() error {
|
||||||
|
for _, mw := range s.opts.middleware {
|
||||||
|
s.Router.Use(mw)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnStart starts the HTTP server in a background goroutine.
|
||||||
|
func (s *httpServer) OnStart() error {
|
||||||
|
host := s.cfg.Host
|
||||||
|
if host == "" {
|
||||||
|
host = "0.0.0.0"
|
||||||
|
}
|
||||||
|
port := s.cfg.Port
|
||||||
|
if port == 0 {
|
||||||
|
port = 8080
|
||||||
|
}
|
||||||
|
s.srv = &http.Server{
|
||||||
|
Addr: fmt.Sprintf("%s:%d", host, port),
|
||||||
|
Handler: s.Router,
|
||||||
|
ReadTimeout: s.cfg.ReadTimeout,
|
||||||
|
WriteTimeout: s.cfg.WriteTimeout,
|
||||||
|
IdleTimeout: s.cfg.IdleTimeout,
|
||||||
|
}
|
||||||
|
s.logger.Info("httpserver: starting", "addr", s.srv.Addr)
|
||||||
|
go func() {
|
||||||
|
if err := s.srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
|
s.logger.Error("httpserver: fatal error", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnStop performs a graceful shutdown, waiting up to 10 seconds for in-flight
|
||||||
|
// requests to complete.
|
||||||
|
func (s *httpServer) OnStop() error {
|
||||||
|
s.logger.Info("httpserver: shutting down gracefully")
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
return s.srv.Shutdown(ctx)
|
||||||
|
}
|
||||||
192
httpserver_test.go
Normal file
192
httpserver_test.go
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
package httpserver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- helpers ---
|
||||||
|
|
||||||
|
type testLogger struct{ last string }
|
||||||
|
|
||||||
|
func (l *testLogger) Info(msg string, args ...any) { l.last = "info:" + msg }
|
||||||
|
func (l *testLogger) Error(msg string, err error, args ...any) { l.last = "error:" + msg }
|
||||||
|
|
||||||
|
func newLogger() *testLogger { return &testLogger{} }
|
||||||
|
|
||||||
|
// freePort returns a random available TCP port.
|
||||||
|
func freePort(t *testing.T) int {
|
||||||
|
t.Helper()
|
||||||
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
port := ln.Addr().(*net.TCPAddr).Port
|
||||||
|
ln.Close()
|
||||||
|
return port
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Tests ---
|
||||||
|
|
||||||
|
func TestNew(t *testing.T) {
|
||||||
|
srv := New(newLogger(), Config{})
|
||||||
|
if srv == nil {
|
||||||
|
t.Fatal("New returned nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNew_ImplementsLauncherComponent(t *testing.T) {
|
||||||
|
srv := New(newLogger(), Config{})
|
||||||
|
// Verify launcher.Component methods exist (compile-time checked in compliance_test.go)
|
||||||
|
if err := srv.OnInit(); err != nil {
|
||||||
|
t.Fatalf("OnInit: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNew_WithMiddleware(t *testing.T) {
|
||||||
|
called := false
|
||||||
|
mw := func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
called = true
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
srv := New(newLogger(), Config{}, WithMiddleware(mw))
|
||||||
|
if err := srv.OnInit(); err != nil {
|
||||||
|
t.Fatalf("OnInit: %v", err)
|
||||||
|
}
|
||||||
|
srv.Get("/test", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
})
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
srv.ServeHTTP(rec, req)
|
||||||
|
if !called {
|
||||||
|
t.Error("WithMiddleware: middleware was not called")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComponent_OnInit(t *testing.T) {
|
||||||
|
srv := New(newLogger(), Config{})
|
||||||
|
if err := srv.OnInit(); err != nil {
|
||||||
|
t.Errorf("OnInit returned error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComponent_Routes(t *testing.T) {
|
||||||
|
srv := New(newLogger(), Config{})
|
||||||
|
if err := srv.OnInit(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
srv.Get("/ping", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
})
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/ping", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
srv.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Errorf("want 200, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComponent_RouteGroup(t *testing.T) {
|
||||||
|
srv := New(newLogger(), Config{})
|
||||||
|
if err := srv.OnInit(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
srv.Route("/api", func(r chi.Router) {
|
||||||
|
r.Get("/v1/hello", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/hello", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
srv.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Errorf("want 200, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComponent_OnStart_OnStop(t *testing.T) {
|
||||||
|
port := freePort(t)
|
||||||
|
srv := New(newLogger(), Config{
|
||||||
|
Host: "127.0.0.1",
|
||||||
|
Port: port,
|
||||||
|
ReadTimeout: time.Second,
|
||||||
|
WriteTimeout: time.Second,
|
||||||
|
IdleTimeout: time.Second,
|
||||||
|
})
|
||||||
|
if err := srv.OnInit(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := srv.OnStart(); err != nil {
|
||||||
|
t.Fatalf("OnStart: %v", err)
|
||||||
|
}
|
||||||
|
// Give the goroutine time to bind.
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
if err := srv.OnStop(); err != nil {
|
||||||
|
t.Errorf("OnStop: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComponent_OnStop_Graceful(t *testing.T) {
|
||||||
|
port := freePort(t)
|
||||||
|
srv := New(newLogger(), Config{
|
||||||
|
Host: "127.0.0.1",
|
||||||
|
Port: port,
|
||||||
|
ReadTimeout: 5 * time.Second,
|
||||||
|
WriteTimeout: 5 * time.Second,
|
||||||
|
IdleTimeout: 5 * time.Second,
|
||||||
|
})
|
||||||
|
if err := srv.OnInit(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register a slow handler.
|
||||||
|
done := make(chan struct{})
|
||||||
|
srv.Get("/slow", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
<-done
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := srv.OnStart(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
|
||||||
|
// Fire a request in the background.
|
||||||
|
result := make(chan int, 1)
|
||||||
|
go func() {
|
||||||
|
resp, err := http.Get("http://127.0.0.1:" + itoa(port) + "/slow")
|
||||||
|
if err != nil {
|
||||||
|
result <- 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result <- resp.StatusCode
|
||||||
|
}()
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
|
||||||
|
// Unblock handler, then stop the server.
|
||||||
|
close(done)
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
h := srv.(*httpServer)
|
||||||
|
if err := h.srv.Shutdown(ctx); err != nil {
|
||||||
|
t.Errorf("graceful shutdown error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if code := <-result; code != http.StatusOK {
|
||||||
|
t.Errorf("in-flight request: want 200, got %d", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func itoa(n int) string {
|
||||||
|
return fmt.Sprint(n)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user