Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions .claude/skills/verify/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
---
name: verify
description: Use when the user runs /verify or asks to run make verify. Runs the full verification suite (tests, lint, coverage, benchmarks, license) and fixes every issue found.
---

# verify

Run `make verify` and fix all issues until it passes clean.

`make verify` runs in order: `test` → `ui-test` → `license-check` → `lint` → `benchmark` → `coverage`

## How to run

```bash
make verify
```

Read ALL output carefully. Don't stop at the first failure — run through to the end to collect all issues, then fix them together.

## Fixing issues — The Iron Law

**Fix the code. Never silence the tool.**

| Forbidden | Why |
|-----------|-----|
| Adding `//nolint:...` directives | Hides the problem, ships broken code |
| Removing or skipping tests | Destroys the safety net |
| Lowering the coverage threshold | Treats the symptom |
| Commenting out failing assertions | Same as deleting the test |
| `//nolint` without a real reason | `nolintlint` requires specific linter + explanation anyway |

The only valid `//nolint` is when the linter is provably wrong for that exact line and you include a clear explanation. This should be rare.

## Linter quick reference

Config: `.golangci.yaml` — standard linters + `nolintlint`, `gocyclo` (≥20), `nestif` (≥5), `gosec`, `dupl`

| Linter | Common fix |
|--------|-----------|
| `errcheck` | Handle or explicitly discard the error: `_ = f()` only if truly safe |
| `staticcheck` | Follow the message — usually dead code, deprecated API, or impossible condition |
| `unused` | Delete the unused symbol, don't keep it for "future use" |
| `govet` | Fix the suspicious construct (printf verbs, mutex copies, etc.) |
| `ineffassign` | Remove the assignment or actually use the value |
| `gocyclo` / `nestif` | Refactor: extract helper functions, invert conditions, reduce nesting |
| `gosec` | Fix the security issue (weak random, unhandled error on Close, etc.) |
| `dupl` | Extract the duplicated block into a shared function |
| `nolintlint` | Remove invalid nolint or add specific linter name + explanation |

## Coverage

Threshold: **70%** for `./internal/...` and `./libs/...`

If coverage drops below 70%: write the missing tests. Do not lower the threshold.

## Step-by-step

1. Run `make verify`, capture full output
2. Group failures by type (test failures, lint issues, coverage gaps)
3. Fix all test failures first (they may affect coverage numbers)
4. Fix all lint issues by refactoring code
5. Add missing tests if coverage is below threshold
6. Run `make verify` again — repeat until it passes with zero errors
23 changes: 23 additions & 0 deletions .github/workflows/golangci-lint.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
name: golangci-lint
on:
push:
branches: [main]
pull_request:

permissions:
contents: read

jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-go@v5
with:
go-version-file: go.mod

- name: golangci-lint
uses: golangci/golangci-lint-action@v7
with:
version: v2.1
22 changes: 22 additions & 0 deletions .github/workflows/license-check.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
name: license-check
on:
push:
branches: [main]
pull_request:

jobs:
license-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Setup Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod

- name: Install go-licence-detector
run: go install go.elastic.co/go-licence-detector@v0.10.0

- name: License check
run: make license-check
22 changes: 22 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
name: test
on:
push:
branches: [main]
pull_request:

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Setup Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod

- name: Run tests
run: make test

- name: Check coverage
run: make coverage
34 changes: 34 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Commands

```bash
make test # run all tests with coverage
make lint # run golangci-lint (must be installed externally)
make benchmark # run benchmarks
make verify # run tests + lint + benchmarks + coverage check
go test ./... # run all tests
go test ./middleware/ # run tests for a single package
go test ./middleware/ -run TestMiddleware # run a single test
```

## Architecture

This is a Go library (`github.com/go-bumbu/http`) providing reusable HTTP components for backend services. It is not an application — it's imported by other projects.

### Packages

- **middleware/** — Composable middleware chain using standard `func(next http.Handler) http.Handler` pattern. Includes: structured logging (slog), Prometheus metrics, JSON error wrapping, generic error messages, and development delay.
- **handlers/spa/** — Single Page Application handler serving files from an `fs.FS` (typically embedded).
- **lib/limitio/** — Internal IO utilities: bounded buffer (2000 byte cap) and limited writer.

### Key Design Decisions

- **StatWriter** (`middleware/respwriter.go`) wraps `http.ResponseWriter` to intercept status codes and error bodies. The `teeOnErr` flag simultaneously buffers the body for logging while forwarding it to the client — this prevents reverse-proxy hangs when the upstream writes an error body.
- **Error classification**: `IsStatusError()` (< 200 or >= 400) vs `IsServerErr()` (>= 500) drives log levels — server errors log at ERROR, client errors at INFO.

## Linting

Uses golangci-lint v2 with: nolintlint, gocyclo (max 20), nestif (max 5), gosec, dupl. All `//nolint` directives require an explanation and specific linter name.
23 changes: 22 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,29 @@ license-check: ## check for invalid licenses
# depends on : https://github.com/elastic/go-licence-detector
@go list -m -mod=readonly -json all | go-licence-detector -includeIndirect -validate -rules allowedLicenses.json


COVERAGE_THRESHOLD ?= 70
.PHONY: coverage
coverage:
@fail=0; \
for pkg in $$(go list ./lib/... ./middleware/... ./handlers/...); do \
go test -coverprofile=coverage.out -covermode=atomic $$pkg > /dev/null 2>&1; \
if [ -f coverage.out ]; then \
coverage=$$(go tool cover -func=coverage.out | grep total: | awk '{print $$3}' | sed 's/%//'); \
if [ $$(echo "$$coverage < $(COVERAGE_THRESHOLD)" | bc -l) -eq 1 ]; then \
echo "❌ Coverage in $$pkg is below $(COVERAGE_THRESHOLD)%: $${coverage}%"; \
fail=1; \
fi; \
rm -f coverage.out; \
else \
echo "⚠️ No coverage data for $$pkg"; \
fail=1; \
fi; \
done; \
exit $$fail

.PHONY: verify
verify: test license-check lint benchmark ## run all tests
verify: test license-check lint benchmark coverage ## run all tests

cover-report: ## generate a coverage report
go test -covermode=count -coverpkg=./... -coverprofile cover.out ./...
Expand Down
68 changes: 49 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,33 +1,63 @@
# Http

The Http module contains a set uf useful http packages that can be used in any Http backend project.
Reusable HTTP packages for Go backend services.

## Import
## Install

Import the module
```
go get github.com/go-bumbu/http
```

## Server
## Packages

The server package contains boilerplate to create an http server.
Features:
* you can specify a main handler and an optional observability handler
* the observability handler is intended to expose details like metrics, runtime controls or hprof endpoint
* the server can safely shut down with os kill signals
* it exposes a Stop() method to safely shut down both servers.
### middleware

Composable HTTP middleware using the standard `func(next http.Handler) http.Handler` pattern.
Middleware can be used individually or combined via the `Middleware` struct which orchestrates all features in a single handler.

**Standalone middleware:**

## Middleware
| Middleware | Import | Description |
|---|---|---|
| `Logging` | `middleware.Logging(logger)` | Structured request logging via `log/slog`. Logs at INFO for client errors, ERROR for server errors. Captures error response bodies. |
| `Metrics` | `middleware.Metrics(hist)` | Prometheus histogram recording request duration, method, path, status code, and error flag. |
| `JSONErrors` | `middleware.JSONErrors(generic)` | Intercepts error responses (>= 400) and wraps the body in `{"error":"...","code":N}`. Optionally replaces messages with generic status text. |
| `GenericErrors` | `middleware.GenericErrors()` | Replaces error response bodies with the standard status text (e.g. "Internal Server Error"). |
| `PanicRecover` | `middleware.PanicRecover(logger)` | Recovers from panics, logs a stack trace, and returns 500 to the client. |
| `ReqDelay` | `middleware.ReqDelay{...}.Delay` | Adds a random delay between min/max duration. Useful during development to simulate slow backends. |

Middleware contains several middleware handlers to facilitate writing backends
* delay: useful during development for AJAX calls, adds delay to a response
* jsonErr: wraps all http errors into a json response, also allows to generalize errors
* setting the flag _genericMessage_ to true, will not return the error string but the generic error string matching the response code instead
* prometheus: adds an _http_duration_seconds_ bucket to measure volume and duration of requests per response code
* zerolog: middleware that will write a log message to a zerolog logger capturing every request
**Combined middleware:**

## Handlers
* spa: simple Single page application handler to serve SPAs embedded into go code
```go
m := middleware.New(middleware.Cfg{
JsonErrors: true,
GenericErrs: true,
PanicRecover: true,
Logger: slog.Default(),
PromHisto: hist,
})
mux.Handle("/", m.Middleware(handler))
```

The combined `Middleware` struct runs logging, metrics, error wrapping, and panic recovery in a single pass.

### handlers/spa

Single Page Application handler that serves files from an `fs.FS` (typically `embed.FS`).
Requests for unknown paths fall back to `index.html`, allowing client-side routing.

```go
spaHandler, err := handlers.NewSpaHAndler(embeddedFS, "dist", "/ui")
```

Parameters:
- `inputFs` — the filesystem containing the SPA assets
- `fsSubDir` — subdirectory within the FS to serve from (empty string for root)
- `pathPrefix` — URL path prefix where the SPA is mounted

### lib/limitio

Internal IO utilities for bounded writes.

- **`LimitedBuf`** — A `bytes.Buffer` that stops accepting data after a configured byte limit (default 2000 in the middleware). Returns `ErrBufferLimit` when the cap is reached. Used to safely buffer error response bodies for logging without unbounded memory growth.
- **`LimitWriter`** — Wraps any `io.Writer` and caps total bytes written, returning `io.EOF` at the limit.
23 changes: 20 additions & 3 deletions lib/limitio/limitbuf.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,22 +9,39 @@ import (
// it implements the io.ReadWriter interface
type LimitedBuf struct {
bytes.Buffer
MaxBytes int
curByte int
MaxBytes int
curByte int
truncated bool
}

func (b *LimitedBuf) Reset() {
b.Buffer.Reset()
b.curByte = 0
b.truncated = false
}

func (b *LimitedBuf) Write(p []byte) (n int, err error) {
if len(p)+b.curByte > b.MaxBytes {
remaining := b.MaxBytes - b.curByte
if remaining <= 0 {
return 0, ErrBufferLimit
}
if len(p) > remaining {
p = p[:remaining]
n, err = b.Buffer.Write(p)
b.curByte += n
b.truncated = true
if err != nil {
return n, err
}
return n, ErrBufferLimit
}
n, err = b.Buffer.Write(p)
b.curByte += n
return n, err
}

func (b *LimitedBuf) Truncated() bool {
return b.truncated
}

var ErrBufferLimit = fmt.Errorf("buffer write limit reached")
Loading
Loading