From 1ec0780f7237fb1a60534ac9a7b5ea20e60fcd2c Mon Sep 17 00:00:00 2001 From: Rene Nochebuena Date: Thu, 19 Mar 2026 13:39:19 +0000 Subject: [PATCH] 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. --- .devcontainer/devcontainer.json | 26 +++ .gitignore | 38 +++++ CHANGELOG.md | 28 ++++ CLAUDE.md | 85 ++++++++++ LICENSE | 21 +++ README.md | 67 ++++++++ compliance_test.go | 19 +++ doc.go | 19 +++ docs/adr/ADR-001-chi-over-echo.md | 30 ++++ docs/adr/ADR-002-embedded-chi-router.md | 52 ++++++ docs/adr/ADR-003-no-default-middleware.md | 41 +++++ go.mod | 10 ++ go.sum | 6 + httpserver.go | 116 +++++++++++++ httpserver_test.go | 192 ++++++++++++++++++++++ 15 files changed, 750 insertions(+) create mode 100644 .devcontainer/devcontainer.json create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 CLAUDE.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 compliance_test.go create mode 100644 doc.go create mode 100644 docs/adr/ADR-001-chi-over-echo.md create mode 100644 docs/adr/ADR-002-embedded-chi-router.md create mode 100644 docs/adr/ADR-003-no-default-middleware.md create mode 100644 go.mod create mode 100644 go.sum create mode 100644 httpserver.go create mode 100644 httpserver_test.go diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..54f5aae --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -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" +} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..221da82 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..58ec349 --- /dev/null +++ b/CHANGELOG.md @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..0a15dad --- /dev/null +++ b/CLAUDE.md @@ -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. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0b33b48 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..4ef2d70 --- /dev/null +++ b/README.md @@ -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 | diff --git a/compliance_test.go b/compliance_test.go new file mode 100644 index 0000000..4108fdc --- /dev/null +++ b/compliance_test.go @@ -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{}) diff --git a/doc.go b/doc.go new file mode 100644 index 0000000..56907b7 --- /dev/null +++ b/doc.go @@ -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 diff --git a/docs/adr/ADR-001-chi-over-echo.md b/docs/adr/ADR-001-chi-over-echo.md new file mode 100644 index 0000000..c40a632 --- /dev/null +++ b/docs/adr/ADR-001-chi-over-echo.md @@ -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). diff --git a/docs/adr/ADR-002-embedded-chi-router.md b/docs/adr/ADR-002-embedded-chi-router.md new file mode 100644 index 0000000..fb7252f --- /dev/null +++ b/docs/adr/ADR-002-embedded-chi-router.md @@ -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. diff --git a/docs/adr/ADR-003-no-default-middleware.md b/docs/adr/ADR-003-no-default-middleware.md new file mode 100644 index 0000000..f84bd2d --- /dev/null +++ b/docs/adr/ADR-003-no-default-middleware.md @@ -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. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..b6854b0 --- /dev/null +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..2b986f8 --- /dev/null +++ b/go.sum @@ -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= diff --git a/httpserver.go b/httpserver.go new file mode 100644 index 0000000..af901af --- /dev/null +++ b/httpserver.go @@ -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) +} diff --git a/httpserver_test.go b/httpserver_test.go new file mode 100644 index 0000000..2c941d1 --- /dev/null +++ b/httpserver_test.go @@ -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) +}