<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Dávid Pörös</title>
    <description>The latest articles on DEV Community by Dávid Pörös (@davidporos92).</description>
    <link>https://dev.to/davidporos92</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4169172%2F0deac0b9-578b-4393-ad00-00d9102fcd6f.png</url>
      <title>DEV Community: Dávid Pörös</title>
      <link>https://dev.to/davidporos92</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/davidporos92"/>
    <language>en</language>
    <item>
      <title>Building MarginCMS, part 3: Tools out of go.mod, a strict linter and a validated edge</title>
      <dc:creator>Dávid Pörös</dc:creator>
      <pubDate>Sun, 18 Oct 2026 08:26:13 +0000</pubDate>
      <link>https://dev.to/davidporos92/building-margincms-part-3-tools-out-of-gomod-a-strict-linter-and-a-validated-edge-47n9</link>
      <guid>https://dev.to/davidporos92/building-margincms-part-3-tools-out-of-gomod-a-strict-linter-and-a-validated-edge-47n9</guid>
      <description>&lt;p&gt;&lt;a href="https://davidporos92.github.io/posts/building-margincms-part-2-a-go-server-generated-from-the-spec/" rel="noopener noreferrer"&gt;Part 2&lt;/a&gt; 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.&lt;/p&gt;

&lt;p&gt;So this part covers three things: every Go tool in its own module, a strict &lt;a href="https://golangci-lint.run/" rel="noopener noreferrer"&gt;golangci-lint&lt;/a&gt; config and what it caught, and request validation against the OpenAPI spec, so a bad request gets a &lt;code&gt;422&lt;/code&gt; with field errors before any handler runs and every authenticated endpoint is closed until real auth exists.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the tools had to move
&lt;/h2&gt;

&lt;p&gt;In part 2 I pinned &lt;a href="https://github.com/oapi-codegen/oapi-codegen" rel="noopener noreferrer"&gt;oapi-codegen&lt;/a&gt; and &lt;a href="https://github.com/air-verse/air" rel="noopener noreferrer"&gt;Air&lt;/a&gt; with &lt;code&gt;tool&lt;/code&gt; directives in &lt;code&gt;api/go.mod&lt;/code&gt; and called it done. The trade-off I accepted there was a longer &lt;code&gt;go.mod&lt;/code&gt;. The real cost is bigger than that.&lt;/p&gt;

&lt;p&gt;golangci-lint's &lt;a href="https://golangci-lint.run/docs/welcome/install/local/" rel="noopener noreferrer"&gt;install docs&lt;/a&gt; advise against installing it with &lt;code&gt;go tool&lt;/code&gt; or &lt;code&gt;go install&lt;/code&gt;, and their reasons apply to every tool:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A tool's dependencies can modify the dependencies of another tool or of your project.&lt;/li&gt;
&lt;li&gt;The binary is built locally with your Go version, not the one the release was tested with.&lt;/li&gt;
&lt;li&gt;golangci-lint's own &lt;code&gt;replace&lt;/code&gt; directives don't apply when it's a dependency of your module.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Their fallback for people who still want &lt;code&gt;go tool&lt;/code&gt; is a dedicated module or modfile for the linter.&lt;/p&gt;

&lt;p&gt;The mechanism behind the first point is &lt;a href="https://go.dev/ref/mod#minimal-version-selection" rel="noopener noreferrer"&gt;minimal version selection&lt;/a&gt;. A &lt;code&gt;tool&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;My own &lt;code&gt;api/go.mod&lt;/code&gt; already showed the problem without the linter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;42 &lt;code&gt;// indirect&lt;/code&gt; lines, almost all of them from the two tools.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;go mod why -m github.com/gohugoio/hugo&lt;/code&gt; traced a path from Air through &lt;code&gt;air/runner&lt;/code&gt; to &lt;code&gt;hugo/watcher/filenotify&lt;/code&gt;. My API's module graph contained &lt;a href="https://gohugo.io/" rel="noopener noreferrer"&gt;Hugo&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;make update-deps&lt;/code&gt; ran &lt;code&gt;go get -u tool&lt;/code&gt;: upgrade every tool &lt;em&gt;and all of their dependencies&lt;/em&gt; inside the API's graph. That's exactly the case the docs warn about.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 1: Every tool in its own module
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Commit:&lt;/strong&gt; &lt;a href="https://github.com/davidporos92/margin-cms/commit/c6a5fa5" rel="noopener noreferrer"&gt;tooling: move each Go tool into its own module&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I originally planned to use alternate modfiles next to the API's &lt;code&gt;go.mod&lt;/code&gt;, like &lt;code&gt;tools/&amp;lt;name&amp;gt;.mod&lt;/code&gt;, selected with &lt;code&gt;-modfile&lt;/code&gt;. It took three attempts to land somewhere else.&lt;/p&gt;

&lt;h3&gt;
  
  
  Attempt 1: alternate modfiles, run from &lt;code&gt;api/&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;go mod tidy -modfile=tools/air.mod&lt;/code&gt; run from &lt;code&gt;api/&lt;/code&gt; still treats &lt;code&gt;api/&lt;/code&gt; 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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Attempt 2: one shared nested module for all tools
&lt;/h3&gt;

&lt;p&gt;Next, &lt;code&gt;api/tools/go.mod&lt;/code&gt; holding all three tools. My first version reused the API's module path and had only &lt;code&gt;tool&lt;/code&gt; lines, no &lt;code&gt;require&lt;/code&gt;s. It needs its own module path, and &lt;code&gt;go get -tool&lt;/code&gt; to add the requirements and a &lt;code&gt;go.sum&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Once fixed, it worked: the API's &lt;code&gt;go.mod&lt;/code&gt; 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 &lt;code&gt;go version -m&lt;/code&gt; of the two binaries. That reports what's actually linked; &lt;code&gt;go list -m all&lt;/code&gt; also counts test-only modules and overstates the overlap.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Linked deps&lt;/th&gt;
&lt;th&gt;Built with other versions than its release resolves&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;oapi-codegen v2.8.0&lt;/td&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;td&gt;6, including &lt;code&gt;golang.org/x/tools&lt;/code&gt; v0.48.0 -&amp;gt; v0.50.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Air v1.67.4&lt;/td&gt;
&lt;td&gt;22&lt;/td&gt;
&lt;td&gt;6, including &lt;code&gt;x/sys&lt;/code&gt;, &lt;code&gt;x/text&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;golangci-lint v2.14.0&lt;/td&gt;
&lt;td&gt;207&lt;/td&gt;
&lt;td&gt;2: &lt;code&gt;spf13/cast&lt;/code&gt; v1.5.0 -&amp;gt; v1.10.0, &lt;code&gt;mitchellh/mapstructure&lt;/code&gt; v1.5.0 -&amp;gt; a 2023 pseudo-version&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;go mod graph&lt;/code&gt; 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 &lt;code&gt;.golangci.yml&lt;/code&gt;, and &lt;code&gt;golangci-lint --version&lt;/code&gt; still said 2.14.0.&lt;/p&gt;

&lt;p&gt;oapi-codegen uses &lt;code&gt;x/tools&lt;/code&gt; 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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Attempt 3: one nested module per tool
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;api/tools/air/&lt;/code&gt;, &lt;code&gt;api/tools/oapi-codegen/&lt;/code&gt; and &lt;code&gt;api/tools/golangci-lint/&lt;/code&gt;, each with its own module path, &lt;code&gt;go.mod&lt;/code&gt; and &lt;code&gt;go.sum&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;module&lt;/span&gt; &lt;span class="n"&gt;github&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;com&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;davidporos92&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;margin&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;cms&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;api&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;air&lt;/span&gt;

&lt;span class="k"&gt;go&lt;/span&gt; &lt;span class="m"&gt;1.27.1&lt;/span&gt;

&lt;span class="n"&gt;tool&lt;/span&gt; &lt;span class="n"&gt;github&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;com&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;air&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;verse&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;air&lt;/span&gt;

&lt;span class="n"&gt;require&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;dario&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cat&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;mergo&lt;/span&gt; &lt;span class="n"&gt;v1&lt;/span&gt;&lt;span class="m"&gt;.0.2&lt;/span&gt; &lt;span class="c"&gt;// indirect&lt;/span&gt;
    &lt;span class="n"&gt;github&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;com&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;air&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;verse&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;air&lt;/span&gt; &lt;span class="n"&gt;v1&lt;/span&gt;&lt;span class="m"&gt;.67.4&lt;/span&gt; &lt;span class="c"&gt;// indirect&lt;/span&gt;
    &lt;span class="c"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Tools run from &lt;code&gt;api/&lt;/code&gt; with the module selected by &lt;code&gt;-modfile&lt;/code&gt;, so the working directory stays the API's:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;CMD&lt;/span&gt;&lt;span class="s"&gt; ["go", "tool", "-modfile=tools/air/go.mod", "air"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;go -C tools/air tool air&lt;/code&gt; looks shorter, but &lt;code&gt;-C&lt;/code&gt; changes the working directory, so Air and the linter would run on the wrong tree. And &lt;code&gt;./...&lt;/code&gt; doesn't descend into nested modules, so tests and lint never see the tool modules.&lt;/p&gt;

&lt;p&gt;The result after this step: &lt;code&gt;api/go.mod&lt;/code&gt; went from 42 indirect lines to 3, &lt;code&gt;go mod why&lt;/code&gt; reports that the main module doesn't need Hugo, &lt;code&gt;make openapi-generate-go&lt;/code&gt; produces byte-identical code, and Air starts in the container through the new &lt;code&gt;CMD&lt;/code&gt;. golangci-lint's module alone is over 200 lines of &lt;code&gt;go.mod&lt;/code&gt;. That's the graph that would otherwise have sat next to the API's.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Option&lt;/th&gt;
&lt;th&gt;Isolation&lt;/th&gt;
&lt;th&gt;Cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;tool&lt;/code&gt; in the app's &lt;code&gt;go.mod&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;None: tools and app share MVS&lt;/td&gt;
&lt;td&gt;Nothing extra&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;One shared tools module&lt;/td&gt;
&lt;td&gt;From the app, not between tools&lt;/td&gt;
&lt;td&gt;One module&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;One module per tool&lt;/td&gt;
&lt;td&gt;Full&lt;/td&gt;
&lt;td&gt;A longer command and one module per tool&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Release binaries (golangci-lint's recommendation)&lt;/td&gt;
&lt;td&gt;Full&lt;/td&gt;
&lt;td&gt;A second install mechanism next to Go's&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;One mechanism with full isolation won. The cost is a longer command per tool, which lives in Make targets anyway.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;make update-tools&lt;/code&gt; that finds tools by itself
&lt;/h3&gt;

&lt;p&gt;With &lt;code&gt;go get -u tool&lt;/code&gt; gone, the tools need their own update target. I wanted a new tool to be picked up without touching the Makefile.&lt;/p&gt;

&lt;p&gt;My first version had &lt;code&gt;cd tools/air&lt;/code&gt; on its own line. Make runs each recipe line in a new shell, so every &lt;code&gt;go get -u tool&lt;/code&gt; that followed ran in &lt;code&gt;api/&lt;/code&gt;. Two more obvious options didn't help: &lt;code&gt;go list tool&lt;/code&gt; only warns that the pattern matched no packages in a module with no &lt;code&gt;require&lt;/code&gt;s yet, and &lt;code&gt;go get tool@latest&lt;/code&gt; is rejected with &lt;code&gt;can't request explicit version of "tool" pattern&lt;/code&gt;. What works is reading the &lt;code&gt;tool&lt;/code&gt; lines with &lt;code&gt;go mod edit -json&lt;/code&gt; and running &lt;code&gt;go get &amp;lt;path&amp;gt;@latest&lt;/code&gt; plus &lt;code&gt;go mod tidy&lt;/code&gt; in each &lt;code&gt;tools/*/go.mod&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight make"&gt;&lt;code&gt;&lt;span class="nv"&gt;TOOL_DIRS&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="p"&gt;$(&lt;/span&gt;patsubst %/go.mod,%,&lt;span class="p"&gt;$(&lt;/span&gt;wildcard tools/&lt;span class="k"&gt;*&lt;/span&gt;/go.mod&lt;span class="p"&gt;))&lt;/span&gt;

&lt;span class="nl"&gt;update-tools&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
    &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="nb"&gt;dir &lt;/span&gt;&lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="p"&gt;$(&lt;/span&gt;TOOL_DIRS&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
        &lt;span class="nv"&gt;tools&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;$$(&lt;/span&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; &lt;span class="nv"&gt;$$&lt;/span&gt;&lt;span class="nb"&gt;dir&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; go mod edit &lt;span class="nt"&gt;-json&lt;/span&gt; | &lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s1"&gt;'/"Tool"/,/]/s/.*"Path": "\(.*\)".*/\1@latest/p'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
        &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Updating &lt;/span&gt;&lt;span class="nv"&gt;$$&lt;/span&gt;&lt;span class="s2"&gt;dir: &lt;/span&gt;&lt;span class="nv"&gt;$$&lt;/span&gt;&lt;span class="s2"&gt;tools"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
        &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; &lt;span class="nv"&gt;$$&lt;/span&gt;&lt;span class="nb"&gt;dir&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; go get &lt;span class="nv"&gt;$$&lt;/span&gt;tools &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; go mod tidy&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;1&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There's deliberately no &lt;code&gt;-u&lt;/code&gt;. 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 &lt;code&gt;spf13/cast&lt;/code&gt; v1.5.0 and &lt;code&gt;mapstructure&lt;/code&gt; v1.5.0 again, and a second run changes nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: A strict, curated golangci-lint config
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Commits:&lt;/strong&gt; &lt;a href="https://github.com/davidporos92/margin-cms/commit/866cb6f" rel="noopener noreferrer"&gt;tooling: add golangci-lint with a strict config&lt;/a&gt;, then &lt;a href="https://github.com/davidporos92/margin-cms/commit/c5bbac4" rel="noopener noreferrer"&gt;api: fix lint findings&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;golangci-lint's tool module stayed out of the step 1 commit and lands here, together with the config.&lt;/p&gt;

&lt;p&gt;I haven't used golangci-lint in a while and wanted an up-to-date strict config, so I had Claude generate &lt;code&gt;.golangci.yml&lt;/code&gt;. Then I went through it: I checked every key against the v2.14.0 reference config, ran &lt;code&gt;golangci-lint config verify&lt;/code&gt;, and ran it on the code.&lt;/p&gt;

&lt;h3&gt;
  
  
  The shape of the config
&lt;/h3&gt;

&lt;p&gt;The main decision is curated versus exhaustive. &lt;code&gt;default: all&lt;/code&gt; 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 &lt;code&gt;default: standard&lt;/code&gt; plus about 40 linters listed by name, each with a one-line reason:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;linters&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="c1"&gt;# standard: errcheck, govet, ineffassign, staticcheck, unused.&lt;/span&gt;
  &lt;span class="c1"&gt;# Everything else is opt-in, so a golangci-lint upgrade never turns on new linters by itself.&lt;/span&gt;
  &lt;span class="na"&gt;default&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;standard&lt;/span&gt;
  &lt;span class="na"&gt;enable&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# Bugs the compiler doesn't catch&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;asasalint&lt;/span&gt; &lt;span class="c1"&gt;# []any passed as a single any to a variadic func&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;bidichk&lt;/span&gt; &lt;span class="c1"&gt;# dangerous unicode sequences (trojan source)&lt;/span&gt;
    &lt;span class="c1"&gt;# ...&lt;/span&gt;
    &lt;span class="c1"&gt;# HTTP, context and SQL (the last two matter once Postgres lands)&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;bodyclose&lt;/span&gt; &lt;span class="c1"&gt;# unclosed http.Response bodies, mostly in tests&lt;/span&gt;
    &lt;span class="c1"&gt;# ...&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;rowserrcheck&lt;/span&gt; &lt;span class="c1"&gt;# rows.Err() not checked&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;sqlclosecheck&lt;/span&gt; &lt;span class="c1"&gt;# rows and statements not closed&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The groups are bugs, errors, HTTP/context/SQL (&lt;code&gt;sqlclosecheck&lt;/code&gt; and &lt;code&gt;rowserrcheck&lt;/code&gt; are there for when Postgres lands), security (&lt;a href="https://github.com/securego/gosec" rel="noopener noreferrer"&gt;gosec&lt;/a&gt;), logging (&lt;code&gt;sloglint&lt;/code&gt;), tests, and style rules that prevent real confusion. Some settings go beyond the defaults:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;govet runs every analyzer except &lt;code&gt;fieldalignment&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;errcheck treats an unchecked type assertion as an unchecked error.&lt;/li&gt;
&lt;li&gt;nolintlint requires a linter name and a reason on every &lt;code&gt;//nolint&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;revive runs its defaults plus &lt;code&gt;deep-exit&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;sloglint wants the request's logger and context, not the global logger.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Generated code is excluded with &lt;code&gt;generated: strict&lt;/code&gt;, which is the default; the config sets it explicitly because oapi-codegen's header follows the &lt;code&gt;Code generated ... DO NOT EDIT.&lt;/code&gt; convention and the exclusion depends on it. Formatting is gofumpt and goimports with the module as the local prefix.&lt;/p&gt;

&lt;p&gt;Left out on purpose: wsl, varnamelen, exhaustruct, mnd, lll, the complexity linters, paralleltest and err113. &lt;code&gt;warn-unused&lt;/code&gt; flagged an exclusion rule I had for test files as unused, so it's gone until a test needs it.&lt;/p&gt;

&lt;p&gt;Every rule that's turned off has its reason in the config:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Rule&lt;/th&gt;
&lt;th&gt;Why it's off&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;revive &lt;code&gt;package-comments&lt;/code&gt;, &lt;code&gt;exported&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Internal packages of one binary: a doc comment on every package and exported name would restate the name and add nothing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gocritic &lt;code&gt;hugeParam&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Passing structs by value is a design choice at this size, not a defect&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;govet &lt;code&gt;fieldalignment&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Reordering fields for padding hurts readability for no measurable gain&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  What it caught
&lt;/h3&gt;

&lt;p&gt;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 &lt;code&gt;unused-parameter&lt;/code&gt; findings on the stub handlers, 10 missing doc comments and import grouping. The doc comments went away with the &lt;code&gt;exported&lt;/code&gt; decision above, not with new comments. Renaming the stub parameters to &lt;code&gt;_&lt;/code&gt; then surfaced &lt;code&gt;unused-receiver&lt;/code&gt;, fixed the same way:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;server&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;ListPosts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="n"&gt;api&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ListPostsRequestObject&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;api&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ListPostsResponseObject&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ErrNotImplemented&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The interesting ones:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;deep-exit&lt;/code&gt;: &lt;code&gt;log.Fatal&lt;/code&gt; in &lt;code&gt;config.New&lt;/code&gt;.&lt;/strong&gt; A library function that exits the process can't be tested and skips every deferred call on the way out. &lt;code&gt;config.New&lt;/code&gt; now returns &lt;code&gt;(*Config, error)&lt;/code&gt; and &lt;code&gt;main&lt;/code&gt; decides:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;New&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;Config&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="n"&gt;Config&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;envconfig&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Process&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"parse config from environment: %w"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The wrap also clears wrapcheck. Error wrapping has been in the standard library since Go 1.13 (&lt;code&gt;%w&lt;/code&gt;, &lt;code&gt;errors.Is&lt;/code&gt;, &lt;code&gt;errors.As&lt;/code&gt;), with &lt;code&gt;errors.Join&lt;/code&gt; and multiple &lt;code&gt;%w&lt;/code&gt; since Go 1.20. &lt;a href="https://github.com/pkg/errors" rel="noopener noreferrer"&gt;&lt;code&gt;github.com/pkg/errors&lt;/code&gt;&lt;/a&gt;, which I used last time, is in maintenance mode. What you lose is stack traces; specific wrap messages read like one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;gocritic &lt;code&gt;exitAfterDefer&lt;/code&gt;: &lt;code&gt;log.Fatal&lt;/code&gt; in &lt;code&gt;main&lt;/code&gt; skips &lt;code&gt;defer stop()&lt;/code&gt;.&lt;/strong&gt; &lt;code&gt;log.Fatal&lt;/code&gt; calls &lt;code&gt;os.Exit&lt;/code&gt;, which runs no deferred calls. I switched to &lt;code&gt;log.Panic&lt;/code&gt;: a panic in &lt;code&gt;main&lt;/code&gt;'s goroutine unwinds it and runs its deferred calls first. That only holds for the goroutine that panics. The &lt;code&gt;ListenAndServe&lt;/code&gt; goroutine panics too, and that crashes the process without running &lt;code&gt;main&lt;/code&gt;'s defers, which is acceptable for a server that failed to bind.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;gosec G706, log injection.&lt;/strong&gt; The error handler logged &lt;code&gt;r.URL.Path&lt;/code&gt; straight into &lt;code&gt;log.Printf&lt;/code&gt;. Client-controlled data in a log line can fake entries (&lt;code&gt;%0A&lt;/code&gt; in the path decodes to a newline), break log parsers or inject terminal escapes. Checking &lt;code&gt;analyzers/loginjection.go&lt;/code&gt; in gosec revealed that the taint sources are &lt;code&gt;*http.Request&lt;/code&gt;, &lt;code&gt;*url.URL&lt;/code&gt;, &lt;code&gt;os.Getenv&lt;/code&gt; and &lt;code&gt;os.Args&lt;/code&gt;, and the sinks are every &lt;code&gt;log.Print*&lt;/code&gt;, &lt;code&gt;log.Fatal*&lt;/code&gt; and &lt;code&gt;log.Panic*&lt;/code&gt;, plus the &lt;em&gt;message&lt;/em&gt; argument of &lt;code&gt;slog.Info&lt;/code&gt;, &lt;code&gt;Warn&lt;/code&gt;, &lt;code&gt;Error&lt;/code&gt; and &lt;code&gt;Debug&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Quoting both &lt;code&gt;r.Method&lt;/code&gt; and &lt;code&gt;r.URL.Path&lt;/code&gt; fixed it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Printf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"%s %s %s"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;strconv&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Quote&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Method&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;strconv&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Quote&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;URL&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The alternative was &lt;code&gt;log/slog&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;errchkjson: &lt;code&gt;_ = json.NewEncoder(w).Encode(api.Problem{...})&lt;/code&gt;.&lt;/strong&gt; &lt;code&gt;Problem.Extensions&lt;/code&gt; is a &lt;code&gt;map[string]interface{}&lt;/code&gt;, so the encoder can't be proven safe, and discarding its error hides a broken response. It's handled now (the full &lt;code&gt;writeProblem&lt;/code&gt; is in step 3).&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Validate every request against the spec
&lt;/h2&gt;

&lt;p&gt;The generated strict server checks types: a string where an integer belongs fails to decode. It doesn't check &lt;code&gt;minLength&lt;/code&gt;, &lt;code&gt;format&lt;/code&gt;, or &lt;code&gt;required&lt;/code&gt; 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.&lt;/p&gt;

&lt;h3&gt;
  
  
  The embedded spec
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Commit:&lt;/strong&gt; &lt;a href="https://github.com/davidporos92/margin-cms/commit/0f51c29" rel="noopener noreferrer"&gt;api: generate the embedded spec&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The validator needs the spec at runtime, and oapi-codegen can embed it (&lt;code&gt;embedded-spec: true&lt;/code&gt;). I decided to generate it to its own file: &lt;code&gt;openapi/oapi-codegen/spec.yaml&lt;/code&gt; generates &lt;code&gt;internal/api/spec.go&lt;/code&gt;, so the embedded spec shows up as its own diff whenever the spec changes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;package&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;api&lt;/span&gt;

&lt;span class="na"&gt;generate&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;embedded-spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;

&lt;span class="na"&gt;output&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;internal/api/spec.go&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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, &lt;code&gt;api/go.mod&lt;/code&gt; 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 &lt;code&gt;GetSpec()&lt;/code&gt; next to the older &lt;code&gt;GetSwagger()&lt;/code&gt;; the server uses &lt;code&gt;GetSpec()&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Field errors get named types
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Commit:&lt;/strong&gt; &lt;a href="https://github.com/davidporos92/margin-cms/commit/4226cab" rel="noopener noreferrer"&gt;spec: extract FieldError and FieldErrors schemas from Problem&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Problem.errors&lt;/code&gt; was an inline array of objects, which generated a Go &lt;code&gt;*[]struct{Field, Message string}&lt;/code&gt;: awkward to build in the error handler. So I added two new schemas: &lt;code&gt;FieldError&lt;/code&gt; (one object) and &lt;code&gt;FieldErrors&lt;/code&gt; (an array of &lt;code&gt;$ref: FieldError&lt;/code&gt;), both registered in &lt;code&gt;openapi.yaml&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Wiring the validator
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Commit:&lt;/strong&gt; &lt;a href="https://github.com/davidporos92/margin-cms/commit/60f7faa" rel="noopener noreferrer"&gt;api: validate requests against the spec and deny bearer auth&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The validator is &lt;a href="https://github.com/oapi-codegen/nethttp-middleware" rel="noopener noreferrer"&gt;nethttp-middleware&lt;/a&gt; v1.2.0, oapi-codegen's &lt;code&gt;net/http&lt;/code&gt; wrapper around kin-openapi's &lt;code&gt;openapi3filter&lt;/code&gt;. Before writing the error handler, I probed every default against the real embedded spec. Most of them weren't what I'd assumed.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Server matching.&lt;/strong&gt; The spec lists &lt;code&gt;http://localhost:8080/api/v1&lt;/code&gt; under &lt;code&gt;servers&lt;/code&gt;. The validator's router matches the &lt;code&gt;Host&lt;/code&gt; too, so any other host (Docker's &lt;code&gt;api:8080&lt;/code&gt;, production) gets 404 "no matching operation was found" for &lt;em&gt;every&lt;/em&gt; request. The fix is &lt;code&gt;DoNotValidateServers: true&lt;/code&gt; plus &lt;code&gt;Prefix: "/api/v1"&lt;/code&gt;. The library logs a warning about this itself, pointing to &lt;a href="https://github.com/oapi-codegen/oapi-codegen/issues/882" rel="noopener noreferrer"&gt;oapi-codegen issue 882&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;format: email&lt;/code&gt; isn't checked&lt;/strong&gt; unless a validator for it is registered: globally with &lt;code&gt;openapi3.DefineStringFormatValidator&lt;/code&gt;, or per validation through &lt;code&gt;SchemaValidationOptions&lt;/code&gt;. I used the global registry, and registered it in the constructor rather than in &lt;code&gt;init()&lt;/code&gt;, which the linter forbids anyway.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A missing &lt;code&gt;AuthenticationFunc&lt;/code&gt; already fails closed:&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Errors nest.&lt;/strong&gt; With &lt;code&gt;MultiError: true&lt;/code&gt;, a request with several problems arrives as &lt;code&gt;MultiError -&amp;gt; RequestError -&amp;gt; MultiError -&amp;gt; SchemaError&lt;/code&gt;, and both a missing &lt;code&gt;email&lt;/code&gt; and a missing &lt;code&gt;password&lt;/code&gt; come back.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Status codes.&lt;/strong&gt; The validator suggests 400 for bad requests. The contract says 422 (&lt;code&gt;ValidationErrorResponse&lt;/code&gt;). With &lt;code&gt;ErrorHandlerWithOpts&lt;/code&gt;, 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The body is read into memory twice&lt;/strong&gt; for secured operations: once before the &lt;code&gt;AuthenticationFunc&lt;/code&gt; runs, and again for body validation.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;newServerValidationMiddleware&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;router&lt;/span&gt; &lt;span class="n"&gt;chi&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Routes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;api&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MiddlewareFunc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;oAPISpec&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;api&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetSpec&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"get spec: %w"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c"&gt;// kin-openapi doesn't check `format: email` unless the format is explicitly registered.&lt;/span&gt;
    &lt;span class="c"&gt;// The registry is package-global, so this registration applies to every spec in the process.&lt;/span&gt;
    &lt;span class="n"&gt;openapi3&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DefineStringFormatValidator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"email"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;openapi3&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewRegexpFormatValidator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;openapi3&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;FormatOfStringForEmail&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;nethttpmiddleware&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OapiRequestValidatorWithOptions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;oAPISpec&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;nethttpmiddleware&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Options&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;DoNotValidateServers&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="no"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;Prefix&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;               &lt;span class="s"&gt;"/api/v1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;Options&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;openapi3filter&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Options&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;MultiError&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;         &lt;span class="no"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;AuthenticationFunc&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;authFunc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="n"&gt;ErrorHandlerWithOpts&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;errHandler&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="c"&gt;// Let chi answer requests which we cannot route&lt;/span&gt;
        &lt;span class="c"&gt;// This will automatically set Allow for 405&lt;/span&gt;
        &lt;span class="n"&gt;Skipper&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;router&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chi&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewRouteContext&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Method&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;URL&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;}),&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;authFunc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;openapi3filter&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AuthenticationInput&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;ErrUnauthenticated&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Closed by default is the point of &lt;code&gt;authFunc&lt;/code&gt;. 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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where it's mounted
&lt;/h3&gt;

&lt;p&gt;There were three candidate places for the validator:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Where&lt;/th&gt;
&lt;th&gt;Problem&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Strict middlewares&lt;/td&gt;
&lt;td&gt;They run after the body is decoded into typed request objects, too late&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;chi's &lt;code&gt;Middlewares&lt;/code&gt; option&lt;/td&gt;
&lt;td&gt;Generated code runs these per operation, after routing and parameter parsing, so &lt;code&gt;?limit=abc&lt;/code&gt; gets the generated plain-text 400 and unknown routes get chi's plain-text 404/405&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Around the whole router&lt;/td&gt;
&lt;td&gt;Sees every request first&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;So it wraps the whole router: &lt;code&gt;cors(maxBytes(validator(chi)))&lt;/code&gt;. CORS stays outermost so preflight &lt;code&gt;OPTIONS&lt;/code&gt; 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 &lt;code&gt;main.go&lt;/code&gt; into &lt;code&gt;server.New&lt;/code&gt;, which the tests can now call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;New&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cfg&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;Config&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Server&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;router&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;chi&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewRouter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;router&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NotFound&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ResponseWriter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;writeProblem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;api&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Problem&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;Status&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusNotFound&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="n"&gt;serverInterface&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;api&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewStrictHandlerWithOptions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;server&lt;/span&gt;&lt;span class="p"&gt;{},&lt;/span&gt; &lt;span class="n"&gt;newServerMiddlewares&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;newServerOptions&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="n"&gt;handler&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;api&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HandlerWithOptions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;serverInterface&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;api&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ChiServerOptions&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;BaseURL&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;    &lt;span class="s"&gt;"/api/v1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;BaseRouter&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;router&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;

    &lt;span class="n"&gt;serverValidationMiddleware&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;newServerValidationMiddleware&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;router&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"failed to initialize server validation middleware: %w"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;handler&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;maxBytesReaderMiddleware&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;)(&lt;/span&gt;&lt;span class="n"&gt;serverValidationMiddleware&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;handler&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cors&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Handler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cors&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Options&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c"&gt;// ...&lt;/span&gt;
    &lt;span class="p"&gt;})(&lt;/span&gt;&lt;span class="n"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The error handler
&lt;/h3&gt;

&lt;p&gt;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 &lt;code&gt;WWW-Authenticate: Bearer&lt;/code&gt;. Schema problems are a 422 with one field error each. Anything unrecognised is logged and answered with a 500, and &lt;code&gt;err.Error()&lt;/code&gt; never reaches the client.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="n"&gt;validationResult&lt;/span&gt;
&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;collect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;switch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;unauthenticated&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
    &lt;span class="c"&gt;// Check unauthenticated first:&lt;/span&gt;
    &lt;span class="c"&gt;// Unauthenticated caller doesn't get to learn what's wrong in a request.&lt;/span&gt;
    &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Header&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"WWW-Authenticate"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Bearer"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;writeProblem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;api&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Problem&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;Status&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusUnauthorized&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tooLarge&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;writeProblem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;api&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Problem&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;Status&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusRequestEntityTooLarge&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;unknown&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Printf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"validate %s %s: %v"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;strconv&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Quote&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Method&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;strconv&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Quote&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;URL&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;writeProblem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;api&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Problem&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;Status&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusInternalServerError&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;writeProblem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;api&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Problem&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;Status&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusUnprocessableEntity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;Errors&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;fields&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Auth is checked first, so an anonymous &lt;code&gt;GET /posts?limit=abc&lt;/code&gt; is a 401, not a 422: a caller without a token doesn't learn what else is wrong with the request.&lt;/p&gt;

&lt;p&gt;The tree walk uses a type switch, not &lt;code&gt;errors.As&lt;/code&gt;, and that's deliberate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// collect walks kin-openapi's error tree. With MultiError on, a request with&lt;/span&gt;
&lt;span class="c"&gt;// several problems arrives as MultiError -&amp;gt; RequestError -&amp;gt; MultiError -&amp;gt; SchemaError.&lt;/span&gt;
&lt;span class="c"&gt;//&lt;/span&gt;
&lt;span class="c"&gt;// It switches on concrete types on purpose: MultiError implements As, so&lt;/span&gt;
&lt;span class="c"&gt;// errors.As on it returns only the first match and drops the rest.&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;validationResult&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;collect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c"&gt;//nolint:errorlint // see above, errors.As would flatten MultiError&lt;/span&gt;
    &lt;span class="k"&gt;switch&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;type&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;openapi3&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MultiError&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;inner&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;collect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;inner&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;openapi3filter&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SecurityRequirementsError&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;unauthenticated&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;true&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;openapi3filter&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RequestError&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;collectRequestError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;unknown&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;kin-openapi's &lt;code&gt;MultiError&lt;/code&gt; implements &lt;code&gt;As&lt;/code&gt;, so &lt;code&gt;errors.As&lt;/code&gt; returns the first match and drops the rest. Worse, &lt;code&gt;errors.As(requestErr, &amp;amp;multi)&lt;/code&gt; unwraps past the &lt;code&gt;RequestError&lt;/code&gt; and loses the parameter name with it. I hit exactly this while iterating: I replaced the switch with &lt;code&gt;errors.As&lt;/code&gt; to satisfy errorlint, and every schema error became a 500, because the bare &lt;code&gt;SchemaError&lt;/code&gt;s matched nothing and fell through to &lt;code&gt;unknown&lt;/code&gt;. Build, vet and lint all passed. The strict linter had pushed a deliberate type switch into a bug, so the switch is back, with &lt;code&gt;//nolint:errorlint&lt;/code&gt; and a reason, which is exactly the case nolintlint's required reason is for.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;writeProblem&lt;/code&gt; takes the whole &lt;code&gt;api.Problem&lt;/code&gt;. &lt;code&gt;Type&lt;/code&gt; defaults to &lt;code&gt;about:blank&lt;/code&gt; and &lt;code&gt;Title&lt;/code&gt; to Go's status text. With &lt;code&gt;about:blank&lt;/code&gt;, &lt;a href="https://www.rfc-editor.org/rfc/rfc9457" rel="noopener noreferrer"&gt;RFC 9457&lt;/a&gt; wants the title to be the status code's recommended phrase, not a custom one. Go's &lt;code&gt;http.StatusText&lt;/code&gt; 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".&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;writeProblem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ResponseWriter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;problem&lt;/span&gt; &lt;span class="n"&gt;api&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Problem&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;problem&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Type&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;problem&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"about:blank"&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;problem&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Title&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;problem&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Title&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;problem&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Status&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Header&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Content-Type"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"application/problem+json"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WriteHeader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;problem&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Status&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewEncoder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;problem&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Printf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"write problem: %v"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Localizable errors: codes, not prose
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Commit:&lt;/strong&gt; &lt;a href="https://github.com/davidporos92/margin-cms/commit/783e845" rel="noopener noreferrer"&gt;spec: replace FieldError message with a code and constraint&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The frontend translates its messages, so the server should return &lt;code&gt;required&lt;/code&gt;, not &lt;code&gt;property "email" is missing&lt;/code&gt;. kin-openapi already records the failed keyword in &lt;code&gt;SchemaError.SchemaField&lt;/code&gt; (&lt;code&gt;required&lt;/code&gt;, &lt;code&gt;format&lt;/code&gt;, &lt;code&gt;type&lt;/code&gt;, &lt;code&gt;minLength&lt;/code&gt; and so on), separately from the English &lt;code&gt;Reason&lt;/code&gt;. I verified that on real requests before building on it.&lt;/p&gt;

&lt;p&gt;The contract is a &lt;code&gt;code&lt;/code&gt; enum on &lt;code&gt;FieldError&lt;/code&gt; listing only the keywords my schemas use, plus an &lt;code&gt;invalid&lt;/code&gt; catch-all. Unknown keywords map to &lt;code&gt;invalid&lt;/code&gt;, 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.&lt;/p&gt;

&lt;p&gt;Messages like "at least 8 characters" also need the limit. My first version had a typed &lt;code&gt;FieldErrorParams&lt;/code&gt; (&lt;code&gt;min&lt;/code&gt;, &lt;code&gt;max&lt;/code&gt;, &lt;code&gt;format&lt;/code&gt;, &lt;code&gt;allowed&lt;/code&gt;) filled by a switch per keyword. It worked, but a &lt;code&gt;number&lt;/code&gt; with &lt;code&gt;minimum&lt;/code&gt; came back without params: every new keyword needed new Go code. Before writing the next case, I checked whether this already existed somewhere:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Option&lt;/th&gt;
&lt;th&gt;Gives&lt;/th&gt;
&lt;th&gt;Why not&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;nethttp-middleware&lt;/td&gt;
&lt;td&gt;One &lt;code&gt;error&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;No field-level output; the default handler writes &lt;code&gt;err.Error()&lt;/code&gt; as text&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;kin-openapi's message customizer&lt;/td&gt;
&lt;td&gt;A rewritten message string&lt;/td&gt;
&lt;td&gt;Still a string, and the tree walk is still needed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;a href="https://github.com/santhosh-tekuri/jsonschema" rel="noopener noreferrer"&gt;santhosh-tekuri/jsonschema&lt;/a&gt; v6 (already in the graph)&lt;/td&gt;
&lt;td&gt;Typed error kinds per keyword, localization through &lt;code&gt;x/text&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;kin-openapi only uses it for OpenAPI 3.1 and flattens it to &lt;code&gt;SchemaError{Reason: string}&lt;/code&gt;; my spec is 3.0.3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RFC 9457&lt;/td&gt;
&lt;td&gt;The envelope, and an example &lt;code&gt;errors&lt;/code&gt; extension&lt;/td&gt;
&lt;td&gt;The example is illustrative, not a standard field, and has no machine-readable code&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;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 &lt;code&gt;SchemaError.Schema&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;fieldErrorConstraint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;schemaErr&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;openapi3&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SchemaError&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;any&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;schemaErr&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SchemaField&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s"&gt;"required"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;schemaErr&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Schema&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Marshal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;schemaErr&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Schema&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;keywords&lt;/span&gt; &lt;span class="k"&gt;map&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="n"&gt;any&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Unmarshal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;keywords&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;keywords&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;schemaErr&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SchemaField&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;minimum&lt;/code&gt;, &lt;code&gt;maxLength&lt;/code&gt;, &lt;code&gt;enum&lt;/code&gt; and &lt;code&gt;format&lt;/code&gt; all work with no per-keyword code. The exception is &lt;code&gt;required&lt;/code&gt;, whose value is the parent's whole list, so it has no constraint. The trade-off is on the client: TypeScript gets &lt;code&gt;unknown&lt;/code&gt; and has to check the type against &lt;code&gt;code&lt;/code&gt;. In return, the contract describes one rule instead of a growing set of keys:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;  &lt;span class="na"&gt;code&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;string&lt;/span&gt;
    &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Machine-readable reason. Clients can use this to localize the error messages.&lt;/span&gt;
    &lt;span class="na"&gt;enum&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;required&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;type&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;format&lt;/span&gt;
    &lt;span class="c1"&gt;# ...&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;invalid&lt;/span&gt;
  &lt;span class="na"&gt;constraint&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
      &lt;span class="s"&gt;Value of the schema keyword named by `code`, for example 3 for maxLength&lt;/span&gt;
      &lt;span class="s"&gt;or ["draft", "published"] for enum. Absent for required.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I did consider dropping &lt;code&gt;constraint&lt;/code&gt; 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 &lt;code&gt;code&lt;/code&gt; and reads &lt;code&gt;constraint&lt;/code&gt; for messages like "at least 8 characters", so even the safety net shows the limit in the user's language.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST localhost:8080/api/v1/auth/login &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'Content-Type: application/json'&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"email":"foo","password":"bar"}'&lt;/span&gt;
&lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"errors"&lt;/span&gt;:[&lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"code"&lt;/span&gt;:&lt;span class="s2"&gt;"format"&lt;/span&gt;,&lt;span class="s2"&gt;"constraint"&lt;/span&gt;:&lt;span class="s2"&gt;"email"&lt;/span&gt;,&lt;span class="s2"&gt;"field"&lt;/span&gt;:&lt;span class="s2"&gt;"email"&lt;/span&gt;&lt;span class="o"&gt;}]&lt;/span&gt;,&lt;span class="s2"&gt;"status"&lt;/span&gt;:422,&lt;span class="s2"&gt;"title"&lt;/span&gt;:&lt;span class="s2"&gt;"Unprocessable Entity"&lt;/span&gt;,&lt;span class="s2"&gt;"type"&lt;/span&gt;:&lt;span class="s2"&gt;"about:blank"&lt;/span&gt;&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Two gotchas
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;The &lt;code&gt;Allow&lt;/code&gt; header.&lt;/strong&gt; A 405 must carry &lt;code&gt;Allow&lt;/code&gt;. No library I found sets it together with a &lt;code&gt;problem+json&lt;/code&gt; body. But chi's default 405 handler already sets &lt;code&gt;Allow&lt;/code&gt; 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 &lt;code&gt;Skipper&lt;/code&gt;: skip anything chi can't route (&lt;code&gt;router.Match&lt;/code&gt;) and let chi answer. chi gets its own router through &lt;code&gt;BaseRouter&lt;/code&gt;, with a &lt;code&gt;problem+json&lt;/code&gt; &lt;code&gt;NotFound&lt;/code&gt;. A custom &lt;code&gt;MethodNotAllowed&lt;/code&gt; would lose &lt;code&gt;Allow&lt;/code&gt;, since it has no access to the allowed methods, so 405 keeps chi's default: an empty body, where the status and &lt;code&gt;Allow&lt;/code&gt; say everything. That's no routing code and no method list of my own.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;413 in the spec.&lt;/strong&gt; Bodies are capped at 10 MiB by default (&lt;code&gt;SERVER_MAX_BYTES_TO_READ&lt;/code&gt;) through &lt;code&gt;http.MaxBytesReader&lt;/code&gt;. OpenAPI 3.0 has no global responses: &lt;code&gt;components.responses&lt;/code&gt; still needs a &lt;code&gt;$ref&lt;/code&gt; 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 &lt;code&gt;info.description&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Tests through the whole handler
&lt;/h3&gt;

&lt;p&gt;The tests are in the same commit. They're table tests through the real &lt;code&gt;server.New&lt;/code&gt; handler, in a black-box &lt;code&gt;server_test&lt;/code&gt; 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 &lt;code&gt;errors.As&lt;/code&gt; bug above, because the walk only breaks on the error tree kin-openapi builds for a real request.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="s"&gt;"GET /posts?limit=abc - without token, auth wins over the param error"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;method&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;      &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MethodGet&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;route&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;       &lt;span class="s"&gt;"/posts?limit=abc"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;wantStatus&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;  &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusUnauthorized&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;wantBody&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;    &lt;span class="n"&gt;errUnauthorized&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;wantHeaders&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="k"&gt;map&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;][]&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s"&gt;"WWW-Authenticate"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s"&gt;"Bearer"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="s"&gt;"Content-Type"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s"&gt;"application/problem+json"&lt;/span&gt;&lt;span class="p"&gt;}},&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The 13 cases cover:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;200 for the health check&lt;/li&gt;
&lt;li&gt;422 for a missing &lt;code&gt;Content-Type&lt;/code&gt;, an empty body, a bad email and broken JSON&lt;/li&gt;
&lt;li&gt;501 for a valid login (for now)&lt;/li&gt;
&lt;li&gt;413 for an oversized body&lt;/li&gt;
&lt;li&gt;two 401s, including auth winning over a bad &lt;code&gt;?limit=&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;404 for an unknown route&lt;/li&gt;
&lt;li&gt;405 with &lt;code&gt;Allow&lt;/code&gt; on a templated path&lt;/li&gt;
&lt;li&gt;a CORS preflight, and a bare &lt;code&gt;OPTIONS&lt;/code&gt; that gets a 405&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The setup builds an explicit &lt;code&gt;server.Config&lt;/code&gt; instead of calling &lt;code&gt;config.New&lt;/code&gt;, which would read my real &lt;code&gt;SERVER_*&lt;/code&gt; 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 &lt;code&gt;testdata/response/&lt;/code&gt;, embedded with &lt;code&gt;//go:embed&lt;/code&gt;. For one-line bodies that's more ceremony than needed, but the fixtures will earn their place once the responses are full &lt;code&gt;Post&lt;/code&gt; payloads.&lt;/p&gt;

&lt;p&gt;httptest's default host is &lt;code&gt;example.com&lt;/code&gt;, 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 &lt;code&gt;errors.As&lt;/code&gt; bug: with it reintroduced in a scratch copy, the empty-body and email cases fail.&lt;/p&gt;

&lt;p&gt;What writing the tests turned up:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The login test got &lt;code&gt;invalid&lt;/code&gt; on &lt;code&gt;body&lt;/code&gt; instead of two &lt;code&gt;required&lt;/code&gt; errors.&lt;/strong&gt; The case sent &lt;code&gt;{}&lt;/code&gt; without a &lt;code&gt;Content-Type&lt;/code&gt;. kin-openapi picks the body decoder by media type, and the spec only defines &lt;code&gt;application/json&lt;/code&gt;, so it never validated against &lt;code&gt;Credentials&lt;/code&gt;. It returned a plain "header Content-Type has unexpected value" error, not a &lt;code&gt;SchemaError&lt;/code&gt;, and my handler mapped that to &lt;code&gt;invalid&lt;/code&gt; on &lt;code&gt;body&lt;/code&gt;. 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 &lt;code&gt;Content-Type&lt;/code&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;413 on login regardless of body content or &lt;code&gt;Content-Type&lt;/code&gt;.&lt;/strong&gt; kin-openapi reads the whole body before the media-type check, so an oversized body hits &lt;code&gt;MaxBytesReader&lt;/code&gt; first. A body of exactly the limit still passes and gets the 501.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;OPTIONS /posts&lt;/code&gt; returned 405, not the CORS answer.&lt;/strong&gt; A bare &lt;code&gt;OPTIONS&lt;/code&gt; isn't a preflight: &lt;a href="https://github.com/go-chi/cors" rel="noopener noreferrer"&gt;go-chi/cors&lt;/a&gt; only treats it as one when both &lt;code&gt;Origin&lt;/code&gt; and &lt;code&gt;Access-Control-Request-Method&lt;/code&gt; are present. Without them it passes the request through, the &lt;code&gt;Skipper&lt;/code&gt; sees chi can't route &lt;code&gt;OPTIONS&lt;/code&gt;, and chi answers 405 with &lt;code&gt;Allow&lt;/code&gt;. I kept both cases. A real preflight gets 200 with &lt;code&gt;Access-Control-Allow-Origin&lt;/code&gt;, and a bare &lt;code&gt;OPTIONS&lt;/code&gt; gets 405. Together they document that CORS only answers preflights.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A flaky &lt;code&gt;Allow&lt;/code&gt; assertion.&lt;/strong&gt; The test expected &lt;code&gt;[DELETE GET PATCH]&lt;/code&gt; and once got &lt;code&gt;[GET PATCH DELETE]&lt;/code&gt;. chi collects the allowed methods by ranging over a map in &lt;code&gt;tree.go&lt;/code&gt;, so the order is random per run. &lt;code&gt;Allow&lt;/code&gt; is a set, so the assertion has to ignore order. All header expectations now use &lt;code&gt;ElementsMatch&lt;/code&gt;; single-value headers lose nothing. Afterwards I ran the suite with &lt;code&gt;-count=20 -race -shuffle=on&lt;/code&gt; to make sure nothing else depends on order.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 4: One entry point for the API's make targets
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Commit:&lt;/strong&gt; &lt;a href="https://github.com/davidporos92/margin-cms/commit/2d99647" rel="noopener noreferrer"&gt;tooling: add root make targets for the API&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Every API target can now run from the repo root: &lt;code&gt;api-lint&lt;/code&gt;, &lt;code&gt;api-lint-fix&lt;/code&gt;, &lt;code&gt;api-fmt&lt;/code&gt;, &lt;code&gt;api-test&lt;/code&gt;, &lt;code&gt;api-build&lt;/code&gt;, &lt;code&gt;api-update-deps&lt;/code&gt; and &lt;code&gt;api-update-tools&lt;/code&gt; in the root &lt;code&gt;Makefile&lt;/code&gt;. They all go through &lt;code&gt;$(MAKE) -C api &amp;lt;target&amp;gt;&lt;/code&gt; rather than &lt;code&gt;cd api &amp;amp;&amp;amp; make&lt;/code&gt;, so the same make binary runs and flags like &lt;code&gt;-n&lt;/code&gt;, &lt;code&gt;-j&lt;/code&gt; and &lt;code&gt;-k&lt;/code&gt; reach the sub-make.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changes in production
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Auth is deny by default.&lt;/strong&gt; Part 4 opens endpoints by verifying tokens; nothing is open because someone forgot it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Validation is the first line against malformed input.&lt;/strong&gt; It doesn't replace the body size limit, which is a separate middleware and runs before the validator reads anything.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The cost per request is unmeasured.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Responses aren't validated.&lt;/strong&gt; Checking responses against the spec belongs in integration tests, once there are real responses to check.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I'd tell myself before starting
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;code&gt;tool&lt;/code&gt; 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 (&lt;code&gt;go version -m&lt;/code&gt;), not the module list.&lt;/li&gt;
&lt;li&gt;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.&lt;/li&gt;
&lt;li&gt;Don't &lt;code&gt;errors.As&lt;/code&gt; through a library's multi-error type without reading its &lt;code&gt;As&lt;/code&gt; method: it can silently return only the first error.&lt;/li&gt;
&lt;li&gt;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.&lt;/li&gt;
&lt;li&gt;Test HTTP behaviour through the whole handler, not the pieces.&lt;/li&gt;
&lt;li&gt;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 &lt;code&gt;//nolint&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Next steps
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Auth with in-memory stores.&lt;/strong&gt; Login, refresh, logout and &lt;code&gt;GET /me&lt;/code&gt; with Argon2id and JWTs, behind store interfaces that Postgres will implement later. The deny-all &lt;code&gt;AuthenticationFunc&lt;/code&gt; becomes real token verification.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Postgres, Ent and Atlas.&lt;/strong&gt; The database, the schema and migrations.&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://davidporos92.github.io/posts/building-margincms-part-3-tools-out-of-go-mod-a-strict-linter-and-a-validated-edge/" rel="noopener noreferrer"&gt;my blog&lt;/a&gt;, where the comments live. The code for this post is &lt;a href="https://github.com/davidporos92/margin-cms/tree/part-3" rel="noopener noreferrer"&gt;on GitHub&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>go</category>
      <category>openapi</category>
      <category>margincms</category>
    </item>
    <item>
      <title>Building MarginCMS, part 2: A Go server generated from the spec</title>
      <dc:creator>Dávid Pörös</dc:creator>
      <pubDate>Wed, 07 Oct 2026 14:41:28 +0000</pubDate>
      <link>https://dev.to/davidporos92/building-margincms-part-2-a-go-server-generated-from-the-spec-5875</link>
      <guid>https://dev.to/davidporos92/building-margincms-part-2-a-go-server-generated-from-the-spec-5875</guid>
      <description>&lt;p&gt;In &lt;a href="https://davidporos92.github.io/posts/building-margincms-part-1-contract-first-code-later/" rel="noopener noreferrer"&gt;part 1&lt;/a&gt; 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.&lt;/p&gt;

&lt;p&gt;It also includes a CORS bug where my server logged every request and the browser still showed nothing. That one gets its own section.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why generate the server at all?
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Choose the server and generate it from the spec
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Commit:&lt;/strong&gt; &lt;a href="https://github.com/davidporos92/margin-cms/commit/4ca2fb4" rel="noopener noreferrer"&gt;api: generate Go server and types from the OpenAPI spec&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  chi
&lt;/h3&gt;

&lt;p&gt;I picked &lt;a href="https://github.com/go-chi/chi" rel="noopener noreferrer"&gt;chi&lt;/a&gt; as the router:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;I've used it before, so it's one less new thing.&lt;/li&gt;
&lt;li&gt;It's a thin layer on top of &lt;code&gt;net/http&lt;/code&gt;. Handlers are plain &lt;code&gt;http.Handler&lt;/code&gt;s, and anything from the standard library works with it.&lt;/li&gt;
&lt;li&gt;It has a good community and plenty of middleware and packages, including the CORS package that shows up later in this post.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  oapi-codegen
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/oapi-codegen/oapi-codegen" rel="noopener noreferrer"&gt;oapi-codegen&lt;/a&gt; turns the bundled spec into Go code. I generate four files, each from its own small config:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Config&lt;/th&gt;
&lt;th&gt;Generates&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;models.yaml&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Request and response types&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;server.yaml&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A chi server and a &lt;em&gt;strict&lt;/em&gt; server interface&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;client.yaml&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A typed Go client, useful for tests later&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;server_urls.yaml&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Constants for the server URLs in the spec&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The strict server is the important part. Instead of &lt;code&gt;func(w http.ResponseWriter, r *http.Request)&lt;/code&gt;, each operation becomes a method with typed input and typed output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="n"&gt;server&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;GetHealth&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt; &lt;span class="n"&gt;api&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetHealthRequestObject&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;api&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetHealthResponseObject&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The generated code parses the path, query, headers and body, calls my method, and writes the response. My code never touches &lt;code&gt;http.Request&lt;/code&gt;. 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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Empty string or no field at all?
&lt;/h3&gt;

&lt;p&gt;A few years ago I worked with code generated from proto3 messages with plain &lt;code&gt;string&lt;/code&gt; fields. Without the &lt;code&gt;optional&lt;/code&gt; keyword (generally available only since protobuf 3.15) or wrapper types like &lt;code&gt;StringValue&lt;/code&gt;, proto3 can't tell the difference between a client sending &lt;code&gt;"title": ""&lt;/code&gt; and not sending &lt;code&gt;title&lt;/code&gt; at all. Both arrived as an empty string. For a &lt;code&gt;PATCH&lt;/code&gt; endpoint that's a real problem: "clear the excerpt" and "don't touch the excerpt" look the same.&lt;/p&gt;

&lt;p&gt;oapi-codegen handles this the Go way. Optional fields become pointers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;PostUpdate&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Body&lt;/span&gt;    &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;     &lt;span class="s"&gt;`json:"body,omitempty"`&lt;/span&gt;
    &lt;span class="n"&gt;Excerpt&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;     &lt;span class="s"&gt;`json:"excerpt,omitempty"`&lt;/span&gt;
    &lt;span class="n"&gt;Slug&lt;/span&gt;    &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;     &lt;span class="s"&gt;`json:"slug,omitempty"`&lt;/span&gt;
    &lt;span class="n"&gt;Status&lt;/span&gt;  &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;PostStatus&lt;/span&gt; &lt;span class="s"&gt;`json:"status,omitempty"`&lt;/span&gt;
    &lt;span class="n"&gt;Tags&lt;/span&gt;    &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;   &lt;span class="s"&gt;`json:"tags,omitempty"`&lt;/span&gt;
    &lt;span class="n"&gt;Title&lt;/span&gt;   &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;     &lt;span class="s"&gt;`json:"title,omitempty"`&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;nil&lt;/code&gt; means "not sent", and a pointer to &lt;code&gt;""&lt;/code&gt; means "set it to empty" (for &lt;code&gt;excerpt&lt;/code&gt; and &lt;code&gt;body&lt;/code&gt;; &lt;code&gt;title&lt;/code&gt; has &lt;code&gt;minLength: 1&lt;/code&gt;). That's exactly what the &lt;code&gt;PATCH&lt;/code&gt; semantics need.&lt;/p&gt;

&lt;p&gt;One caveat: an explicit &lt;code&gt;"excerpt": null&lt;/code&gt; also decodes to &lt;code&gt;nil&lt;/code&gt;, 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 &lt;code&gt;nullable-type&lt;/code&gt; output option generates &lt;code&gt;nullable.Nullable[T]&lt;/code&gt; for it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Tools pinned with &lt;code&gt;go tool&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;My first version installed the generator with &lt;code&gt;go install ...@latest&lt;/code&gt;. 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.&lt;/p&gt;

&lt;p&gt;Since Go 1.24, &lt;code&gt;go.mod&lt;/code&gt; can track tools the same way it tracks dependencies:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;tool&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;github&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;com&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;air&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;verse&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;air&lt;/span&gt;
    &lt;span class="n"&gt;github&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;com&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;oapi&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;codegen&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;oapi&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;codegen&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;v2&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;cmd&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;oapi&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;codegen&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now &lt;code&gt;go tool oapi-codegen&lt;/code&gt; always runs the exact version in &lt;code&gt;go.mod&lt;/code&gt;, and the root &lt;code&gt;Makefile&lt;/code&gt; uses it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight make"&gt;&lt;code&gt;&lt;span class="nl"&gt;openapi-generate-go&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
    &lt;span class="nb"&gt;cd &lt;/span&gt;api &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; go tool oapi-codegen &lt;span class="nt"&gt;--config&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;../openapi/oapi-codegen/server.yaml ../openapi/dist/openapi.bundled.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The trade-off is that the tools' dependencies show up in &lt;code&gt;go.mod&lt;/code&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Config, stubs and a health check
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Commit:&lt;/strong&gt; &lt;a href="https://github.com/davidporos92/margin-cms/commit/ae3c5d7" rel="noopener noreferrer"&gt;api: add server skeleton with config, stub handlers and health check&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Config from the environment
&lt;/h3&gt;

&lt;p&gt;Config comes from environment variables, mapped onto structs with &lt;a href="https://github.com/sethvargo/go-envconfig" rel="noopener noreferrer"&gt;go-envconfig&lt;/a&gt;. Each package owns its own config struct, and the top-level config just composes them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// internal/server/config.go&lt;/span&gt;
&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;Config&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Addr&lt;/span&gt;              &lt;span class="kt"&gt;string&lt;/span&gt;        &lt;span class="s"&gt;`env:"SERVER_ADDR, default=:8080"`&lt;/span&gt;
    &lt;span class="n"&gt;ShutdownTimeout&lt;/span&gt;   &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Duration&lt;/span&gt; &lt;span class="s"&gt;`env:"SERVER_SHUTDOWN_TIMEOUT, default=30s"`&lt;/span&gt;
    &lt;span class="n"&gt;ReadHeaderTimeout&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Duration&lt;/span&gt; &lt;span class="s"&gt;`env:"SERVER_READ_HEADER_TIMEOUT, default=5s"`&lt;/span&gt;
    &lt;span class="c"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;// internal/config/config.go&lt;/span&gt;
&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;Config&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Server&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;server&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Config&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When the database and auth packages arrive, they'll add their own &lt;code&gt;Config&lt;/code&gt; next to their code instead of one giant struct growing in a single file.&lt;/p&gt;

&lt;p&gt;There's also a test that loads the config with no &lt;code&gt;SERVER_*&lt;/code&gt; 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 &lt;code&gt;.env.example&lt;/code&gt; every time a default changes, so the code and the docs don't drift apart.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;main.go&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;main.go&lt;/code&gt; stays small: load the config, build the generated handler, wrap it, and run an &lt;code&gt;http.Server&lt;/code&gt; with timeouts and graceful shutdown:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stop&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;signal&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NotifyContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Background&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;syscall&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SIGINT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;syscall&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SIGTERM&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;defer&lt;/span&gt; &lt;span class="n"&gt;stop&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="n"&gt;cfg&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;New&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;serverInterface&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;api&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewStrictHandlerWithOptions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;server&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;New&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;server&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewServerMiddlewares&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;server&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewServerOptions&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;span class="n"&gt;handler&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;api&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HandlerWithOptions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;serverInterface&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;api&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ChiServerOptions&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;BaseURL&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"/api/v1"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On &lt;code&gt;SIGINT&lt;/code&gt; or &lt;code&gt;SIGTERM&lt;/code&gt; the server stops accepting new connections and gives in-flight requests up to &lt;code&gt;SERVER_SHUTDOWN_TIMEOUT&lt;/code&gt; to finish.&lt;/p&gt;

&lt;h3&gt;
  
  
  501 for everything that isn't built yet
&lt;/h3&gt;

&lt;p&gt;The generated interface has 17 methods, and I've implemented one. The stubs for the rest started out as &lt;code&gt;panic("implement me")&lt;/code&gt;. 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.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;501 Not Implemented&lt;/code&gt; 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 &lt;code&gt;error&lt;/code&gt; a method returns goes to a configurable &lt;code&gt;ResponseErrorHandlerFunc&lt;/code&gt;. So every stub returns a sentinel error:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;ErrNotImplemented&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;New&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"not implemented"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="n"&gt;server&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;ListPosts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt; &lt;span class="n"&gt;api&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ListPostsRequestObject&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;api&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ListPostsResponseObject&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ErrNotImplemented&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and the error handler maps it to a status code and writes a &lt;code&gt;problem+json&lt;/code&gt; body using the &lt;code&gt;Problem&lt;/code&gt; type generated from the spec:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;responseErrorHandler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ResponseWriter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusInternalServerError&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Is&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ErrNotImplemented&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusNotImplemented&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Printf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"%s %s %s"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Method&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;URL&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;writeProblem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A nice side effect: real errors returned from handlers now also come back as a clean &lt;code&gt;500&lt;/code&gt; 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.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;curl http://localhost:8080/api/v1/posts
&lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"status"&lt;/span&gt;:501,&lt;span class="s2"&gt;"title"&lt;/span&gt;:&lt;span class="s2"&gt;"Not Implemented"&lt;/span&gt;,&lt;span class="s2"&gt;"type"&lt;/span&gt;:&lt;span class="s2"&gt;"about:blank"&lt;/span&gt;&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Health check
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;GET /api/v1/healthz&lt;/code&gt; returns &lt;code&gt;{"status":"OK"}&lt;/code&gt;. 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: One command for the whole dev environment
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Commit:&lt;/strong&gt; &lt;a href="https://github.com/davidporos92/margin-cms/commit/a851c73" rel="noopener noreferrer"&gt;tooling: add Docker Compose dev environment&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;docker compose up&lt;/code&gt; starts three services:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Service&lt;/th&gt;
&lt;th&gt;Port&lt;/th&gt;
&lt;th&gt;What it is&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;api&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;8080&lt;/td&gt;
&lt;td&gt;The Go API, rebuilt on every save by &lt;a href="https://github.com/air-verse/air" rel="noopener noreferrer"&gt;Air&lt;/a&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;apidocs&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;8081&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;redocly preview&lt;/code&gt;: the API docs with a "Try it" console&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;apimock&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;8082&lt;/td&gt;
&lt;td&gt;Prism, serving mock responses from the spec&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The &lt;code&gt;api&lt;/code&gt; container mounts the source code and runs &lt;code&gt;go tool air&lt;/code&gt;, so Air is pinned in &lt;code&gt;go.mod&lt;/code&gt; 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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I still need Prism?
&lt;/h3&gt;

&lt;p&gt;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?&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;Prefer&lt;/code&gt; header, so the frontend can ask for a specific response, like &lt;code&gt;Prefer: code=412&lt;/code&gt; for the edit-conflict case. That's what the frontend needs, so Prism stays.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: The request that arrived and never came back
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Commit:&lt;/strong&gt; &lt;a href="https://github.com/davidporos92/margin-cms/commit/88a35c1" rel="noopener noreferrer"&gt;api: add CORS middleware&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;With everything running, I opened the docs on &lt;code&gt;localhost:8081&lt;/code&gt;, pointed the "Try it" console at my real API on &lt;code&gt;localhost:8080&lt;/code&gt;, and called the health check. The API logged the request. The docs page showed no response at all.&lt;/p&gt;

&lt;h3&gt;
  
  
  Part one: CORS
&lt;/h3&gt;

&lt;p&gt;The server did its job. The browser threw the response away.&lt;/p&gt;

&lt;p&gt;A different port is a different origin, so a page on &lt;code&gt;:8081&lt;/code&gt; calling &lt;code&gt;:8080&lt;/code&gt; makes a cross-origin request. A plain &lt;code&gt;GET&lt;/code&gt; 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 &lt;code&gt;Access-Control-Allow-Origin&lt;/code&gt; header, so the browser refuses to let the page read it. The devtools console says so, if you think to look there.&lt;/p&gt;

&lt;p&gt;The fix is the &lt;a href="https://github.com/go-chi/cors" rel="noopener noreferrer"&gt;go-chi/cors&lt;/a&gt; middleware. One detail matters: it has to run before chi's routing, so I wrap the whole handler at the &lt;code&gt;net/http&lt;/code&gt; level instead of adding it to the strict server's middleware list. A preflight &lt;code&gt;OPTIONS&lt;/code&gt; request doesn't match any generated route (chi answers 405 or 404 itself), so per-operation middleware never sees it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;handler&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cors&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Handler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cors&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Options&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;AllowedOrigins&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;   &lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Server&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AllowedOrigins&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;AllowedHeaders&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;   &lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Server&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AllowedHeaders&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;AllowedMethods&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;   &lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Server&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AllowedMethods&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;AllowCredentials&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Server&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AllowCredentials&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;})(&lt;/span&gt;&lt;span class="n"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Part two: &lt;code&gt;*&lt;/code&gt; means "all methods" in the spec, but not in go-chi/cors
&lt;/h3&gt;

&lt;p&gt;I added CORS, set everything to &lt;code&gt;*&lt;/code&gt; in my local env to get going, and still got nothing.&lt;/p&gt;

&lt;p&gt;Calling the API with &lt;code&gt;curl&lt;/code&gt; and an &lt;code&gt;Origin&lt;/code&gt; header showed the middleware was running, since the response had &lt;code&gt;Vary: Origin&lt;/code&gt;, but there was still no &lt;code&gt;Access-Control-Allow-Origin&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That surprised me, because &lt;code&gt;*&lt;/code&gt; is a valid value for the &lt;a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Access-Control-Allow-Methods" rel="noopener noreferrer"&gt;&lt;code&gt;Access-Control-Allow-Methods&lt;/code&gt;&lt;/a&gt; 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, &lt;code&gt;*&lt;/code&gt; is a wildcard for &lt;code&gt;AllowedOrigins&lt;/code&gt; and &lt;code&gt;AllowedHeaders&lt;/code&gt;, but not for &lt;code&gt;AllowedMethods&lt;/code&gt;. There, &lt;code&gt;*&lt;/code&gt; is compared literally against the request method, as if it were a method called &lt;code&gt;*&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;GET&lt;/code&gt; was a simple request with no preflight, and &lt;code&gt;GET&lt;/code&gt; 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 &lt;code&gt;200 OK&lt;/code&gt;, just without any CORS headers.&lt;/p&gt;

&lt;p&gt;Even a library that passed &lt;code&gt;*&lt;/code&gt; through wouldn't have saved this config, because I also had &lt;code&gt;AllowCredentials: true&lt;/code&gt;. For requests with credentials (cookies, TLS client certificates or HTTP authentication), browsers treat &lt;code&gt;*&lt;/code&gt; in &lt;code&gt;Access-Control-Allow-Methods&lt;/code&gt; and &lt;code&gt;Access-Control-Allow-Headers&lt;/code&gt; as a literal name. That setting had a problem of its own, which is part three.&lt;/p&gt;

&lt;p&gt;Listing the methods explicitly fixed it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;SERVER_ALLOWED_METHODS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"GET,POST,PUT,PATCH,DELETE,OPTIONS"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Part three: don't ship the permissive version
&lt;/h3&gt;

&lt;p&gt;While I was there, I noticed a config that only worked by accident. &lt;code&gt;AllowedOrigins: *&lt;/code&gt; with &lt;code&gt;AllowCredentials: true&lt;/code&gt; makes go-chi/cors send &lt;code&gt;Access-Control-Allow-Origin: *&lt;/code&gt; next to &lt;code&gt;Access-Control-Allow-Credentials: true&lt;/code&gt;, 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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;AllowedOrigins&lt;/span&gt;   &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="s"&gt;`env:"SERVER_ALLOWED_ORIGINS, default=http://localhost:8081"`&lt;/span&gt;
&lt;span class="n"&gt;AllowedMethods&lt;/span&gt;   &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="s"&gt;`env:"SERVER_ALLOWED_METHODS, default=GET,POST,PUT,PATCH,DELETE,OPTIONS"`&lt;/span&gt;
&lt;span class="n"&gt;AllowedHeaders&lt;/span&gt;   &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="s"&gt;`env:"SERVER_ALLOWED_HEADERS, default=Authorization,Content-Type"`&lt;/span&gt;
&lt;span class="n"&gt;AllowCredentials&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt;     &lt;span class="s"&gt;`env:"SERVER_ALLOW_CREDENTIALS, default=false"`&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If it works locally with &lt;code&gt;*&lt;/code&gt;, that's a sign to tighten it, not a sign that you're done.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell myself before starting
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;When the server says 200 and the client sees nothing, the browser is the one dropping the response. Start debugging there.&lt;/li&gt;
&lt;li&gt;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.&lt;/li&gt;
&lt;li&gt;Pin your code generators. Generated code is only reproducible if the generator version is.&lt;/li&gt;
&lt;li&gt;Test your defaults. It's the cheapest test you'll write and it keeps config and docs honest.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Next steps
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Add a linter.&lt;/strong&gt; &lt;a href="https://golangci-lint.run/" rel="noopener noreferrer"&gt;golangci-lint&lt;/a&gt; with a config that's strict from day one, while there's still almost no code to fix.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Start on auth with mock data.&lt;/strong&gt; Login, token refresh, logout and &lt;code&gt;GET /me&lt;/code&gt;, backed by an in-memory user for now, so the auth flow and its tests exist before the database does.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;In &lt;a href="https://davidporos92.github.io/posts/building-margincms-part-3-tools-out-of-go-mod-a-strict-linter-and-a-validated-edge/" rel="noopener noreferrer"&gt;part 3&lt;/a&gt; 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.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://davidporos92.github.io/posts/building-margincms-part-2-a-go-server-generated-from-the-spec/" rel="noopener noreferrer"&gt;my blog&lt;/a&gt;, where the comments live. The code for this post is &lt;a href="https://github.com/davidporos92/margin-cms/tree/part-2" rel="noopener noreferrer"&gt;on GitHub&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>go</category>
      <category>openapi</category>
      <category>docker</category>
      <category>margincms</category>
    </item>
    <item>
      <title>Building MarginCMS, part 1: Contract first, code later</title>
      <dc:creator>Dávid Pörös</dc:creator>
      <pubDate>Wed, 07 Oct 2026 14:41:20 +0000</pubDate>
      <link>https://dev.to/davidporos92/building-margincms-part-1-contract-first-code-later-141g</link>
      <guid>https://dev.to/davidporos92/building-margincms-part-1-contract-first-code-later-141g</guid>
      <description>&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The code is public at &lt;a href="https://github.com/davidporos92/margin-cms" rel="noopener noreferrer"&gt;github.com/davidporos92/margin-cms&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why build a CMS in 2026?
&lt;/h2&gt;

&lt;p&gt;Nobody needs another CMS, including me. I'm building one anyway, for four reasons.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It's fun.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deliberate practice.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It starts easy and gets hard.&lt;/strong&gt; A CMS starts as CRUD over a &lt;code&gt;posts&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;There's a lot of room to grow.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;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 &lt;a href="https://www.instagram.com/pck.kalandjai" rel="noopener noreferrer"&gt;small little doggo&lt;/a&gt;), and that shapes almost every decision below.&lt;/p&gt;

&lt;h2&gt;
  
  
  The scope, briefly
&lt;/h2&gt;

&lt;p&gt;To keep it finishable, v1 is deliberately boring:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;one admin user, one content type (posts), no media uploads, no plugin system&lt;/li&gt;
&lt;li&gt;posts are markdown with a title, slug, excerpt, tags and a status (&lt;code&gt;draft&lt;/code&gt;, &lt;code&gt;published&lt;/code&gt;, &lt;code&gt;archived&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;every content change creates a revision, and an activity log records who did what&lt;/li&gt;
&lt;li&gt;one Go binary with small internal packages (&lt;code&gt;auth&lt;/code&gt;, &lt;code&gt;posts&lt;/code&gt;, &lt;code&gt;revisions&lt;/code&gt;, &lt;code&gt;activity&lt;/code&gt;, &lt;code&gt;stats&lt;/code&gt;)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;"Modular" here means compile-time packages with small interfaces, not a dynamic content-type builder.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Write the OpenAPI contract first
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Commit:&lt;/strong&gt; &lt;a href="https://github.com/davidporos92/margin-cms/commit/065a09c" rel="noopener noreferrer"&gt;Add OpenAPI contract and spec tooling&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Before writing a single handler, I wrote the API as an OpenAPI 3 spec. That had two goals.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Don't block the frontend.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Get a clear picture of what I'm building.&lt;/strong&gt; Writing the contract forced decisions I would otherwise have made halfway through a handler:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Auth:&lt;/strong&gt; a short-lived access token in &lt;code&gt;Authorization: Bearer&lt;/code&gt;, plus a single-use refresh token. No cookies, so classic CSRF doesn't apply; XSS is the thing to guard against instead.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Errors:&lt;/strong&gt; every error is &lt;code&gt;application/problem+json&lt;/code&gt; (RFC 9457), with field-level details for form validation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Optimistic concurrency:&lt;/strong&gt; posts carry an &lt;code&gt;ETag&lt;/code&gt;, and updates, deletes and revision restores require &lt;code&gt;If-Match&lt;/code&gt;. A stale write gets &lt;code&gt;412 Precondition Failed&lt;/code&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pagination:&lt;/strong&gt; cursor-based, not offset-based.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The result is 17 operations across auth, posts (including bulk actions and markdown export), revisions, tags, activity, stats and a health check.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;openapi.yaml&lt;/code&gt;. The convention is simple: file name equals component name. &lt;code&gt;components/schemas/Post.yaml&lt;/code&gt; defines &lt;code&gt;Post&lt;/code&gt;. A bundler merges everything into a single file for the tools that need one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Set up tooling around the spec
&lt;/h2&gt;

&lt;p&gt;A spec is only useful if the tooling around it is easy to use, so the same commit adds npm scripts and &lt;code&gt;make&lt;/code&gt; targets for:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Need&lt;/th&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Lint the spec&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://redocly.com/docs/cli/" rel="noopener noreferrer"&gt;Redocly CLI&lt;/a&gt; (&lt;code&gt;redocly lint&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bundle the split files into one&lt;/td&gt;
&lt;td&gt;Redocly CLI (&lt;code&gt;redocly bundle&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Browse the docs&lt;/td&gt;
&lt;td&gt;Redocly&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mock server&lt;/td&gt;
&lt;td&gt;&lt;a href="https://stoplight.io/open-source/prism" rel="noopener noreferrer"&gt;Prism&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TypeScript types&lt;/td&gt;
&lt;td&gt;&lt;a href="https://openapi-ts.dev/" rel="noopener noreferrer"&gt;openapi-typescript&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Go types and server&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://github.com/oapi-codegen/oapi-codegen" rel="noopener noreferrer"&gt;oapi-codegen&lt;/a&gt; (wired up in part 2)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Prism deserves a special mention. Besides returning example responses, it supports the &lt;code&gt;Prefer&lt;/code&gt; header. The frontend can send &lt;code&gt;Prefer: code=412&lt;/code&gt; and get a 412 back, which makes it possible to build the conflict handling long before the backend can produce a real conflict.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Plan the Go backend
&lt;/h2&gt;

&lt;p&gt;No commit for this one. It's the thinking that happened before the code.&lt;/p&gt;

&lt;p&gt;I picked tools in three groups.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The HTTP server.&lt;/strong&gt; I went with &lt;a href="https://github.com/go-chi/chi" rel="noopener noreferrer"&gt;chi&lt;/a&gt;. I've used it before, it stays close to &lt;code&gt;net/http&lt;/code&gt;, and oapi-codegen can generate a chi server directly from the spec. More on that in part 2.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Data access.&lt;/strong&gt; 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:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;PostgreSQL&lt;/strong&gt; with &lt;a href="https://github.com/jackc/pgx" rel="noopener noreferrer"&gt;pgx&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://entgo.io/" rel="noopener noreferrer"&gt;Ent&lt;/a&gt;&lt;/strong&gt; for the schema and data access, with the schema written as Go code&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://atlasgo.io/" rel="noopener noreferrer"&gt;Atlas&lt;/a&gt;&lt;/strong&gt; to generate versioned SQL migrations from the Ent schema. The API never changes the database schema on its own.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://golang.testcontainers.org/" rel="noopener noreferrer"&gt;testcontainers-go&lt;/a&gt;&lt;/strong&gt; for integration tests against a real Postgres, using the same migration files that ship&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Code generation from OpenAPI.&lt;/strong&gt; Handlers implement an interface generated from the spec, so the code can't drift from the contract without the compiler noticing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Plan the local dev setup
&lt;/h2&gt;

&lt;p&gt;Also mostly planning at this stage. The goal was a dev loop where everything runs with one command:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Go hot reload&lt;/strong&gt; with &lt;a href="https://github.com/air-verse/air" rel="noopener noreferrer"&gt;Air&lt;/a&gt;, so saving a file rebuilds and restarts the API&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prism&lt;/strong&gt; as the mock backend for the web app, for the &lt;code&gt;Prefer&lt;/code&gt; header support mentioned above&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Docker Compose&lt;/strong&gt; so we both get the same environment, including Postgres later&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Next steps
&lt;/h2&gt;

&lt;p&gt;In &lt;a href="https://davidporos92.github.io/posts/building-margincms-part-2-a-go-server-generated-from-the-spec/" rel="noopener noreferrer"&gt;part 2&lt;/a&gt; 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.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://davidporos92.github.io/posts/building-margincms-part-1-contract-first-code-later/" rel="noopener noreferrer"&gt;my blog&lt;/a&gt;, where the comments live. The code for this post is &lt;a href="https://github.com/davidporos92/margin-cms/tree/part-1" rel="noopener noreferrer"&gt;on GitHub&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>go</category>
      <category>openapi</category>
      <category>margincms</category>
    </item>
  </channel>
</rss>
