<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://davidporos92.github.io/feed.xml" rel="self" type="application/atom+xml" /><link href="https://davidporos92.github.io/" rel="alternate" type="text/html" /><updated>2026-10-07T23:26:54+02:00</updated><id>https://davidporos92.github.io/feed.xml</id><title type="html">Dávid Pörös</title><subtitle>Backend engineer since 2013, building pet projects in the open: the decisions, the trade-offs and what broke. Every post links to code you can run.</subtitle><author><name>Dávid Pörös</name></author><entry><title type="html">Building MarginCMS, part 2: A Go server generated from the spec</title><link href="https://davidporos92.github.io/posts/building-margincms-part-2-a-go-server-generated-from-the-spec/" rel="alternate" type="text/html" title="Building MarginCMS, part 2: A Go server generated from the spec" /><published>2026-10-07T00:00:00+02:00</published><updated>2026-10-07T00:00:00+02:00</updated><id>https://davidporos92.github.io/posts/building-margincms-part-2-a-go-server-generated-from-the-spec</id><content type="html" xml:base="https://davidporos92.github.io/posts/building-margincms-part-2-a-go-server-generated-from-the-spec/"><![CDATA[<p>In <a href="/posts/building-margincms-part-1-contract-first-code-later/">part 1</a> 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.</p>

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

<h2 id="why-generate-the-server-at-all">Why generate the server at all?</h2>

<p>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.</p>

<h2 id="step-1-choose-the-server-and-generate-it-from-the-spec">Step 1: Choose the server and generate it from the spec</h2>

<p><strong>Commit:</strong> <a href="https://github.com/davidporos92/margin-cms/commit/4ca2fb4">api: generate Go server and types from the OpenAPI spec</a></p>

<h3 id="chi">chi</h3>

<p>I picked <a href="https://github.com/go-chi/chi">chi</a> as the router:</p>

<ul>
  <li>I’ve used it before, so it’s one less new thing.</li>
  <li>It’s a thin layer on top of <code class="language-plaintext highlighter-rouge">net/http</code>. Handlers are plain <code class="language-plaintext highlighter-rouge">http.Handler</code>s, and anything from the standard library works with it.</li>
  <li>It has a good community and plenty of middleware and packages, including the CORS package that shows up later in this post.</li>
</ul>

<h3 id="oapi-codegen">oapi-codegen</h3>

<p><a href="https://github.com/oapi-codegen/oapi-codegen">oapi-codegen</a> turns the bundled spec into Go code. I generate four files, each from its own small config:</p>

<table>
  <thead>
    <tr>
      <th>Config</th>
      <th>Generates</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">models.yaml</code></td>
      <td>Request and response types</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">server.yaml</code></td>
      <td>A chi server and a <em>strict</em> server interface</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">client.yaml</code></td>
      <td>A typed Go client, useful for tests later</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">server_urls.yaml</code></td>
      <td>Constants for the server URLs in the spec</td>
    </tr>
  </tbody>
</table>

<p>The strict server is the important part. Instead of <code class="language-plaintext highlighter-rouge">func(w http.ResponseWriter, r *http.Request)</code>, each operation becomes a method with typed input and typed output:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="p">(</span><span class="n">s</span> <span class="n">server</span><span class="p">)</span> <span class="n">GetHealth</span><span class="p">(</span><span class="n">ctx</span> <span class="n">context</span><span class="o">.</span><span class="n">Context</span><span class="p">,</span> <span class="n">request</span> <span class="n">api</span><span class="o">.</span><span class="n">GetHealthRequestObject</span><span class="p">)</span> <span class="p">(</span><span class="n">api</span><span class="o">.</span><span class="n">GetHealthResponseObject</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span>
</code></pre></div></div>

<p>The generated code parses the path, query, headers and body, calls my method, and writes the response. My code never touches <code class="language-plaintext highlighter-rouge">http.Request</code>. 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.</p>

<h3 id="empty-string-or-no-field-at-all">Empty string or no field at all?</h3>

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

<p>oapi-codegen handles this the Go way. Optional fields become pointers:</p>

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

<p><code class="language-plaintext highlighter-rouge">nil</code> means “not sent”, and a pointer to <code class="language-plaintext highlighter-rouge">""</code> means “set it to empty” (for <code class="language-plaintext highlighter-rouge">excerpt</code> and <code class="language-plaintext highlighter-rouge">body</code>; <code class="language-plaintext highlighter-rouge">title</code> has <code class="language-plaintext highlighter-rouge">minLength: 1</code>). That’s exactly what the <code class="language-plaintext highlighter-rouge">PATCH</code> semantics need.</p>

<p>One caveat: an explicit <code class="language-plaintext highlighter-rouge">"excerpt": null</code> also decodes to <code class="language-plaintext highlighter-rouge">nil</code>, 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 <code class="language-plaintext highlighter-rouge">nullable-type</code> output option generates <code class="language-plaintext highlighter-rouge">nullable.Nullable[T]</code> for it.</p>

<h3 id="tools-pinned-with-go-tool">Tools pinned with <code class="language-plaintext highlighter-rouge">go tool</code></h3>

<p>My first version installed the generator with <code class="language-plaintext highlighter-rouge">go install ...@latest</code>. 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.</p>

<p>Since Go 1.24, <code class="language-plaintext highlighter-rouge">go.mod</code> can track tools the same way it tracks dependencies:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>tool (
	github.com/air-verse/air
	github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen
)
</code></pre></div></div>

<p>Now <code class="language-plaintext highlighter-rouge">go tool oapi-codegen</code> always runs the exact version in <code class="language-plaintext highlighter-rouge">go.mod</code>, and the root <code class="language-plaintext highlighter-rouge">Makefile</code> uses it:</p>

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

<p>The trade-off is that the tools’ dependencies show up in <code class="language-plaintext highlighter-rouge">go.mod</code> 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.</p>

<h2 id="step-2-config-stubs-and-a-health-check">Step 2: Config, stubs and a health check</h2>

<p><strong>Commit:</strong> <a href="https://github.com/davidporos92/margin-cms/commit/ae3c5d7">api: add server skeleton with config, stub handlers and health check</a></p>

<h3 id="config-from-the-environment">Config from the environment</h3>

<p>Config comes from environment variables, mapped onto structs with <a href="https://github.com/sethvargo/go-envconfig">go-envconfig</a>. Each package owns its own config struct, and the top-level config just composes them:</p>

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

<span class="c">// internal/config/config.go</span>
<span class="k">type</span> <span class="n">Config</span> <span class="k">struct</span> <span class="p">{</span>
	<span class="n">Server</span> <span class="o">*</span><span class="n">server</span><span class="o">.</span><span class="n">Config</span>
<span class="p">}</span>
</code></pre></div></div>

<p>When the database and auth packages arrive, they’ll add their own <code class="language-plaintext highlighter-rouge">Config</code> next to their code instead of one giant struct growing in a single file.</p>

<p>There’s also a test that loads the config with no <code class="language-plaintext highlighter-rouge">SERVER_*</code> 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 <code class="language-plaintext highlighter-rouge">.env.example</code> every time a default changes, so the code and the docs don’t drift apart.</p>

<h3 id="maingo"><code class="language-plaintext highlighter-rouge">main.go</code></h3>

<p><code class="language-plaintext highlighter-rouge">main.go</code> stays small: load the config, build the generated handler, wrap it, and run an <code class="language-plaintext highlighter-rouge">http.Server</code> with timeouts and graceful shutdown:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">ctx</span><span class="p">,</span> <span class="n">stop</span> <span class="o">:=</span> <span class="n">signal</span><span class="o">.</span><span class="n">NotifyContext</span><span class="p">(</span><span class="n">context</span><span class="o">.</span><span class="n">Background</span><span class="p">(),</span> <span class="n">syscall</span><span class="o">.</span><span class="n">SIGINT</span><span class="p">,</span> <span class="n">syscall</span><span class="o">.</span><span class="n">SIGTERM</span><span class="p">)</span>
<span class="k">defer</span> <span class="n">stop</span><span class="p">()</span>

<span class="n">cfg</span> <span class="o">:=</span> <span class="n">config</span><span class="o">.</span><span class="n">New</span><span class="p">(</span><span class="n">ctx</span><span class="p">)</span>

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

<p>On <code class="language-plaintext highlighter-rouge">SIGINT</code> or <code class="language-plaintext highlighter-rouge">SIGTERM</code> the server stops accepting new connections and gives in-flight requests up to <code class="language-plaintext highlighter-rouge">SERVER_SHUTDOWN_TIMEOUT</code> to finish.</p>

<h3 id="501-for-everything-that-isnt-built-yet">501 for everything that isn’t built yet</h3>

<p>The generated interface has 17 methods, and I’ve implemented one. The stubs for the rest started out as <code class="language-plaintext highlighter-rouge">panic("implement me")</code>. 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.</p>

<p><code class="language-plaintext highlighter-rouge">501 Not Implemented</code> 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 <code class="language-plaintext highlighter-rouge">error</code> a method returns goes to a configurable <code class="language-plaintext highlighter-rouge">ResponseErrorHandlerFunc</code>. So every stub returns a sentinel error:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">var</span> <span class="n">ErrNotImplemented</span> <span class="o">=</span> <span class="n">errors</span><span class="o">.</span><span class="n">New</span><span class="p">(</span><span class="s">"not implemented"</span><span class="p">)</span>

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

<p>and the error handler maps it to a status code and writes a <code class="language-plaintext highlighter-rouge">problem+json</code> body using the <code class="language-plaintext highlighter-rouge">Problem</code> type generated from the spec:</p>

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

	<span class="n">writeProblem</span><span class="p">(</span><span class="n">w</span><span class="p">,</span> <span class="n">status</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<p>A nice side effect: real errors returned from handlers now also come back as a clean <code class="language-plaintext highlighter-rouge">500</code> 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.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>$ curl http://localhost:8080/api/v1/posts
{"status":501,"title":"Not Implemented","type":"about:blank"}
</code></pre></div></div>

<h3 id="health-check">Health check</h3>

<p><code class="language-plaintext highlighter-rouge">GET /api/v1/healthz</code> returns <code class="language-plaintext highlighter-rouge">{"status":"OK"}</code>. 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.</p>

<h2 id="step-3-one-command-for-the-whole-dev-environment">Step 3: One command for the whole dev environment</h2>

<p><strong>Commit:</strong> <a href="https://github.com/davidporos92/margin-cms/commit/a851c73">tooling: add Docker Compose dev environment</a></p>

<p><code class="language-plaintext highlighter-rouge">docker compose up</code> starts three services:</p>

<table>
  <thead>
    <tr>
      <th>Service</th>
      <th>Port</th>
      <th>What it is</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">api</code></td>
      <td>8080</td>
      <td>The Go API, rebuilt on every save by <a href="https://github.com/air-verse/air">Air</a></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">apidocs</code></td>
      <td>8081</td>
      <td><code class="language-plaintext highlighter-rouge">redocly preview</code>: the API docs with a “Try it” console</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">apimock</code></td>
      <td>8082</td>
      <td>Prism, serving mock responses from the spec</td>
    </tr>
  </tbody>
</table>

<p>The <code class="language-plaintext highlighter-rouge">api</code> container mounts the source code and runs <code class="language-plaintext highlighter-rouge">go tool air</code>, so Air is pinned in <code class="language-plaintext highlighter-rouge">go.mod</code> 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.</p>

<h3 id="do-i-still-need-prism">Do I still need Prism?</h3>

<p>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?</p>

<p>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 <code class="language-plaintext highlighter-rouge">Prefer</code> header, so the frontend can ask for a specific response, like <code class="language-plaintext highlighter-rouge">Prefer: code=412</code> for the edit-conflict case. That’s what the frontend needs, so Prism stays.</p>

<h2 id="step-4-the-request-that-arrived-and-never-came-back">Step 4: The request that arrived and never came back</h2>

<p><strong>Commit:</strong> <a href="https://github.com/davidporos92/margin-cms/commit/88a35c1">api: add CORS middleware</a></p>

<p>With everything running, I opened the docs on <code class="language-plaintext highlighter-rouge">localhost:8081</code>, pointed the “Try it” console at my real API on <code class="language-plaintext highlighter-rouge">localhost:8080</code>, and called the health check. The API logged the request. The docs page showed no response at all.</p>

<h3 id="part-one-cors">Part one: CORS</h3>

<p>The server did its job. The browser threw the response away.</p>

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

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

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">handler</span> <span class="o">=</span> <span class="n">cors</span><span class="o">.</span><span class="n">Handler</span><span class="p">(</span><span class="n">cors</span><span class="o">.</span><span class="n">Options</span><span class="p">{</span>
	<span class="n">AllowedOrigins</span><span class="o">:</span>   <span class="n">cfg</span><span class="o">.</span><span class="n">Server</span><span class="o">.</span><span class="n">AllowedOrigins</span><span class="p">,</span>
	<span class="n">AllowedHeaders</span><span class="o">:</span>   <span class="n">cfg</span><span class="o">.</span><span class="n">Server</span><span class="o">.</span><span class="n">AllowedHeaders</span><span class="p">,</span>
	<span class="n">AllowedMethods</span><span class="o">:</span>   <span class="n">cfg</span><span class="o">.</span><span class="n">Server</span><span class="o">.</span><span class="n">AllowedMethods</span><span class="p">,</span>
	<span class="n">AllowCredentials</span><span class="o">:</span> <span class="n">cfg</span><span class="o">.</span><span class="n">Server</span><span class="o">.</span><span class="n">AllowCredentials</span><span class="p">,</span>
<span class="p">})(</span><span class="n">handler</span><span class="p">)</span>
</code></pre></div></div>

<h3 id="part-two--means-all-methods-in-the-spec-but-not-in-go-chicors">Part two: <code class="language-plaintext highlighter-rouge">*</code> means “all methods” in the spec, but not in go-chi/cors</h3>

<p>I added CORS, set everything to <code class="language-plaintext highlighter-rouge">*</code> in my local env to get going, and still got nothing.</p>

<p>Calling the API with <code class="language-plaintext highlighter-rouge">curl</code> and an <code class="language-plaintext highlighter-rouge">Origin</code> header showed the middleware was running, since the response had <code class="language-plaintext highlighter-rouge">Vary: Origin</code>, but there was still no <code class="language-plaintext highlighter-rouge">Access-Control-Allow-Origin</code>.</p>

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

<p>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 <code class="language-plaintext highlighter-rouge">GET</code> was a simple request with no preflight, and <code class="language-plaintext highlighter-rouge">GET</code> 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 <code class="language-plaintext highlighter-rouge">200 OK</code>, just without any CORS headers.</p>

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

<p>Listing the methods explicitly fixed it:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>SERVER_ALLOWED_METHODS="GET,POST,PUT,PATCH,DELETE,OPTIONS"
</code></pre></div></div>

<h3 id="part-three-dont-ship-the-permissive-version">Part three: don’t ship the permissive version</h3>

<p>While I was there, I noticed a config that only worked by accident. <code class="language-plaintext highlighter-rouge">AllowedOrigins: *</code> with <code class="language-plaintext highlighter-rouge">AllowCredentials: true</code> makes go-chi/cors send <code class="language-plaintext highlighter-rouge">Access-Control-Allow-Origin: *</code> next to <code class="language-plaintext highlighter-rouge">Access-Control-Allow-Credentials: true</code>, 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:</p>

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

<p>If it works locally with <code class="language-plaintext highlighter-rouge">*</code>, that’s a sign to tighten it, not a sign that you’re done.</p>

<h2 id="what-id-tell-myself-before-starting">What I’d tell myself before starting</h2>

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

<h2 id="next-steps">Next steps</h2>

<ol>
  <li><strong>Add a linter.</strong> <a href="https://golangci-lint.run/">golangci-lint</a> with a config that’s strict from day one, while there’s still almost no code to fix.</li>
  <li><strong>Start on auth with mock data.</strong> Login, token refresh, logout and <code class="language-plaintext highlighter-rouge">GET /me</code>, backed by an in-memory user for now, so the auth flow and its tests exist before the database does.</li>
</ol>]]></content><author><name>Dávid Pörös</name></author><category term="go" /><category term="openapi" /><category term="docker" /><category term="margincms" /><summary type="html"><![CDATA[Generating a typed chi server from the OpenAPI spec with oapi-codegen, stub handlers that return 501, a one-command Docker Compose setup, and a CORS bug that hid every response.]]></summary></entry><entry><title type="html">Building MarginCMS, part 1: Contract first, code later</title><link href="https://davidporos92.github.io/posts/building-margincms-part-1-contract-first-code-later/" rel="alternate" type="text/html" title="Building MarginCMS, part 1: Contract first, code later" /><published>2026-10-05T00:00:00+02:00</published><updated>2026-10-05T00:00:00+02:00</updated><id>https://davidporos92.github.io/posts/building-margincms-part-1-contract-first-code-later</id><content type="html" xml:base="https://davidporos92.github.io/posts/building-margincms-part-1-contract-first-code-later/"><![CDATA[<p>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.</p>

<p>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.</p>

<p>The code is public at <a href="https://github.com/davidporos92/margin-cms">github.com/davidporos92/margin-cms</a>.</p>

<h2 id="why-build-a-cms-in-2026">Why build a CMS in 2026?</h2>

<p>Nobody needs another CMS, including me. I’m building one anyway, for four reasons.</p>

<p><strong>It’s fun.</strong> 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.</p>

<p><strong>Deliberate practice.</strong> 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.</p>

<p><strong>It starts easy and gets hard.</strong> A CMS starts as CRUD over a <code class="language-plaintext highlighter-rouge">posts</code> 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.</p>

<p><strong>There’s a lot of room to grow.</strong> 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.</p>

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

<h2 id="the-scope-briefly">The scope, briefly</h2>

<p>To keep it finishable, v1 is deliberately boring:</p>

<ul>
  <li>one admin user, one content type (posts), no media uploads, no plugin system</li>
  <li>posts are markdown with a title, slug, excerpt, tags and a status (<code class="language-plaintext highlighter-rouge">draft</code>, <code class="language-plaintext highlighter-rouge">published</code>, <code class="language-plaintext highlighter-rouge">archived</code>)</li>
  <li>every content change creates a revision, and an activity log records who did what</li>
  <li>one Go binary with small internal packages (<code class="language-plaintext highlighter-rouge">auth</code>, <code class="language-plaintext highlighter-rouge">posts</code>, <code class="language-plaintext highlighter-rouge">revisions</code>, <code class="language-plaintext highlighter-rouge">activity</code>, <code class="language-plaintext highlighter-rouge">stats</code>)</li>
</ul>

<p>“Modular” here means compile-time packages with small interfaces, not a dynamic content-type builder.</p>

<h2 id="step-1-write-the-openapi-contract-first">Step 1: Write the OpenAPI contract first</h2>

<p><strong>Commit:</strong> <a href="https://github.com/davidporos92/margin-cms/commit/065a09c">Add OpenAPI contract and spec tooling</a></p>

<p>Before writing a single handler, I wrote the API as an OpenAPI 3 spec. That had two goals.</p>

<p><strong>Don’t block the frontend.</strong> 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.</p>

<p><strong>Get a clear picture of what I’m building.</strong> Writing the contract forced decisions I would otherwise have made halfway through a handler:</p>

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

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

<p>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 <code class="language-plaintext highlighter-rouge">openapi.yaml</code>. The convention is simple: file name equals component name. <code class="language-plaintext highlighter-rouge">components/schemas/Post.yaml</code> defines <code class="language-plaintext highlighter-rouge">Post</code>. A bundler merges everything into a single file for the tools that need one.</p>

<h2 id="step-2-set-up-tooling-around-the-spec">Step 2: Set up tooling around the spec</h2>

<p>A spec is only useful if the tooling around it is easy to use, so the same commit adds npm scripts and <code class="language-plaintext highlighter-rouge">make</code> targets for:</p>

<table>
  <thead>
    <tr>
      <th>Need</th>
      <th>Tool</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Lint the spec</td>
      <td><a href="https://redocly.com/docs/cli/">Redocly CLI</a> (<code class="language-plaintext highlighter-rouge">redocly lint</code>)</td>
    </tr>
    <tr>
      <td>Bundle the split files into one</td>
      <td>Redocly CLI (<code class="language-plaintext highlighter-rouge">redocly bundle</code>)</td>
    </tr>
    <tr>
      <td>Browse the docs</td>
      <td>Redocly</td>
    </tr>
    <tr>
      <td>Mock server</td>
      <td><a href="https://stoplight.io/open-source/prism">Prism</a></td>
    </tr>
    <tr>
      <td>TypeScript types</td>
      <td><a href="https://openapi-ts.dev/">openapi-typescript</a></td>
    </tr>
    <tr>
      <td>Go types and server</td>
      <td><a href="https://github.com/oapi-codegen/oapi-codegen">oapi-codegen</a> (wired up in part 2)</td>
    </tr>
  </tbody>
</table>

<p>Prism deserves a special mention. Besides returning example responses, it supports the <code class="language-plaintext highlighter-rouge">Prefer</code> header. The frontend can send <code class="language-plaintext highlighter-rouge">Prefer: code=412</code> and get a 412 back, which makes it possible to build the conflict handling long before the backend can produce a real conflict.</p>

<h2 id="step-3-plan-the-go-backend">Step 3: Plan the Go backend</h2>

<p>No commit for this one. It’s the thinking that happened before the code.</p>

<p>I picked tools in three groups.</p>

<p><strong>The HTTP server.</strong> I went with <a href="https://github.com/go-chi/chi">chi</a>. I’ve used it before, it stays close to <code class="language-plaintext highlighter-rouge">net/http</code>, and oapi-codegen can generate a chi server directly from the spec. More on that in part 2.</p>

<p><strong>Data access.</strong> 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:</p>

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

<p><strong>Code generation from OpenAPI.</strong> Handlers implement an interface generated from the spec, so the code can’t drift from the contract without the compiler noticing.</p>

<h2 id="step-4-plan-the-local-dev-setup">Step 4: Plan the local dev setup</h2>

<p>Also mostly planning at this stage. The goal was a dev loop where everything runs with one command:</p>

<ul>
  <li><strong>Go hot reload</strong> with <a href="https://github.com/air-verse/air">Air</a>, so saving a file rebuilds and restarts the API</li>
  <li><strong>Prism</strong> as the mock backend for the web app, for the <code class="language-plaintext highlighter-rouge">Prefer</code> header support mentioned above</li>
  <li><strong>Docker Compose</strong> so we both get the same environment, including Postgres later</li>
</ul>

<h2 id="next-steps">Next steps</h2>

<p>In <a href="/posts/building-margincms-part-2-a-go-server-generated-from-the-spec/">part 2</a> 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.</p>]]></content><author><name>Dávid Pörös</name></author><category term="go" /><category term="openapi" /><category term="margincms" /><summary type="html"><![CDATA[Why I'm building a small CMS in Go, and why the OpenAPI contract came before any code: a split spec, mocks and types for the frontend, and a plan for the backend.]]></summary></entry></feed>