<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:blog="https://jonesrussell.github.io/blog/ns"><channel><title>Api-Design on Web Developer Blog</title><link>https://jonesrussell.github.io/blog/tags/api-design/</link><description>Recent content in Api-Design on Web Developer Blog</description><image><title>Web Developer Blog</title><url>https://jonesrussell.github.io/blog/images/og-default.png</url><link>https://jonesrussell.github.io/blog/images/og-default.png</link></image><generator>Hugo -- 0.165.0</generator><language>en-us</language><lastBuildDate>Sun, 30 Aug 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://jonesrussell.github.io/blog/tags/api-design/feed.xml" rel="self" type="application/rss+xml"/><item><title>Publishing a versioned API contract you can actually trust</title><link>https://jonesrussell.github.io/blog/publishing-a-versioned-api-contract/</link><pubDate>Sun, 30 Aug 2026 00:00:00 +0000</pubDate><guid>https://jonesrussell.github.io/blog/publishing-a-versioned-api-contract/</guid><category>go</category><blog:tag>go</blog:tag><blog:tag>api</blog:tag><blog:tag>openapi</blog:tag><blog:tag>api-design</blog:tag><description>How goformx freezes, packages, and checksums its OpenAPI contract so external clients can pin to a specific, verifiable release instead of a moving branch.</description><content:encoded><![CDATA[<p>Ahnii!</p>
<p>If you ship a public API, you eventually hit the same question from every client: &ldquo;what exactly am I integrating against, and will it change under me?&rdquo; <a href="https://github.com/goformx/goformx">goformx</a>, a Go forms service, answers with a generated OpenAPI contract that gets frozen, checksummed, and published as its own release artifact, separate from the application&rsquo;s version tags. Here&rsquo;s how that pipeline works: the CI drift check, the release packaging script, and the rules the published docs give clients for staying compatible.</p>
<h2 id="the-problem-generated-code-that-quietly-goes-stale">The problem: generated code that quietly goes stale</h2>
<p>goformx generates its TypeScript client types from an OpenAPI 3.1 spec using <code>openapi-typescript</code>. Generated files are easy to forget to regenerate after an API change, and once that happens, the published client silently disagrees with the server. The fix is a CI gate that fails the build if generated artifacts don&rsquo;t match a clean regeneration — <code>goforms/contracts/check-generated.mjs</code>:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-js" data-lang="js"><span style="display:flex;"><span><span style="color:#66d9ef">import</span> { <span style="color:#a6e22e">execFileSync</span> } <span style="color:#a6e22e">from</span> <span style="color:#e6db74">&#34;node:child_process&#34;</span>;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">import</span> { <span style="color:#a6e22e">fileURLToPath</span> } <span style="color:#a6e22e">from</span> <span style="color:#e6db74">&#34;node:url&#34;</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">const</span> <span style="color:#a6e22e">cwd</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">fileURLToPath</span>(<span style="color:#66d9ef">new</span> <span style="color:#a6e22e">URL</span>(<span style="color:#e6db74">&#34;../&#34;</span>, <span style="color:#66d9ef">import</span>.<span style="color:#a6e22e">meta</span>.<span style="color:#a6e22e">url</span>));
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">execFileSync</span>(<span style="color:#e6db74">&#34;git&#34;</span>, [<span style="color:#e6db74">&#34;diff&#34;</span>, <span style="color:#e6db74">&#34;--exit-code&#34;</span>, <span style="color:#e6db74">&#34;--&#34;</span>, <span style="color:#e6db74">&#34;contracts/generated&#34;</span>], { <span style="color:#a6e22e">cwd</span>, <span style="color:#a6e22e">stdio</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#34;inherit&#34;</span> });
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">const</span> <span style="color:#a6e22e">untracked</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">execFileSync</span>(<span style="color:#e6db74">&#34;git&#34;</span>, [<span style="color:#e6db74">&#34;ls-files&#34;</span>, <span style="color:#e6db74">&#34;--others&#34;</span>, <span style="color:#e6db74">&#34;--exclude-standard&#34;</span>, <span style="color:#e6db74">&#34;--&#34;</span>, <span style="color:#e6db74">&#34;contracts/generated&#34;</span>], { <span style="color:#a6e22e">cwd</span> }).<span style="color:#a6e22e">toString</span>().<span style="color:#a6e22e">trim</span>();
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">if</span> (<span style="color:#a6e22e">untracked</span>) <span style="color:#66d9ef">throw</span> <span style="color:#66d9ef">new</span> Error(<span style="color:#e6db74">`Generated artifacts must be committed:\n</span><span style="color:#e6db74">${</span><span style="color:#a6e22e">untracked</span><span style="color:#e6db74">}</span><span style="color:#e6db74">`</span>);
</span></span></code></pre></div><p>It regenerates the contract, then checks two things: no diff against what&rsquo;s committed, and no new untracked files under <code>contracts/generated</code>. Either failure means someone changed the API without regenerating and committing the client types.</p>
<h2 id="packaging-a-frozen-release">Packaging a frozen release</h2>
<p>Passing CI isn&rsquo;t the same as being safe to integrate against — <code>main</code> still moves. goformx solves that with <code>package-release.mjs</code>, which turns a specific commit into an immutable, checksummed bundle:</p>
<ul>
<li><strong>Refuses a dirty tree.</strong> It runs <code>git status --porcelain</code> first and throws if there are uncommitted changes — only a clean, verified commit gets packaged.</li>
<li><strong>Hashes committed bytes, not a checkout.</strong> It reads file contents with <code>git show HEAD:&lt;path&gt;</code> instead of the filesystem, so a contributor&rsquo;s line-ending settings can&rsquo;t change the published SHA-256.</li>
<li><strong>Requires an explicit semantic version.</strong> It reads <code>info.version</code> out of the generated <code>openapi.json</code> and rejects anything that isn&rsquo;t <code>MAJOR.MINOR.PATCH</code>.</li>
<li><strong>Builds a manifest of content-addressed URLs.</strong> Each artifact (OpenAPI spec, form-definition schema, auth assertion schema, generated client types, example archive) gets a <code>raw.githubusercontent.com/&lt;repo&gt;/&lt;exact-commit-sha&gt;/...</code> URL plus its own SHA-256, so a client can verify what it downloaded actually matches that commit.</li>
<li><strong>Never touches the network.</strong> The script only writes files to a local, git-ignored <code>.contract-release/</code> directory — publishing the GitHub release is a separate, deliberate step.</li>
</ul>
<p>That last point matters: packaging and publishing are decoupled on purpose, so building the artifact can&rsquo;t accidentally ship it.</p>
<h2 id="what-clients-are-told-to-do-with-it">What clients are told to do with it</h2>
<p>The published guide, <code>docs/api-clients.md</code>, gives external integrators (including the project&rsquo;s own agent tooling) a discovery list rather than a single &ldquo;latest&rdquo; link:</p>
<ul>
<li>Current release tag, manifest, OpenAPI download, client example archive, and <code>SHA256SUMS</code></li>
<li>Links to the <strong>previous</strong> frozen versions (so a client mid-migration isn&rsquo;t stranded)</li>
<li>A pointer to the <strong>current development contract</strong> on <code>main</code>, explicitly labeled as not immutable</li>
</ul>
<p>The guidance is blunt about the distinction that matters most: a published contract describes the <em>interface</em>, not the <em>deployment state</em> of any given server. Clients are told to confirm the target deployment actually supports the pinned version before integrating — a frozen spec is not proof anything is live.</p>
<h2 id="the-credential-model-at-a-glance">The credential model, at a glance</h2>
<p>The same doc lays out three credential types and where each is allowed to live:</p>
<table>
	<thead>
			<tr>
					<th>Credential</th>
					<th>Where it belongs</th>
					<th>Authority</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td><code>gfpk_</code> public form key</td>
					<td>Browser embeds and public submission clients</td>
					<td>Published schema/submission access only; never management access</td>
			</tr>
			<tr>
					<td><code>gfst_</code> service token</td>
					<td>External agents and custom-dashboard servers, in secret custody</td>
					<td>One organization, explicit scopes, expiry and revocation</td>
			</tr>
			<tr>
					<td>First-party assertion (<code>gofx-fpa+jwt</code>)</td>
					<td>Server-to-server request only</td>
					<td>Verified user and resolved organization; signed, audience-bound, single-use, at most <strong>60 seconds</strong></td>
			</tr>
	</tbody>
</table>
<p>Every management operation also declares its own <code>x-goformx-required-scopes</code> in the spec — <strong>eight scopes total</strong> (<code>forms:read/write/publish</code>, <code>submissions:read</code>, <code>tokens:read/write</code>, <code>webhooks:read/write</code>). Write access doesn&rsquo;t implicitly grant publish access. Reading submissions doesn&rsquo;t implicitly grant webhook configuration.</p>
<h2 id="semantics-that-outlive-any-one-endpoint">Semantics that outlive any one endpoint</h2>
<p>A few rules from the contract are the kind of thing that usually gets documented only after someone gets bitten by their absence:</p>
<ul>
<li><strong>Additive fields need an explicit contract bump.</strong> Contract <strong>1.1.0</strong> added a stored <code>allowedOrigins</code> array to form responses. The docs are explicit that an empty array means no cross-origin grant — never treat it as a wildcard — and that older servers omitting the field entirely is not the same as an empty configuration.</li>
<li><strong>Numeric precision is a first-class guarantee.</strong> Contract <strong>1.1.1</strong> states that schemas and submission values retain numeric precision, with published token/exponent/decimal-place budgets, because JSONB storage can normalize spelling but must not normalize value.</li>
<li><strong>Concurrency is ETag-based.</strong> Metadata <code>PATCH</code> requires the ETag from a current <code>GET</code> in <code>If-Match</code>; a <code>428</code> means fetch a fresh validator, a <code>412</code> means reconcile before retrying.</li>
<li><strong>Idempotency keys are retry-scoped, not create-scoped.</strong> Public submissions require one; management creation has no general idempotency contract, so an uncertain create/publish response has to be reconciled rather than blindly retried.</li>
</ul>
<h2 id="the-release-checklist">The release checklist</h2>
<p>Maintainers publish a new frozen contract with a fixed sequence:</p>
<ol>
<li>Bump <code>info.version</code> for a new contract — never republish an existing version. Regenerate, verify, and commit source and generated files.</li>
<li>Run the packaging script from <code>goforms/</code>, which writes the manifest, OpenAPI copy, checksums, and client archive to the ignored release directory without any network call.</li>
<li>Create a <code>contract-vVERSION</code> tag at that exact commit and attach the four files to its GitHub release (not an application <code>v*</code> tag — contract releases are versioned independently).</li>
<li>Download the published artifacts anonymously, verify the SHA-256 digests and commit-pinned URLs, and run the example client against them.</li>
<li>Update the discovery doc for the new version, keeping the prior versions&rsquo; links live.</li>
</ol>
<p>Separating the contract&rsquo;s version from the application&rsquo;s version, and verifying the published artifact anonymously after the fact, closes the gap between &ldquo;we merged it&rdquo; and &ldquo;an outside client can safely depend on it.&rdquo;</p>
<p>Baamaapii</p>
]]></content:encoded></item></channel></rss>