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:
2026-03-19 13:39:19 +00:00
commit 1ec0780f72
15 changed files with 750 additions and 0 deletions

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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

View 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).

View 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.

View 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
View 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
View 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
View 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
View 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)
}