Skip to content

XLSX Export from Go, and the Binary Guess That Missed

Adityo Guni Waluyo

openapi-fetch does have parseAs blob. The friction is binary-format typing and the DOM download flow.

TL;DR

I assumed openapi-fetch couldn't handle binary downloads, but checking the source showed it supports blobs just fine. The real hurdles were typing that masks blobs as strings and the manual DOM work to trigger downloads. On the backend, the Go export validates module filters and always returns a valid file via excelize, even when empty.

# XLSX Export from Go, and the "No Binary Support" Guess That Missed

A meeting needed data people could take home. Stakeholders at a government information-system project asked for an Excel file that opens offline, not a dashboard link that waits for a stable connection. That is how it works: numbers on screen for monitoring, files for folding into reports.

My first guess concerned the least glamorous part: binary responses. I assumed openapi-fetch could not handle binary responses, so I went straight to writing the download with a manual fetch.

When I idly opened the installed package's source, version 0.17, the guess collapsed. There is an option to parse the response as a blob. The response body type explicitly lists blob and arrayBuffer alongside json and text [11]. The client can. Two other things were the actual friction.

First, typing. An OpenAPI schema with a binary format maps to a string in the generated response type. So in the compiler's eyes the endpoint "returns a string", and casting it to a Blob felt like lying to it.

Second, once you have the Blob, the rest is simply not an HTTP client's job: create an object URL, attach it to an anchor, trigger the click, clean up afterwards. URL.createObjectURL() is what makes a blob-URL string pointing at the object [10], and it must be answered by revokeObjectURL() so memory does not leak [10]. This part is always hand-written; the only question is where to put it.

So the final decision stayed with the manual fetch, but the reason I recorded in the commit was too coarse. The accurate version: binary support exists, but the generated typing disguises itself as a string, and the remainder is DOM work. My guess's reputation: zero. Luckily I checked before writing it into documentation.

Backend side: an empty result is still a file

In Go I used excelize/v2, a pure-Go library for writing and reading XLSX files [9]. Two decisions that, in my view, make this admin-flavored export different from a generic Excel tutorial:

One, the ?module= query is an enum filter, not free text. Six valid values; anything else gets a 400. Better a user hits a clear error than receives a file whose contents do not match expectations.

Two, an empty query result must not become a 404 or a 500. The file still gets built, at minimum containing the headers of the two sheets, Entities and Announcements. A user exporting at the start of the month, when data is thin, still gets a file that opens normally. Four integration tests lock this behavior: filtered, unfiltered, unknown module, and an empty database that must still yield a valid file.

if module != "" && !validModules[module] {
    return nil, fmt.Errorf("%w: invalid module", ErrValidation)
}

f := excelize.NewFile()
defer f.Close()
// excelize names the first sheet "Sheet1" - use it as the entities sheet
_ = f.SetSheetName("Sheet1", "Entities")

An easy-to-miss detail: migration 000051 already dropped announcements.category_id, so the Announcements export maps the module to announcement_type instead of the old relation. An export that memorized the stale relation would break as new data arrives through the different path.

Frontend side: the download pattern with a fixed length

const res = await fetch(`${API_BASE_URL}/admin/export/xlsx`, {
  headers: authHeaders(),
});
if (!res.ok) {
  const body = await res.json().catch(() => null);
  throw new AdminApiError(res.status, body?.error ?? "Export failed");
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `report-export-${new Date().toISOString().slice(0, 10)}.xlsx`;
a.click();
URL.revokeObjectURL(url);

The small consequence: client.ts now exports API_BASE_URL, so the constant is usable outside the openapi-fetch wrapper. One door for JSON, one small door for binary. Not elegant, but honest to the compiler and to the reader.

If another binary endpoint shows up tomorrow, my consideration is not "supported or not" but: is the typing clear, and does the download flow need the DOM. Two yeses mean the manual fetch stands on its own, rather than being a shortcut taken because nobody read the source.

Sources

[9] https://github.com/xuri/excelize/blob/master/README.md

[10] https://developer.mozilla.org/en-US/docs/Web/API/URL/createObjectURL_static

[11] https://github.com/openapi-ts/openapi-typescript/blob/openapi-fetch%400.17.0/packages/openapi-fetch/src/index.d.ts

Related articles