go-chi

Stars and mentions

Last 30 days, Oct 5 – Nov 3

GitHub stars0
mentions7
01234Oct 5Oct 19Nov 3
UTC days · repositories go-chi owns0 repositories

Package downloads

No package download history available yet.

Top repositories

go-chi’s repositories ranked by new stars, as on trending.

Loading

Contributes to

Repositories outside go-chi’s account where go-chi opened or reviewed pull requests in the past 365 days.

Who noticed go-chi

Everyone who starred or forked go-chi’s repositories since the archive began in 2011.

Notable accounts

Ordered by the stars their own repositories have received.

Biggest fans

Accounts that starred the most of these repositories.

Breakout weeks

Each repository’s best week of new stars, with the release published around it.

Mentions

7posts
Nov 40 posts
  1. My fix checker found two missing tests. Then it wrote them. --- title: My fix checker found two missing tests. Then it wrote them. tags: testing, ai, opensource, python published: true --- In my last post, Bite checked some well-known projects and found a few small gaps: changes that no test would notice if they broke. Two of them: - In Rich, Markdown <kbd>Ctrl</kbd> is drawn in bold yellow, and no test checks it. - In chi, a Go web router, a change made XML responses get compressed by default. The commit did add a test, but that test counts the list against itself, so it still passes with the change taken out. Finding a gap is the easy half. Someone still has to write the test. So this week Bite got a new option, --write-tests, that tries to write it. How it works For each change no test noticed, a fresh AI worker writes one small test on a copy of the project. Then Bite doesn't take its word for it. It runs the test itself: 1. With the change in, the test must pass, twice. 2. With the change taken out, the test must fail, and fail for a real reason (not just a crash on loading). If either part goes wrong, Bite throws the test away and says so. It uses your own Anthropic API key, and --budget caps what a run spends. What happened | Gap | Bite's test | With the change | Without it | Cost | |---|---|---|---|---| | Rich <kbd> | prints <kbd>Ctrl</kbd>, checks Ctrl is bold yellow | passes, twice | fails: Ctrl not bold yellow | $0.14 | | chi XML | sends XML through chi's compressor, checks it comes back gzipped | passes, twice | fails: XML not gzipped | $0.10 | Total: $0.34. That's more than the two rows add up to, because of the next part. The first chi try got thrown away, and that was Bite's fault. Go runs tests by folder, not by file, and Bite asked Go to run one file alongside the whole project. Go refused before running anything. Bite's rule caught it: a test that fails before it even runs doesn't count. So it threw the test out instead of showing a fake result. The fix is in PR #67, and the second try worked. Did it write the right test? Before Bite could do this, Claude had already written a test for each gap the slow way, to confirm the gaps were real. So there was an answer key. - Rich: Bite's test checks the same thing as the answer key: Ctrl comes out bold bright yellow. - chi: the answer key (chi #1246, still open) adds XML to chi's existing list test. Bite's test goes further: it sends a real XML response through the compressor and checks it gets zipped. Different route, same claim. Here's the Rich one, exactly as Bite wrote it: # Guards that <kbd>…</kbd> in Markdown renders its text in the markdown.kbd style. from rich.console import Console from rich.markdown import Markdown def test_kbd_inline_html_is_styled(): console = Console( file=__import__("io").StringIO(), force_terminal=True, color_system="truecolor", width=40, ) console.print(Markdown("Press <kbd>Ctrl</kbd> now")) output = console.file.getvalue() assert "\x1b[1;93mCtrl\x1b[0m" in output Then we tried the whole loop On my own copy of Rich, Bite ran as a GitHub Action on a pull request. It found the <kbd> gap, wrote the test, and opened a small pull request with the test in it. I merged that pull request, and Bite's check on the original pull request turned green. Gap found, test written, test merged, without anyone typing a test. What I took from it - Don't trust a test you didn't watch fail. The pass-and-fail check is the whole point. An AI can write a test that looks right and checks nothing, just like the chi commit's own test did. - Throwing things away is a feature. The chi test that never ran would have looked like a catch. Bite saying "no" there is what makes its "yes" worth something. - Two gaps is a small trial. It worked twice, on two languages. That's a start, not proof it always works. It's in v0.7.0 (pipx upgrade bite-check). On your own computer: bite --write-tests. In GitHub Actions, the setup is in Bite's README. Since this trial, Bite also asks a second fresh check whether each test is fair before showing it. --- Disclosure: I'm not a coder. Claude ran these trials, wrote Bite's code and helped me draft this post. The numbers come from the run logs.

  2. @aptabeenhere Use plain net/http or chi😋 go-chi/chi

    5 likes · 497 views
  3. 🚨 New MEDIUM CVE detected in AWS Lambda 🚨 CVE-2026-114437 impacts github.com/go-chi/chi/v5 in 27 Lambda base images. Details: aws-lambda-base-images#767 More: lambdawatchdog.com #AWS #Lambda #CVE #CloudSecurity #Serverless

  4. npm と比べて分かった Go の依存管理の「一番の違い」── go get の中身を 6 ステップで追う 2026 年 9 月 30 日の Go Bash vol.3 で「npm との違いを手がかりに Go の依存管理を理解したい」というタイトルで登壇しました。この記事はその内容を、登壇では時間の都合で省いた実測ログも足して書き直したものです。 この記事で扱うこと 普段 Go を書いていると、go get を打てば依存が入り、go.sum も勝手に増えていきます。それで困ったこともありません。ただ、次の質問に即答できるでしょうか。 go.sum は package-lock.json と同じものですか? go get したモジュールの実体は、どこに置かれていますか? そのディレクトリを、別のプロジェクトが書き換えたらどうなりますか? 私は答えられませんでした。この記事では、go get が内部でやっていることを 6 つのステップに分けて追いかけ、上の問いに答えていきます。 確認した環境は次のとおりです。 対象 バージョン Go 1.26.8 依存に使うモジュール github.com/go-chi/chi/v5 v5.3.2 比較対象の Node.js / npm Node.js v26 系 / express 5.2.1 最初に、登壇の冒頭で会場に投げかけた問いを紹介します。 npm と Go の一番の違いは何だと思いますか? 答えは記事の最後で出します。少し考えてから読み進めてみてください。 同じサーバーを Go と npm で書いて比べる 「Hello」を返すだけの HTTP サーバーを、Go(chi)と Node.js(express)で書きました。やっていることは同じです。 main.go package main import ( "fmt" "log" "net/http" "github.com/go-chi/chi/v5" ) func main() { r := chi.NewRouter() r.Get("/", func(w http.ResponseWriter, req *http.Request) { fmt.Fprintln(w, "Hello, Go server!") }) log.Fatal(http.ListenAndServe(":8080", r)) } index.js const express = require("express"); const app = express(); app.get("/", (req, res) => { res.send("Hello, Node server!\n"); }); app.listen(8080); 依存の実体がどこにあるかを調べます。npm の場合は、プロジェクト直下の node_modules にあります。 $ npm ls express --parseable /path/to/ex-npm/node_modules/express $ ls -1 node_modules | wc -l 65 $ find node_modules -type f | wc -l 601 express を 1 つ指定しただけで、node_modules には 65 ディレクトリ・601 ファイル(3.8MB)が展開されていました。 一方、Go のプロジェクトを ls すると main.go・go.mod・go.sum の 3 つしかありません。chi を import してビルドできているのに、chi のソースはプロジェクトの中に無いのです。どこにあるのかを go コマンドに聞きます。 ホームディレクトリは ~ に置き換えています $ go env GOMODCACHE ~/go/pkg/mod $ go list -m -f '{{.Dir}}' github.com/go-chi/chi/v5 ~/go/pkg/mod/github.com/go-chi/chi/v5@v5.3.2 実体は、ホームディレクトリの下の モジュールキャッシュ(GOMODCACHE、既定は ~/go/pkg/mod)にありました。このキャッシュはマシンに 1 つで、どのプロジェクトからも同じ場所が読まれます。 Go プロジェクトA ─┐ ├─→ ~/go/pkg/mod/github.com/go-chi/chi/v5@v5.3.2 プロジェクトB ─┘ マシンに 1 つ npm プロジェクトA/node_modules/express ← プロジェクトごとに別のコピー プロジェクトB/node_modules/express 共有して、困らないのか? npm に慣れていると、ここで不安になります。1 つの実体を全プロジェクトで共有して、本当に大丈夫なのでしょうか。私が感じた疑問は 4 つです。 なぜ同じディレクトリに置いているのか? 別のプロジェクトが別のバージョン(たとえば v5.2.0)を使いたくなったら? 別のプロジェクトがキャッシュの中身を書き換えたら? モジュールの作者が v5.3.2 のタグを別のコミットに付け替えたら? この 4 つの答えは、どれも go get の処理の中にあります。順番に追っていく前に、用語を 2 つだけ揃えておきます。 Go の「モジュール」は npm の「パッケージ」にあたる 同じ「パッケージ」という言葉でも、Go と npm では指すものが逆です。 Go npm バージョンを付けて配布・取得される単位 module(go.mod があるもの) package(package.json があるもの) コードを読み込む単位 package(同じディレクトリにあり、まとめてコンパイルされる .go ファイル群) module(require / import で読み込めるファイルやディレクトリ) この記事で npm の package と比べるのは、Go の モジュール のほうです。 package.json は範囲、go.mod は最低バージョン バージョンの書き方も違います。 package.json { "dependencies": { "express": "^5.2.1" } } go.mod module github.com/shirokuma/conference-report/GoBash/ex-Go go 1.26.8 require github.com/go-chi/chi/v5 v5.3.2 ^5.2.1 は「5.2.1 以上 6.0.0 未満」という 範囲 です。範囲のうちどのバージョンが入るかはインストールした時点の最新版で変わるので、npm は選んだ結果を package-lock.json に記録します。範囲の宣言と結果の記録、2 つのファイルで 1 組です。 go.mod の require には範囲を書きません。公式リファレンスでは、require は依存モジュールの 最低バージョン を宣言するものだと定義されています。 A require directive declares a minimum required version of a given module dependency. ここで次の疑問が湧きます。範囲を書かないなら、選んだ結果を記録する必要もなさそうです。では、go.sum は何を記録しているのでしょうか。 冒頭の「go.sum は lock ファイルか」という問いも、ここにつながります。 go get の出力に残る 3 つの謎 モジュールキャッシュが空の状態で go get を 1 回打つと、出力はこれだけです。 $ go get github.com/go-chi/chi/v5 go: downloading github.com/go-chi/chi/v5 v5.3.2 go: downloading github.com/go-chi/chi v1.5.5 # ① 頼んだのは 1 つなのに 2 つ取得している go: added github.com/go-chi/chi/v5 v5.3.2 # ② v5.3.2 はどう決まった? $ cat go.sum github.com/go-chi/chi/v5 v5.3.2 h1:5YQkICvTCSZ25hoRsyJazN0scjzKGiu4VAUc7H1o1nY= github.com/go-chi/chi/v5 v5.3.2/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= ③ なぜ 2 行? h1: とは何? 短い出力ですが、1 行ずつ読むと分からないところが 3 つあります。特に ① は、/v5 を頼んだのに v1 系の chi v1.5.5 まで落としてきているので不思議です。 go get は内部で、だいたい次の 6 ステップを踏んでいます(Go Modules Reference)。 必要なモジュールとバージョンを決める モジュールプロキシ(または Git などの VCS)からモジュールを取得する モジュールキャッシュに保存する 暗号学的ハッシュを計算する go.sum または checksum database(sum.golang.org)と照合する go.sum に記録する このステップを 1 つずつ追うと、①〜③ の謎と、さきほどの 4 つの疑問が順に解けていきます。 ! go: downloading の行はキャッシュが空のときしか出ません。手元のキャッシュを汚さずに再現するには、使い捨てのディレクトリを GOMODCACHE に指定します。キャッシュは読み取り専用で作られるため、後で消せるように -modcacherw も付けておきます。 GOMODCACHE=$(mktemp -d) GOFLAGS=-modcacherw go get -x github.com/go-chi/chi/v5 STEP1 必要なモジュールとバージョンを決める import path のどこまでがモジュールなのか import "github.com/go-chi/chi/v5" と書いても、go コマンドには「どこまでがモジュールパスで、どこからがモジュール内のディレクトリか」が分かりません。 github.com/go-chi/chi/v5 ← この名前のモジュール? github.com/go-chi/chi ← このモジュールの中の v5/ ディレクトリ? そこで go コマンドは 2 段階で探します(Resolving a package to a module)。 まず手元の ビルドリスト(ビルドに使うモジュールとバージョンの一覧。go list -m all で見られます)から、モジュールパスが import path の接頭辞になっているものを探します。go.mod に require があればここで決まり、ネットワークは使いません。 $ GOPROXY=off go list -f '{{.ImportPath}} → {{.Module.Path}} {{.Module.Version}}' \ github.com/go-chi/chi/v5 github.com/go-chi/chi/v5/middleware github.com/go-chi/chi/v5 → github.com/go-chi/chi/v5 v5.3.2 github.com/go-chi/chi/v5/middleware → github.com/go-chi/chi/v5 v5.3.2 初回の go get ではビルドリストに chi が無いので、次の段階に進みます。import path の末尾から 1 要素ずつ削ったパスをすべてモジュールパスの候補にして、モジュールプロキシに「最新バージョンは?」と並列に問い合わせるのです。-x を付けると、その様子が見えます。 $ go get -x github.com/go-chi/chi/v5 2>&1 | grep -E '/@v/list: |go: downloading' get proxy.golang.org: 200 OK get proxy.golang.org: 200 OK get proxy.golang.org: 404 Not Found get proxy.golang.org: 404 Not Found go: downloading github.com/go-chi/chi/v5 v5.3.2 go: downloading github.com/go-chi/chi v1.5.5 200 が返ったのは chi/v5 と chi の 2 つです。公式リファレンスによると、go コマンドは応答があったモジュールの最新バージョンを取得し、要求されたパッケージを含むかどうかを確かめます。含むモジュールが複数あれば、パスが最も長いものを使います。 ① の謎はこれで解けます。 chi v1.5.5 は、「chi モジュールの中に v5/ ディレクトリがあるのでは?」という候補を確かめるために取ってきたものでした。 MVS でバージョンを決める モジュールが決まったら、次はバージョンです。Go は MVS(Minimal Version Selection) という方法で決めます(Minimal version selection (MVS))。 MVS はメインモジュールから各モジュールの go.mod の require をたどり、モジュールごとに「要求された中で最も高いバージョン」を記録します。たどり終えた時点の記録がビルドリストです。 メインモジュール ─require→ 依存 A ── chi v5.0.0 以上が必要 └require→ 依存 B ── chi v5.2.0 以上が必要 ↓ MVS ビルドリスト: chi v5.2.0 「最も高い」なのに「Minimal」と呼ぶのは、これが すべての要求を満たす最小のバージョン だからです。上の例で v5.3.2 が公開されていても、誰も要求していなければ選びません。 この決め方の材料は、各モジュールの go.mod だけです。新しいバージョンが公開されても結果は変わりません。公式リファレンスはこう書いています。 MVS is deterministic, and the build list doesn't change when new versions of dependencies are released. (中略) Unlike other dependency management systems, the build list is not saved in a "lock" file. npm は範囲の中から「いま一番新しいもの」を選ぶため、実行する時期によって結果が変わります。lock ファイルが必要なのはこのためです。Go は go.mod に書いた最低バージョンとほかのモジュールの go.mod から、毎回同じ結果を計算できます。だから結果を別ファイルに残す必要がありません。 STEP2 モジュールプロキシから取得する 取得先は GOPROXY で決まります。既定値はこうです。 $ go env GOPROXY proxy.golang.org,direct proxy.golang.org が モジュールプロキシ です。Go チームが運営する、公開モジュールのキャッシュサーバーです。, はフォールバックの区切りで、プロキシが 404 / 410 を返したら次の direct(プロキシを使わず Git などから直接取る)に進みます。社内リポジトリのように GOPRIVATE に一致するモジュールも direct で取得されます。 モジュールプロキシは、npm の registry とは配布物の集め方が違います。npm では作者が npm publish で registry にアップロードします。Go のモジュールプロキシは、初めて要求されたバージョンを自分で元リポジトリから取ってきて zip にします。いったんキャッシュされると、作者が元リポジトリでソースを消しても取得できます(proxy.golang.org の FAQ)。 空のキャッシュで go get -x した出力から、chi/v5 の取得に関わる行だけを抜き出すとこうなります(URL 以外の情報は省略しています)。 get proxy.golang.org ← バージョン一覧 get proxy.golang.org ← メタデータ get proxy.golang.org ← go.mod 単体 get sum.golang.org ← 照合用のハッシュ(STEP5) go: downloading github.com/go-chi/chi/v5 v5.3.2 get proxy.golang.org ← モジュール zip ファイル go: added github.com/go-chi/chi/v5 v5.3.2 バージョンを指定しない go get は、@v/list で取った一覧からプレリリースを除き、SemVer で最も大きいバージョンを選びます。 $ curl -s proxy.golang.org | sort -V | tail -3 v5.3.0 v5.3.1 v5.3.2 ② の謎はここで解けます。 v5.3.2 は「その時点の最新リリース」として選ばれ、go.mod の require に書かれました。added は「go.mod に書いた」という意味です。いったん書かれたあとは、STEP1 で見た MVS がこの最低バージョンを材料にビルドリストを決めます。 .mod と .zip が別々に取得されている点も覚えておいてください。MVS は依存モジュールの go.mod しか読みません。そのため go コマンドは、ソース全体を落とさずに go.mod だけを先に取得できます。これが ③ の伏線になります。 STEP3 モジュールキャッシュに保存する 取ってきたものは GOMODCACHE の下に、役割の違う 2 つの層に分けて置かれます。 ~/go/pkg/mod/ ├── cache/download/github.com/go-chi/chi/v5/@v/ ← 層1: 取得したものをそのまま置く │ ├── list │ ├── v5.3.2.info ← バージョンと取得元(git のコミット) │ ├── v5.3.2.mod ← go.mod 単体 │ ├── v5.3.2.zip ← モジュール zip ファイル │ ├── v5.3.2.ziphash ← zip のハッシュ(STEP5 で使う) │ └── v5.3.2.lock │ └── github.com/go-chi/chi/v5@v5.3.2/ ← 層2: zip を展開したソース(ビルドが読む) ├── chi.go ├── mux.go └── ... go build や go test が読むのは層2です。層2のディレクトリ名は <モジュールパス>@<バージョン> の形です。展開した時点で、中のディレクトリとファイルは 読み取り専用 になります。 $ ls -ld ~/go/pkg/mod/github.com/go-chi/chi/v5@v5.3.2 dr-xr-xr-x@ 24 ... github.com/go-chi/chi/v5@v5.3.2 go get の出力にあった downloading の 2 行は、どちらもこのキャッシュに置かれています。chi v1.5.5 も github.com/go-chi/chi@v1.5.5/ として同じ場所に残っています。 .info を開くと、プロキシがどこから作った zip なのかが分かります。 {"Version":"v5.3.2","Time":"2026-08-20T09:37:52Z", "Origin":{"VCS":"git","URL":"go-chi/chi", "Hash":"38939062c5df4d3e8814aad1a488983112627ced","Ref":"refs/tags/v5.3.2"}} 「git の refs/tags/v5.3.2、コミット 3893906 から作った」と記録されています。疑問 4(タグの付け替え)を考えるときに、この記録が効いてきます。 STEP4 暗号学的ハッシュを計算する go.sum に並ぶ h1: は、SHA-256 を使うハッシュ方式「Hash1」の識別子です。対象はモジュール zip ファイルですが、zip のバイト列をそのままハッシュしているわけではありません。手順は次の 4 段です。 zip 内の全ファイルについて SHA-256 を計算する "<SHA-256 の hex> <ファイル名>\n" という行を作る(スペースは 2 個) ファイル名でソートして全行を連結する(これが「リスト」) リスト全体の SHA-256 を取り、その 32 バイトを base64 にして h1: を付ける 本当にそうなっているのか、シェルで再現してみます。 $ cd $(mktemp -d) $ unzip -q ~/go/pkg/mod/cache/download/github.com/go-chi/chi/v5/@v/v5.3.2.zip $ find github.com -type f | LC_ALL=C sort | # 3. ファイル名でソート xargs shasum -a 256 | # 1〜3. 「<hex> <ファイル名>」の行を順に出力 shasum -a 256 | cut -d' ' -f1 | # 4. リスト全体の SHA-256 xxd -r -p | base64 # 4. hex を 32 バイトに戻して base64 5YQkICvTCSZ25hoRsyJazN0scjzKGiu4VAUc7H1o1nY= 先頭に h1: を付けると、go.sum の 1 行目と一致しました。shasum の出力形式が「hex + スペース 2 個 + ファイル名」なので、ソートしたファイル名の順に並べるだけでリストになります。LC_ALL=C を付けているのは、Go と同じバイト順でソートするためです。 ファイル名と中身だけから計算するので、zip の圧縮レベルやタイムスタンプ、エントリの順序には左右されません。誰が zip を組み立てても、中身が同じなら同じ値になります。 2 行目の /go.mod 行も同じ Hash1 です。対象は v5.3.2.mod の 1 ファイルだけで、リストに書くファイル名は go.mod です。 $ D=~/go/pkg/mod/cache/download/github.com/go-chi/chi/v5/@v $ printf '%s go.mod\n' "$(shasum -a 256 $D/v5.3.2.mod | cut -d' ' -f1)" | shasum -a 256 | cut -d' ' -f1 | xxd -r -p | base64 R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= Go のコードから計算するなら、golang.org/x/mod/sumdb/dirhash の dirhash.HashZip(path, dirhash.Hash1) で同じ値が得られます。 STEP5 go.sum または checksum database と照合する 計算したハッシュを何と比べるかは、go.sum に行があるかどうかで変わります。 go.sum に行がある場合 go.sum の値が正解です。手元で計算したハッシュと一致すれば、checksum database には問い合わせずに検証を終えます。一致しなければ、取得したものを捨ててエラーで止まります。 SECURITY ERROR This download does NOT match an earlier download recorded in go.sum. go.sum に行が無い場合 checksum database(既定は sum.golang.org)に正解のハッシュを問い合わせます。STEP2 の -x 出力にあった lookup がこれです。 $ curl sum.golang.org 60295460 # ← 台帳の何番目の記録か github.com/go-chi/chi/v5 v5.3.2 h1:5YQkICvTCSZ25hoRsyJazN0scjzKGiu4VAUc7H1o1nY= github.com/go-chi/chi/v5 v5.3.2/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= go.sum database tree # ← ここから下は署名付きのツリーヘッド 63096908 8lHrQPRYe4KT4WQ9/Qk8/taKnD6IRHbFgfN7vQE2Y54= — sum.golang.org Az3gri/oVBN1jAhNZ/iS+W31psFAWRT20/GyrhpU6taFwulQGetBkH5S7wpGXdS935jiIGiUUAXBVHzT/P6fYhqAmAk= STEP4 で計算した値と同じハッシュが返ってきています。checksum database は追記専用の台帳(Merkle tree を使った透明性ログ)で、一度記録したハッシュを後から書き換えられません。 最終行は sum.golang.org の電子署名(Ed25519)です。この署名を確かめる公開鍵は、go コマンドのソースに定数として書かれています。 src/cmd/go/internal/modfetch/key.go var knownGOSUMDB = map[string]string{ "sum.golang.org": "sum.golang.org+033de0ae+Ac4zctda0e5eza+HJyk9SxEdh+s3Ux18htTTAD8OuAn8", } 定数なので、ビルドすると go の実行ファイルにそのまま入ります。 $ strings $(which go) | grep -o 'sum.golang.org+033de0ae+.{44}' sum.golang.org+033de0ae+Ac4zctda0e5eza+HJyk9SxEdh+s3Ux18htTTAD8OuAn8 途中のプロキシは秘密鍵を持っていないので、応答を書き換えると署名が合わなくなります。検証用の鍵は手元の go コマンドの中にあるので、鍵ごと差し替えることもできません。 2 回目以降 キャッシュ済みのモジュールを使うたびに、go コマンドはメインモジュールの go.sum と照合しています。毎回 zip を再計算すると重いので、STEP3 で保存した .ziphash(計算済みのハッシュ)と go.sum を比べます。 To avoid the need to trust other modules, the go command verifies hashes using the main module's go.sum whenever it accesses a file in the module cache. Zip file hashes are expensive to compute, so the go command checks pre-computed hashes stored alongside zip files instead of re-hashing the files. — Authenticating modules "To avoid the need to trust other modules" という一節を覚えておいてください。最後に「一番の違い」を考えるときに使います。 STEP6 go.sum に記録する 検証を通ったハッシュが go.sum に追記されます。行数にはルールがあります(go.sum files)。 ビルドにソースが必要なモジュールは、zip の h1: と /go.mod の h1: の 2 行 モジュールグラフをたどるためだけに使うモジュールは、/go.mod の 1 行 ③ の謎の答えです。 2 行あるのは、STEP2 で見たとおり zip と go.mod を別々に取得し、別々に検証しているからです。MVS は go.mod しか読まないので、ソースが要らないモジュールについては go.mod の行だけで足ります。 ここで改めて go.sum を見てください。 github.com/go-chi/chi/v5 v5.3.2 h1:5YQkICvTCSZ25hoRsyJazN0scjzKGiu4VAUc7H1o1nY= github.com/go-chi/chi/v5 v5.3.2/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= STEP1 で取得した github.com/go-chi/chi v1.5.5 は、zip の取得・展開も checksum database での検証も済んでいます。それなのに go.sum には 1 行もありません。import path を解決するための「探り」で取っただけで、最終的なモジュールグラフには入っていないからです。 キャッシュには「見に行ったもの全部」が残り、go.sum には「ビルドに関わるものだけ」が載ります。この非対称が、最後の答えにつながります。 go.sum は lock ファイルなのか 6 ステップを見終えたので、途中で保留した問いに戻ります。go.sum を消したらどうなるかを試しました。 $ mv go.sum /tmp/go.sum.bak $ go build ./... main.go:8:2: missing go.sum entry for module providing package github.com/go-chi/chi/v5 (imported by github.com/shirokuma/conference-report/GoBash/ex-Go); to add: go get github.com/shirokuma/conference-report/GoBash/ex-Go ビルドは止まりました。Go 1.16 以降は -mod=readonly が既定なので、go build が go.sum を勝手に作り直すことはありません。go mod tidy で作り直して、元のファイルと比べます。 $ go mod tidy $ diff /tmp/go.sum.bak go.sum $ # 差分なし 1 バイトも変わりませんでした。go.sum が無いとビルドは止まりますが、作り直しても中身は同じです。つまり go.sum はバージョンの決定に関わっていません。バージョンを決めているのは go.mod と MVS です。 ファイル 役割 go.mod どのモジュールのどのバージョン以上が要るかの 宣言 go.sum 取ってきた中身が本物であることを確かめる 証明 の台帳 go.sum は lock ファイルではなく、検証のための台帳でした。 4 つの疑問の答え合わせ 最初に挙げた 4 つの疑問に、ここまでのステップで答えます。 疑問 答え 根拠 1. なぜ同じディレクトリに置いているのか? 同じ <モジュール>@<バージョン> なら、中身の h1: ハッシュは 1 つに決まる。1 つ置けばどのプロジェクトも同じものを使える STEP4・5 2. 別のプロジェクトが別のバージョンを使いたくなったら? ディレクトリ名にバージョンが入るので、別ディレクトリとして共存する。どのバージョンを使うかは各プロジェクトの go.mod が決める STEP1・3 3. 別のプロジェクトが中身を書き換えたら? 展開済みのソースは読み取り専用なので、そもそも書き込めない STEP3 4. 作者がタグを別のコミットに付け替えたら? プロキシにキャッシュ済みなら、.info に記録された元のコミットから作った zip がそのまま配られる。別経路で付け替え後の中身を取っても、go.sum や checksum database に残る最初のハッシュと合わずエラーになる STEP2・3・5 疑問 3 は実際に試せます。 $ M=~/go/pkg/mod/github.com/go-chi/chi/v5@v5.3.2 $ echo "// hack" >> $M/mux.go zsh: permission denied: .../chi/v5@v5.3.2/mux.go chmod で権限を外して無理やり書き換えた場合は、go mod verify で見つけられます。go mod verify はキャッシュの zip と展開済みディレクトリのハッシュを計算し直し、ダウンロード時に記録した .ziphash と比べるコマンドです。合わなければ dir has been modified などのエラーを出します。 ! ビルドのたびに展開済みディレクトリの全ファイルを計算し直しているわけではありません。STEP5 で見たとおり、普段の照合は計算済みの .ziphash と go.sum の比較です。手でキャッシュを触ってしまったときは go mod verify を実行してください。go mod verify の照合相手は、Go 1.27.0 の cmd/go/internal/modcmd/verify.go で確認しました。 おまけの疑問にも 1 つ答えておきます。途中で checksum database の応答まで偽のハッシュに書き換えられた場合は、STEP5 で見た署名の検証で止まります。 最初の問いに戻る:npm と Go の一番の違い 冒頭の問いに戻ります。npm と Go の一番の違いは何でしょうか。 表にすると、違いはいくつも挙がります。 観点 Go npm 依存の置き場所 GOMODCACHE(マシンに 1 つ) プロジェクトごとの node_modules 同一バージョンの重複 無い プロジェクトの数だけコピーされる バージョンの宣言 go.mod に最低バージョンを 1 つ package.json に範囲、lock に結果 依存の書き込み 読み取り専用 書き換えられる git 管理するもの go.mod と go.sum package.json と package-lock.json 登壇の準備を始めたとき、私は一番の違いを「依存の置き場所」だと考えていました。node_modules が無いのは、Go を触ると最初に気付く違いだからです。 ただ、6 ステップを追った今は少し違う答えを出せます。npm も ~/.npm/_cacache に共有キャッシュを持っているので、実体を共有すること自体は Go だけの特徴ではありません。違いは、共有しても安全だと言える仕組みが仕様で決まっている点にあります。 バージョンの中身は変わらない(同じバージョンなら h1: は 1 つに決まり、checksum database がそれを記録し続ける) 使うバージョンは各プロジェクトの go.mod だけで決まる(MVS) キャッシュを読むたびに、各プロジェクトの go.sum と照合する STEP5 で引用した一節 "To avoid the need to trust other modules" が言っているのはこのことです。キャッシュには、ほかのプロジェクトが取ってきたものも探りで取っただけのものも入っています。それでも各プロジェクトは自分の go.sum で照合するので、ほかのプロジェクトを信用する必要がありません。 Go が共有しているのは依存の「実体」だけで、「信頼」は共有していません。信頼の単位は、リポジトリごとに git で管理している go.sum です。 これが、私の考える「一番の違い」です。 裏を返すと、go.sum 自体を書き換えられたら、この仕組みでは防げません。go.sum に行があれば checksum database には問い合わせないので、偽のハッシュを書いた go.sum がリポジトリに入れば、go コマンドはそれを正解として扱います。go.sum の差分は、自動生成のファイルだからと読み飛ばさず、コードと同じようにレビューしてください。 調べきれなかったこと 登壇までに調べきれず、宿題として残っているものです。 最後は Google(sum.golang.org の運営者)を信用するしかないのか Go 1.27.0 で依存管理がどう変わったのか dep(Gopkg.lock)の時代の依存管理と、なぜ lock ファイルをやめたのか 標準パッケージが go.mod と go.sum に記録されない理由(どうやってインストールし、どうやって検証しているのか) 1 つ目については手がかりだけ見つけています。透明性ログには、運営者が特定の人にだけ違うハッシュを返すと、台帳の枝分かれとして検出できる仕組みがあります。ただ、それで運営者への信用をどこまで減らせているのかは、まだ整理できていません。 分かったら続きの記事を書く予定です。詳しい方がいたら、ぜひコメントで教えてください。 まとめ go get は「決める → 取得する → キャッシュに置く → ハッシュを計算する → 照合する → 記録する」の 6 ステップで動いている go.mod は最低バージョンの宣言で、MVS により同じ結果が毎回計算できるので lock ファイルが要らない go.sum は lock ファイルではなく、取ってきた中身が本物かを確かめる台帳。依存の実体は全プロジェクトで共有し、信頼は各リポジトリの go.sum に置いている 次のコマンドを一度打ってみると、ここで追った 6 ステップがそのまま流れていくのが見えます。 GOMODCACHE=$(mktemp -d) GOFLAGS=-modcacherw go get -x <モジュール> 普段使っているモジュールで試してみてください。 参考 Go Modules Reference Resolving a package to a module Minimal version selection (MVS) Module cache Authenticating modules go.sum files Managing dependencies proxy.golang.org About packages and modules | npm Docs

    12 likes
  5. Building MarginCMS, part 3: Tools out of go.mod, a strict linter and a validated edge Part 2 ended with two next steps: a linter, then auth with mock data. Auth moves to part 4. Adding the linter turned into a dependency hygiene problem first, and fixing that properly took a step of its own. So this part covers three things: every Go tool in its own module, a strict golangci-lint config and what it caught, and request validation against the OpenAPI spec, so a bad request gets a 422 with field errors before any handler runs and every authenticated endpoint is closed until real auth exists. Why the tools had to move In part 2 I pinned oapi-codegen and Air with tool directives in api/go.mod and called it done. The trade-off I accepted there was a longer go.mod. The real cost is bigger than that. golangci-lint's install docs advise against installing it with go tool or go install, and their reasons apply to every tool: - A tool's dependencies can modify the dependencies of another tool or of your project. - The binary is built locally with your Go version, not the one the release was tested with. - golangci-lint's own replace directives don't apply when it's a dependency of your module. Their fallback for people who still want go tool is a dedicated module or modfile for the linter. The mechanism behind the first point is minimal version selection. A tool directive puts the tool's whole dependency graph into the app's module graph, and MVS picks one version per module across that graph, so the tool and the app can move each other's dependencies. My own api/go.mod already showed the problem without the linter: - 42 // indirect lines, almost all of them from the two tools. - go mod why -m github.com/gohugoio/hugo traced a path from Air through air/runner to hugo/watcher/filenotify. My API's module graph contained Hugo. - make update-deps ran go get -u tool: upgrade every tool and all of their dependencies inside the API's graph. That's exactly the case the docs warn about. Step 1: Every tool in its own module Commit: tooling: move each Go tool into its own module I originally planned to use alternate modfiles next to the API's go.mod, like tools/<name>.mod, selected with -modfile. It took three attempts to land somewhere else. Attempt 1: alternate modfiles, run from api/ go mod tidy -modfile=tools/air.mod run from api/ still treats api/ as the module root, so it scans the API's packages. The result was chi, cors, envconfig and testify copied into Air's modfile, at freshly resolved versions that differed from the API's own (chi v5.4.0 against the API's v5.3.2). A tools modfile that can't be tidied from where it's used is a dead end. Attempt 2: one shared nested module for all tools Next, api/tools/go.mod holding all three tools. My first version reused the API's module path and had only tool lines, no requires. It needs its own module path, and go get -tool to add the requirements and a go.sum. Once fixed, it worked: the API's go.mod was clean, regenerated code was byte-identical, and every tool ran at its pinned version. But the three tools now shared one graph, which is the same problem one level down. To measure it, I built each tool inside the shared module and on its own, then compared go version -m of the two binaries. That reports what's actually linked; go list -m all also counts test-only modules and overstates the overlap. | Tool | Linked deps | Built with other versions than its release resolves | | --- | --- | --- | | oapi-codegen v2.8.0 | 16 | 6, including golang.org/x/tools v0.48.0 -> v0.50.0 | | Air v1.67.4 | 22 | 6, including x/sys, x/text | | golangci-lint v2.14.0 | 207 | 2: spf13/cast v1.5.0 -> v1.10.0, mitchellh/mapstructure v1.5.0 -> a 2023 pseudo-version | go mod graph showed that both golangci-lint bumps came from Air through Hugo. Both libraries sit in the path golangci-lint uses to load its config. A dev reloader was changing how my linter reads .golangci.yml, and golangci-lint --version still said 2.14.0. oapi-codegen uses x/tools to format the code it generates. The output was identical that day, but a future Air bump could change generated code in a commit that never touched oapi-codegen. That's the kind of diff nobody reviews properly, because nobody expects it. Attempt 3: one nested module per tool api/tools/air/, api/tools/oapi-codegen/ and api/tools/golangci-lint/, each with its own module path, go.mod and go.sum: module github.com/davidporos92/margin-cms/api/tools/air go 1.27.1 tool github.com/air-verse/air require ( dario.cat/mergo v1.0.2 // indirect github.com/air-verse/air v1.67.4 // indirect // ... ) Tools run from api/ with the module selected by -modfile, so the working directory stays the API's: CMD ["go", "tool", "-modfile=tools/air/go.mod", "air"] go -C tools/air tool air looks shorter, but -C changes the working directory, so Air and the linter would run on the wrong tree. And ./... doesn't descend into nested modules, so tests and lint never see the tool modules. The result after this step: api/go.mod went from 42 indirect lines to 3, go mod why reports that the main module doesn't need Hugo, make openapi-generate-go produces byte-identical code, and Air starts in the container through the new CMD. golangci-lint's module alone is over 200 lines of go.mod. That's the graph that would otherwise have sat next to the API's. | Option | Isolation | Cost | | --- | --- | --- | | tool in the app's go.mod | None: tools and app share MVS | Nothing extra | | One shared tools module | From the app, not between tools | One module | | One module per tool | Full | A longer command and one module per tool | | Release binaries (golangci-lint's recommendation) | Full | A second install mechanism next to Go's | One mechanism with full isolation won. The cost is a longer command per tool, which lives in Make targets anyway. make update-tools that finds tools by itself With go get -u tool gone, the tools need their own update target. I wanted a new tool to be picked up without touching the Makefile. My first version had cd tools/air on its own line. Make runs each recipe line in a new shell, so every go get -u tool that followed ran in api/. Two more obvious options didn't help: go list tool only warns that the pattern matched no packages in a module with no requires yet, and go get tool@latest is rejected with can't request explicit version of "tool" pattern. What works is reading the tool lines with go mod edit -json and running go get <path>@latest plus go mod tidy in each tools/*/go.mod: TOOL_DIRS := $(patsubst %/go.mod,%,$(wildcard tools/*/go.mod)) update-tools: @for dir in $(TOOL_DIRS); do \ tools=$$(cd $$dir && go mod edit -json | sed -n '/"Tool"/,/]/s/.*"Path": "\(.*\)".*/\1@latest/p'); \ echo "Updating $$dir: $$tools"; \ (cd $$dir && go get $$tools && go mod tidy) || exit 1; \ done There's deliberately no -u. Each tool moves to its latest release, and its dependencies stay at what that release requires. That's golangci-lint's rule, applied to every tool, so the linter needs no special case. I checked it: after the update, golangci-lint links spf13/cast v1.5.0 and mapstructure v1.5.0 again, and a second run changes nothing. Step 2: A strict, curated golangci-lint config Commits: tooling: add golangci-lint with a strict config, then api: fix lint findings golangci-lint's tool module stayed out of the step 1 commit and lands here, together with the config. I haven't used golangci-lint in a while and wanted an up-to-date strict config, so I had Claude generate .golangci.yml. Then I went through it: I checked every key against the v2.14.0 reference config, ran golangci-lint config verify, and ran it on the code. The shape of the config The main decision is curated versus exhaustive. default: all with a disable list is the strictest option, but every golangci-lint upgrade can turn on new linters by itself, and a version bump suddenly fails the build for reasons unrelated to the change. So the config is default: standard plus about 40 linters listed by name, each with a one-line reason: linters: # standard: errcheck, govet, ineffassign, staticcheck, unused. # Everything else is opt-in, so a golangci-lint upgrade never turns on new linters by itself. default: standard enable: # Bugs the compiler doesn't catch - asasalint # []any passed as a single any to a variadic func - bidichk # dangerous unicode sequences (trojan source) # ... # HTTP, context and SQL (the last two matter once Postgres lands) - bodyclose # unclosed http.Response bodies, mostly in tests # ... - rowserrcheck # rows.Err() not checked - sqlclosecheck # rows and statements not closed The groups are bugs, errors, HTTP/context/SQL (sqlclosecheck and rowserrcheck are there for when Postgres lands), security (gosec), logging (sloglint), tests, and style rules that prevent real confusion. Some settings go beyond the defaults: - govet runs every analyzer except fieldalignment. - errcheck treats an unchecked type assertion as an unchecked error. - nolintlint requires a linter name and a reason on every //nolint. - revive runs its defaults plus deep-exit. - sloglint wants the request's logger and context, not the global logger. Generated code is excluded with generated: strict, which is the default; the config sets it explicitly because oapi-codegen's header follows the Code generated ... DO NOT EDIT. convention and the exclusion depends on it. Formatting is gofumpt and goimports with the module as the local prefix. Left out on purpose: wsl, varnamelen, exhaustruct, mnd, lll, the complexity linters, paralleltest and err113. warn-unused flagged an exclusion rule I had for test files as unused, so it's gone until a test needs it. Every rule that's turned off has its reason in the config: | Rule | Why it's off | | --- | --- | | revive package-comments, exported | Internal packages of one binary: a doc comment on every package and exported name would restate the name and add nothing | | gocritic hugeParam | Passing structs by value is a design choice at this size, not a defect | | govet fieldalignment | Reordering fields for padding hurts readability for no measurable gain | What it caught The config and the fixes are two commits on purpose. Lint fails at the first commit, and the second shows exactly what the strict set caught. The first run found 35 issues on a codebase of stubs and one health check; with the doc-comment rules off (see the table above), the lint commit shows 25. The routine ones were 17 unused-parameter findings on the stub handlers, 10 missing doc comments and import grouping. The doc comments went away with the exported decision above, not with new comments. Renaming the stub parameters to _ then surfaced unused-receiver, fixed the same way: func (server) ListPosts(_ context.Context, _ api.ListPostsRequestObject) (api.ListPostsResponseObject, error) { return nil, ErrNotImplemented } The interesting ones: deep-exit: log.Fatal in config.New. A library function that exits the process can't be tested and skips every deferred call on the way out. config.New now returns (*Config, error) and main decides: func New(ctx context.Context) (*Config, error) { var c Config if err := envconfig.Process(ctx, &c); err != nil { return nil, fmt.Errorf("parse config from environment: %w", err) } return &c, nil } The wrap also clears wrapcheck. Error wrapping has been in the standard library since Go 1.13 (%w, errors.Is, errors.As), with errors.Join and multiple %w since Go 1.20. github.com/pkg/errors, which I used last time, is in maintenance mode. What you lose is stack traces; specific wrap messages read like one. gocritic exitAfterDefer: log.Fatal in main skips defer stop(). log.Fatal calls os.Exit, which runs no deferred calls. I switched to log.Panic: a panic in main's goroutine unwinds it and runs its deferred calls first. That only holds for the goroutine that panics. The ListenAndServe goroutine panics too, and that crashes the process without running main's defers, which is acceptable for a server that failed to bind. gosec G706, log injection. The error handler logged r.URL.Path straight into log.Printf. Client-controlled data in a log line can fake entries (%0A in the path decodes to a newline), break log parsers or inject terminal escapes. Checking analyzers/loginjection.go in gosec revealed that the taint sources are *http.Request, *url.URL, os.Getenv and os.Args, and the sinks are every log.Print*, log.Fatal* and log.Panic*, plus the message argument of slog.Info, Warn, Error and Debug. Quoting both r.Method and r.URL.Path fixed it: log.Printf("%s %s %s", strconv.Quote(r.Method), strconv.Quote(r.URL.Path), err) The alternative was log/slog with attributes: the handler escapes attribute values, and G706 doesn't look at them. That's the better long-term answer, and it comes with the move to structured logging, which I'm planning to introduce a bit later in another post. errchkjson: _ = json.NewEncoder(w).Encode(api.Problem{...}). Problem.Extensions is a map[string]interface{}, so the encoder can't be proven safe, and discarding its error hides a broken response. It's handled now (the full writeProblem is in step 3). Step 3: Validate every request against the spec The generated strict server checks types: a string where an integer belongs fails to decode. It doesn't check minLength, format, or required inside nested objects. Writing those checks in each handler duplicates the spec and drifts from it. A validator that reads the spec as data can't drift, so the spec should enforce itself at runtime, not only at compile time. The embedded spec Commit: api: generate the embedded spec The validator needs the spec at runtime, and oapi-codegen can embed it (embedded-spec: true). I decided to generate it to its own file: openapi/oapi-codegen/spec.yaml generates internal/api/spec.go, so the embedded spec shows up as its own diff whenever the spec changes. package: api generate: embedded-spec: true output: internal/api/spec.go kin-openapi becomes a runtime dependency of the API. Step 1 removed it with oapi-codegen; now it comes back for a real reason. I pinned it to v0.142.0, the version oapi-codegen v2.8.0 resolves in its own tool module, so the generated code and the library it calls match. With it and the validator middleware, api/go.mod is back up from 3 indirect lines to 10. Eight of them are linked into the binary; the other two come from test dependencies (testify's YAML support and a test-only dependency of kin-openapi's YAML parser). oapi-codegen v2.8.0 also generates a GetSpec() next to the older GetSwagger(); the server uses GetSpec(). Field errors get named types Commit: spec: extract FieldError and FieldErrors schemas from Problem Problem.errors was an inline array of objects, which generated a Go *[]struct{Field, Message string}: awkward to build in the error handler. So I added two new schemas: FieldError (one object) and FieldErrors (an array of $ref: FieldError), both registered in openapi.yaml. Wiring the validator Commit: api: validate requests against the spec and deny bearer auth This commit landed after the second spec change (codes and constraints, described further down). I describe the handler first because the error codes only make sense once the error tree is clear. The validator is nethttp-middleware v1.2.0, oapi-codegen's net/http wrapper around kin-openapi's openapi3filter. Before writing the error handler, I probed every default against the real embedded spec. Most of them weren't what I'd assumed. - Server matching. The spec lists http://localhost:8080/api/v1 under servers. The validator's router matches the Host too, so any other host (Docker's api:8080, production) gets 404 "no matching operation was found" for every request. The fix is DoNotValidateServers: true plus Prefix: "/api/v1". The library logs a warning about this itself, pointing to oapi-codegen issue 882. - format: email isn't checked unless a validator for it is registered: globally with openapi3.DefineStringFormatValidator, or per validation through SchemaValidationOptions. I used the global registry, and registered it in the constructor rather than in init(), which the linter forbids anyway. - A missing AuthenticationFunc already fails closed: secured operations get 401 "missing AuthenticationFunc". I added an explicit deny-all anyway. It states the intent, keeps an internal message out of responses, and is the seam for part 4. - Errors nest. With MultiError: true, a request with several problems arrives as MultiError -> RequestError -> MultiError -> SchemaError, and both a missing email and a missing password come back. - Status codes. The validator suggests 400 for bad requests. The contract says 422 (ValidationErrorResponse). With ErrorHandlerWithOpts, which I use, nethttp-middleware reports a wrong method as 404 instead of 405. The API still answers 405, because unroutable requests skip the validator and go to chi. - The body is read into memory twice for secured operations: once before the AuthenticationFunc runs, and again for body validation. func newServerValidationMiddleware(router chi.Routes) (api.MiddlewareFunc, error) { oAPISpec, err := api.GetSpec() if err != nil { return nil, fmt.Errorf("get spec: %w", err) } // kin-openapi doesn't check `format: email` unless the format is explicitly registered. // The registry is package-global, so this registration applies to every spec in the process. openapi3.DefineStringFormatValidator("email", openapi3.NewRegexpFormatValidator(openapi3.FormatOfStringForEmail)) return nethttpmiddleware.OapiRequestValidatorWithOptions(oAPISpec, &nethttpmiddleware.Options{ DoNotValidateServers: true, Prefix: "/api/v1", Options: openapi3filter.Options{ MultiError: true, AuthenticationFunc: authFunc, }, ErrorHandlerWithOpts: errHandler, // Let chi answer requests which we cannot route // This will automatically set Allow for 405 Skipper: func(r *http.Request) bool { return !router.Match(chi.NewRouteContext(), r.Method, r.URL.Path) }, }), nil } func authFunc(context.Context, *openapi3filter.AuthenticationInput) error { return ErrUnauthenticated } Closed by default is the point of authFunc. Leaving the bearer endpoints open until part 4 would mean every new endpoint is public until someone remembers to protect it. With deny-all, part 4 opens endpoints one at a time by verifying a token, and forgetting something fails safe. Where it's mounted There were three candidate places for the validator: | Where | Problem | | --- | --- | | Strict middlewares | They run after the body is decoded into typed request objects, too late | | chi's Middlewares option | Generated code runs these per operation, after routing and parameter parsing, so ?limit=abc gets the generated plain-text 400 and unknown routes get chi's plain-text 404/405 | | Around the whole router | Sees every request first | So it wraps the whole router: cors(maxBytes(validator(chi))). CORS stays outermost so preflight OPTIONS requests, which aren't in the spec, still work. The body limit sits outside the validator so the validator's own read is capped too. All of this wiring moved from main.go into server.New, which the tests can now call: func New(cfg *Config) (*http.Server, error) { router := chi.NewRouter() router.NotFound(func(w http.ResponseWriter, _ *http.Request) { writeProblem(w, api.Problem{Status: http.StatusNotFound}) }) serverInterface := api.NewStrictHandlerWithOptions(server{}, newServerMiddlewares(), newServerOptions()) handler := api.HandlerWithOptions(serverInterface, api.ChiServerOptions{ BaseURL: "/api/v1", BaseRouter: router, }) serverValidationMiddleware, err := newServerValidationMiddleware(router) if err != nil { return nil, fmt.Errorf("failed to initialize server validation middleware: %w", err) } handler = maxBytesReaderMiddleware(cfg)(serverValidationMiddleware(handler)) handler = cors.Handler(cors.Options{ // ... })(handler) // ... } The error handler There are three kinds of failure. No matched route is a 404 or 405. A security failure anywhere in the tree is a 401 with WWW-Authenticate: Bearer. Schema problems are a 422 with one field error each. Anything unrecognised is logged and answered with a 500, and err.Error() never reaches the client. var result validationResult result.collect(err) switch { case result.unauthenticated: // Check unauthenticated first: // Unauthenticated caller doesn't get to learn what's wrong in a request. w.Header().Set("WWW-Authenticate", "Bearer") writeProblem(w, api.Problem{Status: http.StatusUnauthorized}) case result.tooLarge: writeProblem(w, api.Problem{Status: http.StatusRequestEntityTooLarge}) case result.unknown != nil: log.Printf("validate %s %s: %v", strconv.Quote(r.Method), strconv.Quote(r.URL.Path), result.unknown) writeProblem(w, api.Problem{Status: http.StatusInternalServerError}) default: writeProblem(w, api.Problem{ Status: http.StatusUnprocessableEntity, Errors: &result.fields, }) } Auth is checked first, so an anonymous GET /posts?limit=abc is a 401, not a 422: a caller without a token doesn't learn what else is wrong with the request. The tree walk uses a type switch, not errors.As, and that's deliberate: // collect walks kin-openapi's error tree. With MultiError on, a request with // several problems arrives as MultiError -> RequestError -> MultiError -> SchemaError. // // It switches on concrete types on purpose: MultiError implements As, so // errors.As on it returns only the first match and drops the rest. func (v *validationResult) collect(err error) { //nolint:errorlint // see above, errors.As would flatten MultiError switch e := err.(type) { case openapi3.MultiError: for _, inner := range e { v.collect(inner) } case *openapi3filter.SecurityRequirementsError: v.unauthenticated = true case *openapi3filter.RequestError: v.collectRequestError(e) default: v.unknown = errors.Join(v.unknown, err) } } kin-openapi's MultiError implements As, so errors.As returns the first match and drops the rest. Worse, errors.As(requestErr, &multi) unwraps past the RequestError and loses the parameter name with it. I hit exactly this while iterating: I replaced the switch with errors.As to satisfy errorlint, and every schema error became a 500, because the bare SchemaErrors matched nothing and fell through to unknown. Build, vet and lint all passed. The strict linter had pushed a deliberate type switch into a bug, so the switch is back, with //nolint:errorlint and a reason, which is exactly the case nolintlint's required reason is for. writeProblem takes the whole api.Problem. Type defaults to about:blank and Title to Go's status text. With about:blank, RFC 9457 wants the title to be the status code's recommended phrase, not a custom one. Go's http.StatusText still uses the older names for two codes this server sends: "Request Entity Too Large" and "Unprocessable Entity", where RFC 9110 now says "Content Too Large" and "Unprocessable Content". func writeProblem(w http.ResponseWriter, problem api.Problem) { if problem.Type == "" { problem.Type = "about:blank" } if problem.Title == "" { problem.Title = http.StatusText(problem.Status) } w.Header().Set("Content-Type", "application/problem+json") w.WriteHeader(problem.Status) err := json.NewEncoder(w).Encode(problem) if err != nil { log.Printf("write problem: %v", err) } } Localizable errors: codes, not prose Commit: spec: replace FieldError message with a code and constraint The frontend translates its messages, so the server should return required, not property "email" is missing. kin-openapi already records the failed keyword in SchemaError.SchemaField (required, format, type, minLength and so on), separately from the English Reason. I verified that on real requests before building on it. The contract is a code enum on FieldError listing only the keywords my schemas use, plus an invalid catch-all. Unknown keywords map to invalid, so none of kin-openapi's strings become API without being in the spec first. Adding a keyword is a deliberate one-line spec change, and the TypeScript types list every code. Messages like "at least 8 characters" also need the limit. My first version had a typed FieldErrorParams (min, max, format, allowed) filled by a switch per keyword. It worked, but a number with minimum came back without params: every new keyword needed new Go code. Before writing the next case, I checked whether this already existed somewhere: | Option | Gives | Why not | | --- | --- | --- | | nethttp-middleware | One error | No field-level output; the default handler writes err.Error() as text | | kin-openapi's message customizer | A rewritten message string | Still a string, and the tree walk is still needed | | santhosh-tekuri/jsonschema v6 (already in the graph) | Typed error kinds per keyword, localization through x/text | kin-openapi only uses it for OpenAPI 3.1 and flattens it to SchemaError{Reason: string}; my spec is 3.0.3 | | RFC 9457 | The envelope, and an example errors extension | The example is illustrative, not a standard field, and has no machine-readable code | So the tree walk is unavoidable, but it's written once and doesn't grow with the spec. The per-keyword switch isn't unavoidable. The generic version reads the failed keyword's value straight from the failing schema, which kin-openapi attaches to the error as SchemaError.Schema: func fieldErrorConstraint(schemaErr *openapi3.SchemaError) any { if schemaErr.SchemaField == "required" || schemaErr.Schema == nil { return nil } raw, err := json.Marshal(schemaErr.Schema) if err != nil { return nil } var keywords map[string]any if err := json.Unmarshal(raw, &keywords); err != nil { return nil } return keywords[schemaErr.SchemaField] } minimum, maxLength, enum and format all work with no per-keyword code. The exception is required, whose value is the parent's whole list, so it has no constraint. The trade-off is on the client: TypeScript gets unknown and has to check the type against code. In return, the contract describes one rule instead of a growing set of keys: code: type: string description: Machine-readable reason. Clients can use this to localize the error messages. enum: - required - type - format # ... - invalid constraint: description: | Value of the schema keyword named by `code`, for example 3 for maxLength or ["draft", "published"] for enum. Absent for required. I did consider dropping constraint altogether. The admin's forms validate client-side from the same rules, so server field errors are a safety net rather than the main path. I kept it: the frontend translates code and reads constraint for messages like "at least 8 characters", so even the safety net shows the limit in the user's language. $ curl -X POST localhost:8080/api/v1/auth/login -H 'Content-Type: application/json' -d '{"email":"foo","password":"bar"}' {"errors":[{"code":"format","constraint":"email","field":"email"}],"status":422,"title":"Unprocessable Entity","type":"about:blank"} Two gotchas The Allow header. A 405 must carry Allow. No library I found sets it together with a problem+json body. But chi's default 405 handler already sets Allow from its routing tree, with an empty body. The validator was answering unmatched routes before chi ever saw them. The fix is nethttp-middleware's Skipper: skip anything chi can't route (router.Match) and let chi answer. chi gets its own router through BaseRouter, with a problem+json NotFound. A custom MethodNotAllowed would lose Allow, since it has no access to the allowed methods, so 405 keeps chi's default: an empty body, where the status and Allow say everything. That's no routing code and no method list of my own. 413 in the spec. Bodies are capped at 10 MiB by default (SERVER_MAX_BYTES_TO_READ) through http.MaxBytesReader. OpenAPI 3.0 has no global responses: components.responses still needs a $ref per operation, which is exactly the thing that gets forgotten. The spec doesn't require it either, since the Responses Object "is not necessarily expected to cover all possible HTTP response codes". So the limit and its 413 are documented once, in info.description. Tests through the whole handler The tests are in the same commit. They're table tests through the real server.New handler, in a black-box server_test package, so every case goes through CORS, the body limit, the validator, chi and the strict server. Unit tests of the error walk alone would have missed the errors.As bug above, because the walk only breaks on the error tree kin-openapi builds for a real request. "GET /posts?limit=abc - without token, auth wins over the param error": { method: http.MethodGet, route: "/posts?limit=abc", wantStatus: http.StatusUnauthorized, wantBody: errUnauthorized, wantHeaders: map[string][]string{"WWW-Authenticate": {"Bearer"}, "Content-Type": {"application/problem+json"}}, }, The 13 cases cover: - 200 for the health check - 422 for a missing Content-Type, an empty body, a bad email and broken JSON - 501 for a valid login (for now) - 413 for an oversized body - two 401s, including auth winning over a bad ?limit= - 404 for an unknown route - 405 with Allow on a templated path - a CORS preflight, and a bare OPTIONS that gets a 405 The setup builds an explicit server.Config instead of calling config.New, which would read my real SERVER_* environment. It also lets the 413 case use a 1 KiB limit instead of a 10 MiB test body. Expected responses are fixture files under testdata/response/, embedded with //go:embed. For one-line bodies that's more ceremony than needed, but the fixtures will earn their place once the responses are full Post payloads. httptest's default host is example.com, so every case already runs against a non-localhost host. The server-matching problem from earlier is covered without a case of its own. I also checked that the suite catches the errors.As bug: with it reintroduced in a scratch copy, the empty-body and email cases fail. What writing the tests turned up: - The login test got invalid on body instead of two required errors. The case sent {} without a Content-Type. kin-openapi picks the body decoder by media type, and the spec only defines application/json, so it never validated against Credentials. It returned a plain "header Content-Type has unexpected value" error, not a SchemaError, and my handler mapped that to invalid on body. That was the correct answer for the wrong request, so every body case now sends the header. It raised a real design question: a missing or wrong Content-Type is a 415. kin-openapi has no error type for it, only a message, so detecting it would mean string matching. I kept 422 and pinned it with its own test case, so it's a decision, not an accident. - 413 on login regardless of body content or Content-Type. kin-openapi reads the whole body before the media-type check, so an oversized body hits MaxBytesReader first. A body of exactly the limit still passes and gets the 501. - OPTIONS /posts returned 405, not the CORS answer. A bare OPTIONS isn't a preflight: go-chi/cors only treats it as one when both Origin and Access-Control-Request-Method are present. Without them it passes the request through, the Skipper sees chi can't route OPTIONS, and chi answers 405 with Allow. I kept both cases. A real preflight gets 200 with Access-Control-Allow-Origin, and a bare OPTIONS gets 405. Together they document that CORS only answers preflights. - A flaky Allow assertion. The test expected [DELETE GET PATCH] and once got [GET PATCH DELETE]. chi collects the allowed methods by ranging over a map in tree.go, so the order is random per run. Allow is a set, so the assertion has to ignore order. All header expectations now use ElementsMatch; single-value headers lose nothing. Afterwards I ran the suite with -count=20 -race -shuffle=on to make sure nothing else depends on order. Step 4: One entry point for the API's make targets Commit: tooling: add root make targets for the API Every API target can now run from the repo root: api-lint, api-lint-fix, api-fmt, api-test, api-build, api-update-deps and api-update-tools in the root Makefile. They all go through $(MAKE) -C api <target> rather than cd api && make, so the same make binary runs and flags like -n, -j and -k reach the sub-make. What changes in production - Auth is deny by default. Part 4 opens endpoints by verifying tokens; nothing is open because someone forgot it. - Validation is the first line against malformed input. It doesn't replace the body size limit, which is a separate middleware and runs before the validator reads anything. - The cost per request is unmeasured. It's a route lookup against the spec plus a schema check, and the body is buffered in memory, twice for secured operations. With a 10 MiB cap that's bounded. I haven't load-tested it yet. - Responses aren't validated. Checking responses against the spec belongs in integration tests, once there are real responses to check. What I'd tell myself before starting - A tool directive is a dependency like any other: it shares minimal version selection with your app. And when you judge dependency drift, measure what's linked into the binary (go version -m), not the module list. - Probe a validation library's defaults against your real spec before writing the handler. Server matching, unchecked formats and fail-closed auth were all different from what I'd assumed. - Don't errors.As through a library's multi-error type without reading its As method: it can silently return only the first error. - When a mapping grows one case per spec keyword, look for the data the library already carries (here, the failed schema) before writing the next case. - Test HTTP behaviour through the whole handler, not the pieces. - A strict linter will push correct code into a bug if you let it. Before "fixing" a finding in deliberate code, check that the finding applies, and if it doesn't, write the reason next to the //nolint. Next steps 1. Auth with in-memory stores. Login, refresh, logout and GET /me with Argon2id and JWTs, behind store interfaces that Postgres will implement later. The deny-all AuthenticationFunc becomes real token verification. 2. Postgres, Ent and Atlas. The database, the schema and migrations. --- Originally published on my blog, where the comments live. The code for this post is on GitHub.

  6. Building MarginCMS, part 2: A Go server generated from the spec In part 1 I wrote the OpenAPI contract for MarginCMS and planned the backend. In this part the Go service comes to life: a server generated from the spec, config from environment variables, stub handlers that fail properly, and a Docker Compose setup that runs the API, the docs and a mock server with one command. It also includes a CORS bug where my server logged every request and the browser still showed nothing. That one gets its own section. Why generate the server at all? The contract is the only thing my wife's frontend and my backend share. If the Go handlers are written by hand, they'll drift from the spec sooner or later: a renamed field, a missing status code, a query parameter parsed as the wrong type. With a generated server, the handlers implement an interface that comes straight from the spec. When the spec changes, I regenerate, and the compiler tells me exactly what's out of date. Step 1: Choose the server and generate it from the spec Commit: api: generate Go server and types from the OpenAPI spec chi I picked chi as the router: - I've used it before, so it's one less new thing. - It's a thin layer on top of net/http. Handlers are plain http.Handlers, and anything from the standard library works with it. - It has a good community and plenty of middleware and packages, including the CORS package that shows up later in this post. oapi-codegen oapi-codegen turns the bundled spec into Go code. I generate four files, each from its own small config: | Config | Generates | | --- | --- | | models.yaml | Request and response types | | server.yaml | A chi server and a strict server interface | | client.yaml | A typed Go client, useful for tests later | | server_urls.yaml | Constants for the server URLs in the spec | The strict server is the important part. Instead of func(w http.ResponseWriter, r *http.Request), each operation becomes a method with typed input and typed output: func (s server) GetHealth(ctx context.Context, request api.GetHealthRequestObject) (api.GetHealthResponseObject, error) The generated code parses the path, query, headers and body, calls my method, and writes the response. My code never touches http.Request. The return type is an interface that the generated per-status response types implement, so returning a response the spec doesn't define takes deliberate effort instead of happening by accident. Empty string or no field at all? A few years ago I worked with code generated from proto3 messages with plain string fields. Without the optional keyword (generally available only since protobuf 3.15) or wrapper types like StringValue, proto3 can't tell the difference between a client sending "title": "" and not sending title at all. Both arrived as an empty string. For a PATCH endpoint that's a real problem: "clear the excerpt" and "don't touch the excerpt" look the same. oapi-codegen handles this the Go way. Optional fields become pointers: type PostUpdate struct { Body *string `json:"body,omitempty"` Excerpt *string `json:"excerpt,omitempty"` Slug *string `json:"slug,omitempty"` Status *PostStatus `json:"status,omitempty"` Tags *[]string `json:"tags,omitempty"` Title *string `json:"title,omitempty"` } nil means "not sent", and a pointer to "" means "set it to empty" (for excerpt and body; title has minLength: 1). That's exactly what the PATCH semantics need. One caveat: an explicit "excerpt": null also decodes to nil, so "not sent" and "sent as null" look the same. That's fine here because none of these fields are nullable. If I ever need the difference, oapi-codegen's nullable-type output option generates nullable.Nullable[T] for it. Tools pinned with go tool My first version installed the generator with go install ...@latest. That works on my machine today, but it means anyone cloning the repo next year gets a different generator than the one that produced the committed code. Since Go 1.24, go.mod can track tools the same way it tracks dependencies: tool ( github.com/air-verse/air github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen ) Now go tool oapi-codegen always runs the exact version in go.mod, and the root Makefile uses it: openapi-generate-go: cd api && go tool oapi-codegen --config=../openapi/oapi-codegen/server.yaml ../openapi/dist/openapi.bundled.yaml The trade-off is that the tools' dependencies show up in go.mod as indirect requirements. They don't end up in the API binary, but the file gets longer. I'm fine with that in exchange for one source of truth for versions. Step 2: Config, stubs and a health check Commit: api: add server skeleton with config, stub handlers and health check Config from the environment Config comes from environment variables, mapped onto structs with go-envconfig. Each package owns its own config struct, and the top-level config just composes them: // internal/server/config.go type Config struct { Addr string `env:"SERVER_ADDR, default=:8080"` ShutdownTimeout time.Duration `env:"SERVER_SHUTDOWN_TIMEOUT, default=30s"` ReadHeaderTimeout time.Duration `env:"SERVER_READ_HEADER_TIMEOUT, default=5s"` // ... } // internal/config/config.go type Config struct { Server *server.Config } When the database and auth packages arrive, they'll add their own Config next to their code instead of one giant struct growing in a single file. There's also a test that loads the config with no SERVER_* variables set and compares it with the expected defaults. It sounds trivial, but it's already caught me twice: once when I added the CORS and timeout fields, and once when I lowered a timeout default. Defaults are part of the behaviour, so they deserve a test. It also makes me look at .env.example every time a default changes, so the code and the docs don't drift apart. main.go main.go stays small: load the config, build the generated handler, wrap it, and run an http.Server with timeouts and graceful shutdown: ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() cfg := config.New(ctx) serverInterface := api.NewStrictHandlerWithOptions(server.New(), server.NewServerMiddlewares(), server.NewServerOptions()) handler := api.HandlerWithOptions(serverInterface, api.ChiServerOptions{BaseURL: "/api/v1"}) On SIGINT or SIGTERM the server stops accepting new connections and gives in-flight requests up to SERVER_SHUTDOWN_TIMEOUT to finish. 501 for everything that isn't built yet The generated interface has 17 methods, and I've implemented one. The stubs for the rest started out as panic("implement me"). Go's HTTP server recovers from the panic, but the client gets a dropped connection and my log gets a stack trace. Not great when the docs page has a "Try it" button. 501 Not Implemented is the honest answer. The catch is that the strict server only lets a method return the responses the spec defines, and the spec has no 501. It doesn't need one, though: any error a method returns goes to a configurable ResponseErrorHandlerFunc. So every stub returns a sentinel error: var ErrNotImplemented = errors.New("not implemented") func (s server) ListPosts(ctx context.Context, request api.ListPostsRequestObject) (api.ListPostsResponseObject, error) { return nil, ErrNotImplemented } and the error handler maps it to a status code and writes a problem+json body using the Problem type generated from the spec: func responseErrorHandler(w http.ResponseWriter, r *http.Request, err error) { status := http.StatusInternalServerError if errors.Is(err, ErrNotImplemented) { status = http.StatusNotImplemented } else { log.Printf("%s %s %s", r.Method, r.URL.Path, err) } writeProblem(w, status) } A nice side effect: real errors returned from handlers now also come back as a clean 500 in the same format, and the internal error message is logged instead of being sent to the client. Request-decoding errors still get oapi-codegen's default plain-text 400 for now. $ curl http://localhost:8080/api/v1/posts {"status":501,"title":"Not Implemented","type":"about:blank"} Health check GET /api/v1/healthz returns {"status":"OK"}. That's a stub for now. Once there's a database, it'll check that the app's dependencies are reachable and healthy, so Docker and any future deployment can tell a running process from a working one. Step 3: One command for the whole dev environment Commit: tooling: add Docker Compose dev environment docker compose up starts three services: | Service | Port | What it is | | --- | --- | --- | | api | 8080 | The Go API, rebuilt on every save by Air | | apidocs | 8081 | redocly preview: the API docs with a "Try it" console | | apimock | 8082 | Prism, serving mock responses from the spec | The api container mounts the source code and runs go tool air, so Air is pinned in go.mod like the generator. Two named volumes keep the Go module cache and the build cache (which holds the compiled Air binary) between container restarts. Without them, every recreated container downloaded all modules and compiled Air from scratch before serving a single request. Do I still need Prism? While setting this up, I noticed that the Redocly preview also has a built-in mock server behind its "Try it" console. So is Prism redundant? Not for us. The Redocly mock is great for trying endpoints from the docs page. Prism is a standalone server on a fixed port that the React app can use as its backend while my endpoints are still returning 501. It also validates requests against the spec and supports the Prefer header, so the frontend can ask for a specific response, like Prefer: code=412 for the edit-conflict case. That's what the frontend needs, so Prism stays. Step 4: The request that arrived and never came back Commit: api: add CORS middleware With everything running, I opened the docs on localhost:8081, pointed the "Try it" console at my real API on localhost:8080, and called the health check. The API logged the request. The docs page showed no response at all. Part one: CORS The server did its job. The browser threw the response away. A different port is a different origin, so a page on :8081 calling :8080 makes a cross-origin request. A plain GET without custom headers is a "simple" request, so the browser sends it straight away without a preflight. My handler runs and returns 200. But the response has no Access-Control-Allow-Origin header, so the browser refuses to let the page read it. The devtools console says so, if you think to look there. The fix is the go-chi/cors middleware. One detail matters: it has to run before chi's routing, so I wrap the whole handler at the net/http level instead of adding it to the strict server's middleware list. A preflight OPTIONS request doesn't match any generated route (chi answers 405 or 404 itself), so per-operation middleware never sees it. handler = cors.Handler(cors.Options{ AllowedOrigins: cfg.Server.AllowedOrigins, AllowedHeaders: cfg.Server.AllowedHeaders, AllowedMethods: cfg.Server.AllowedMethods, AllowCredentials: cfg.Server.AllowCredentials, })(handler) Part two: * means "all methods" in the spec, but not in go-chi/cors I added CORS, set everything to * in my local env to get going, and still got nothing. Calling the API with curl and an Origin header showed the middleware was running, since the response had Vary: Origin, but there was still no Access-Control-Allow-Origin. That surprised me, because * is a valid value for the Access-Control-Allow-Methods header: for requests without credentials, it means "all methods". The protocol was fine; the library was the problem. Reading the go-chi/cors source explained it. In its options, * is a wildcard for AllowedOrigins and AllowedHeaders, but not for AllowedMethods. There, * is compared literally against the request method, as if it were a method called *. On top of that, go-chi/cors checks the method on the actual request too, not only on the preflight, which the CORS spec doesn't ask for. My GET was a simple request with no preflight, and GET wasn't in my list, so it got no CORS headers at all. A preflighted request wouldn't have fared better: the preflight checks the requested method against the same list, and on a mismatch it still answers 200 OK, just without any CORS headers. Even a library that passed * through wouldn't have saved this config, because I also had AllowCredentials: true. For requests with credentials (cookies, TLS client certificates or HTTP authentication), browsers treat * in Access-Control-Allow-Methods and Access-Control-Allow-Headers as a literal name. That setting had a problem of its own, which is part three. Listing the methods explicitly fixed it: SERVER_ALLOWED_METHODS="GET,POST,PUT,PATCH,DELETE,OPTIONS" Part three: don't ship the permissive version While I was there, I noticed a config that only worked by accident. AllowedOrigins: * with AllowCredentials: true makes go-chi/cors send Access-Control-Allow-Origin: * next to Access-Control-Allow-Credentials: true, a combination browsers reject for credentialed requests. MarginCMS uses bearer tokens and no cookies, so it doesn't need credentials at all, and there's no reason to let every origin in either. The defaults are now strict: AllowedOrigins []string `env:"SERVER_ALLOWED_ORIGINS, default=http://localhost:8081"` AllowedMethods []string `env:"SERVER_ALLOWED_METHODS, default=GET,POST,PUT,PATCH,DELETE,OPTIONS"` AllowedHeaders []string `env:"SERVER_ALLOWED_HEADERS, default=Authorization,Content-Type"` AllowCredentials bool `env:"SERVER_ALLOW_CREDENTIALS, default=false"` If it works locally with *, that's a sign to tighten it, not a sign that you're done. What I'd tell myself before starting - When the server says 200 and the client sees nothing, the browser is the one dropping the response. Start debugging there. - Read the source of small middleware packages. go-chi/cors is a few hundred lines, and five minutes of reading beat an hour of guessing. - Pin your code generators. Generated code is only reproducible if the generator version is. - Test your defaults. It's the cheapest test you'll write and it keeps config and docs honest. Next steps 1. Add a linter. golangci-lint with a config that's strict from day one, while there's still almost no code to fix. 2. Start on auth with mock data. Login, token refresh, logout and GET /me, backed by an in-memory user for now, so the auth flow and its tests exist before the database does. In part 3 the linter came first and turned into a dependency hygiene problem: every Go tool moved into its own module, then came a strict golangci-lint config and request validation against the spec. Auth moved to part 4. --- Originally published on my blog, where the comments live. The code for this post is on GitHub.

  7. Building MarginCMS, part 1: Contract first, code later I'm building a small CMS in Go. It's called MarginCMS, it has a React admin on top, and it won't replace anything I already use. This series is the build log: what I decided, what I wrote, and what tripped me up along the way. This first part covers why I'm doing it, and the work that happened before writing any Go code: the API contract, the tooling around it, and the plan for the backend. The code is public at davidporos92/margin-cms. Why build a CMS in 2026? Nobody needs another CMS, including me. I'm building one anyway, for four reasons. It's fun. Writing a CMS has been on my someday list for years, and a pet project with no deadline and no users is the best place to finally do it. Deliberate practice. I use AI tools every day, and they're good. But I noticed I reach for them before I've thought a problem through. I want one project where I make every decision and write the code myself, with AI as a reviewer and a rubber duck rather than the author. It starts easy and gets hard. A CMS starts as CRUD over a posts table, which is a nice way to ease back in. Then the real problems show up: authentication and token refresh, revision history, two people editing the same post at the same time, search, pagination. Each of these is small enough to finish and deep enough to learn something from. There's a lot of room to grow. Once the core works, there's room for tools, plugins and packages around it: an export pipeline to my existing blog, a CLI, maybe a client library. None of that is planned yet, but the option is there. My wife is building the React frontend, so this is a two-person project with a clean split: I own the Go API, she owns the web app and the design system. The only thing we share is the API contract (and a small little doggo), and that shapes almost every decision below. The scope, briefly To keep it finishable, v1 is deliberately boring: - one admin user, one content type (posts), no media uploads, no plugin system - posts are markdown with a title, slug, excerpt, tags and a status (draft, published, archived) - every content change creates a revision, and an activity log records who did what - one Go binary with small internal packages (auth, posts, revisions, activity, stats) "Modular" here means compile-time packages with small interfaces, not a dynamic content-type builder. Step 1: Write the OpenAPI contract first Commit: Add OpenAPI contract and spec tooling Before writing a single handler, I wrote the API as an OpenAPI 3 spec. That had two goals. Don't block the frontend. If the frontend has to wait for my endpoints, it'll wait a long time. With a spec in place, the frontend can generate its TypeScript types and develop against a mock server from day one, and switch the base URL once the real endpoints land. Get a clear picture of what I'm building. Writing the contract forced decisions I would otherwise have made halfway through a handler: - Auth: a short-lived access token in Authorization: Bearer, plus a single-use refresh token. No cookies, so classic CSRF doesn't apply; XSS is the thing to guard against instead. - Errors: every error is application/problem+json (RFC 9457), with field-level details for form validation. - Optimistic concurrency: posts carry an ETag, and updates, deletes and revision restores require If-Match. A stale write gets 412 Precondition Failed with the current version of the post in the response, so the UI can show a proper conflict banner instead of silently overwriting someone's work. - Pagination: cursor-based, not offset-based. The result is 17 operations across auth, posts (including bulk actions and markdown export), revisions, tags, activity, stats and a health check. I didn't want one 2,000-line YAML file, so the spec is split into small files: one per path and one per component (schemas, parameters, request bodies, responses), all referenced from a root openapi.yaml. The convention is simple: file name equals component name. components/schemas/Post.yaml defines Post. A bundler merges everything into a single file for the tools that need one. Step 2: Set up tooling around the spec A spec is only useful if the tooling around it is easy to use, so the same commit adds npm scripts and make targets for: | Need | Tool | | --- | --- | | Lint the spec | Redocly CLI (redocly lint) | | Bundle the split files into one | Redocly CLI (redocly bundle) | | Browse the docs | Redocly | | Mock server | Prism | | TypeScript types | openapi-typescript | | Go types and server | oapi-codegen (wired up in part 2) | Prism deserves a special mention. Besides returning example responses, it supports the Prefer header. The frontend can send Prefer: code=412 and get a 412 back, which makes it possible to build the conflict handling long before the backend can produce a real conflict. Step 3: Plan the Go backend No commit for this one. It's the thinking that happened before the code. I picked tools in three groups. The HTTP server. I went with chi. I've used it before, it stays close to net/http, and oapi-codegen can generate a chi server directly from the spec. More on that in part 2. Data access. The classic Go debate is ORM versus plain SQL. Since part of the point is to learn new things while I ease back into Go, I chose tools I haven't used in anger yet: - PostgreSQL with pgx - Ent for the schema and data access, with the schema written as Go code - Atlas to generate versioned SQL migrations from the Ent schema. The API never changes the database schema on its own. - testcontainers-go for integration tests against a real Postgres, using the same migration files that ship Code generation from OpenAPI. Handlers implement an interface generated from the spec, so the code can't drift from the contract without the compiler noticing. Step 4: Plan the local dev setup Also mostly planning at this stage. The goal was a dev loop where everything runs with one command: - Go hot reload with Air, so saving a file rebuilds and restarts the API - Prism as the mock backend for the web app, for the Prefer header support mentioned above - Docker Compose so we both get the same environment, including Postgres later Next steps In part 2 I set up the Go service and the local environment: generating the server from the spec, config handling, stub handlers, the Docker Compose setup, and a CORS bug that turned out to be a library quirk, not the protocol. --- Originally published on my blog, where the comments live. The code for this post is on GitHub.

    1 reaction · 2 comments

Achievements

Awards earned by the repositories go-chi owns. Select an award to embed its badge.

No awards yet

Repositories earn awards by reaching the top three in a completed trending day or week.

A repository earns an award for finishing first, second or third in a completed trending day or week, ranked by new stars, new forks or pull request authors. Awards follow a repository through renames and transfers and count toward its current owner. Weeks run Monday to Sunday, UTC.