feat(webid): add v1 API with TSV-first format and resolver endpoint

New API routes:
- GET /v1/releases/{pkg}.tab — list releases as TSV (with header)
- GET /v1/releases/{pkg}.json — list releases as JSON array
- GET /v1/resolve/{pkg}.tab — resolve best asset for platform (TSV)
- GET /v1/resolve/{pkg}.json — resolve best asset for platform (JSON)

Key design decisions:
- TSV as primary format via csvutil (easy for cut/grep/sort/agents)
- Go-native naming: darwin, x86_64, aarch64 (no legacy mapping)
- No quoted fields — spaces for lists within fields
- Always includes header row in TSV output
- Resolve endpoint returns single best match with triplet info

Query params: os, arch, libc, channel, version, lts, format, variant, limit
This commit is contained in:
AJ ONeal
2026-03-11 02:34:32 -06:00
parent 9269c32b9c
commit dd5f941eca
4 changed files with 709 additions and 0 deletions

View File

@@ -61,6 +61,10 @@ func main() {
// Legacy API routes (Node.js compat).
mux.HandleFunc("GET /api/releases/{rest...}", srv.handleReleasesAPI)
// New API routes (v1).
mux.HandleFunc("GET /v1/releases/{rest...}", srv.handleV1Releases)
mux.HandleFunc("GET /v1/resolve/{rest...}", srv.handleV1Resolve)
// Debug endpoint.
mux.HandleFunc("GET /api/debug", srv.handleDebug)

View File

@@ -65,6 +65,8 @@ func newTestServer(t *testing.T) (*server, *httptest.Server) {
mux := http.NewServeMux()
mux.HandleFunc("GET /api/releases/{rest...}", srv.handleReleasesAPI)
mux.HandleFunc("GET /v1/releases/{rest...}", srv.handleV1Releases)
mux.HandleFunc("GET /v1/resolve/{rest...}", srv.handleV1Resolve)
mux.HandleFunc("GET /api/debug", srv.handleDebug)
mux.HandleFunc("GET /{pkgSpec}", srv.handleBootstrap)

460
cmd/webid/v1api.go Normal file
View File

@@ -0,0 +1,460 @@
package main
import (
"encoding/json"
"fmt"
"net/http"
"slices"
"strings"
"github.com/jszwec/csvutil"
"github.com/webinstall/webi-installers/internal/buildmeta"
"github.com/webinstall/webi-installers/internal/lexver"
"github.com/webinstall/webi-installers/internal/resolver"
"github.com/webinstall/webi-installers/internal/storage"
)
// v1Release is a single release in the new API TSV format.
// Field order matters for csvutil — it determines column order.
// Fields are designed to be easy to consume with cut/grep/sort.
type v1Release struct {
Version string `csv:"version"`
Channel string `csv:"channel"`
LTS string `csv:"lts"`
Date string `csv:"date"`
OS string `csv:"os"`
Arch string `csv:"arch"`
Libc string `csv:"libc"`
Format string `csv:"format"`
Variants string `csv:"variants"` // space-separated
Download string `csv:"download"`
Filename string `csv:"filename"`
}
// v1ResolveResult is the response for /v1/resolve/{pkg}.
type v1ResolveResult struct {
Version string `csv:"version" json:"version"`
Channel string `csv:"channel" json:"channel"`
LTS string `csv:"lts" json:"lts"`
Date string `csv:"date" json:"date"`
OS string `csv:"os" json:"os"`
Arch string `csv:"arch" json:"arch"`
Libc string `csv:"libc" json:"libc"`
Format string `csv:"format" json:"format"`
Variants string `csv:"variants" json:"variants"`
Download string `csv:"download" json:"download"`
Filename string `csv:"filename" json:"filename"`
Triplet string `csv:"triplet" json:"triplet"`
}
// handleV1Releases serves /v1/releases/{pkg}.tsv (or .json)
// with Go-native naming and TSV-first format.
//
// Query params:
//
// os — filter by OS (darwin, linux, windows)
// arch — filter by arch (aarch64, x86_64, armv7l)
// libc — filter by libc (gnu, musl, msvc)
// channel — release channel (stable, beta, rc, alpha)
// version — version prefix filter (e.g. "1.20")
// lts — if "true", only LTS releases
// format — filter by format (e.g. "tar.gz")
// variant — filter by variant (e.g. "rocm")
// limit — max results (default 1000)
func (s *server) handleV1Releases(w http.ResponseWriter, r *http.Request) {
rest := r.PathValue("rest")
pkg, version, format, err := parseReleasePath(rest)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
pc := s.getPackage(pkg)
if pc == nil {
if s.isSelfHosted(pkg) {
s.v1ServeEmpty(w, format)
return
}
http.Error(w, fmt.Sprintf("package %q not found", pkg), http.StatusNotFound)
return
}
q := r.URL.Query()
osStr := q.Get("os")
archStr := q.Get("arch")
libcStr := q.Get("libc")
channelStr := q.Get("channel")
ltsStr := q.Get("lts")
formatFilter := q.Get("format")
variantStr := q.Get("variant")
limitStr := q.Get("limit")
// Use version from URL path or query.
if version == "" {
version = q.Get("version")
}
// Handle channel selectors in version field.
switch strings.ToLower(version) {
case "stable", "latest":
version = ""
if channelStr == "" {
channelStr = "stable"
}
case "lts":
version = ""
ltsStr = "true"
case "beta", "pre", "preview":
version = ""
if channelStr == "" {
channelStr = "beta"
}
case "rc":
version = ""
if channelStr == "" {
channelStr = "rc"
}
case "alpha", "dev":
version = ""
if channelStr == "" {
channelStr = "alpha"
}
}
lts := ltsStr == "true" || ltsStr == "1"
limit := 1000
if limitStr != "" {
fmt.Sscanf(limitStr, "%d", &limit)
}
// Filter assets directly (not via resolve.Dist).
filtered := filterAssets(pc.assets, osStr, archStr, libcStr, channelStr, version, formatFilter, variantStr, lts, limit)
// Sort newest-first.
sortAssetsDescending(filtered)
switch format {
case "json":
s.v1ServeJSON(w, filtered)
case "tab":
s.v1ServeTSV(w, filtered)
default:
http.Error(w, "unsupported format: "+format+" (use .json or .tab)", http.StatusBadRequest)
}
}
// handleV1Resolve serves /v1/resolve/{pkg}.tsv (or .json)
// It resolves the single best asset for a given platform.
//
// Query params:
//
// os — target OS (required)
// arch — target arch (required)
// libc — target libc
// version — version prefix
// channel — release channel
// lts — if "true", only LTS
// format — preferred formats (comma-separated, in preference order)
// variant — preferred variant
func (s *server) handleV1Resolve(w http.ResponseWriter, r *http.Request) {
rest := r.PathValue("rest")
pkg, version, format, err := parseReleasePath(rest)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
pc := s.getPackage(pkg)
if pc == nil {
http.Error(w, fmt.Sprintf("package %q not found", pkg), http.StatusNotFound)
return
}
q := r.URL.Query()
osStr := q.Get("os")
archStr := q.Get("arch")
libcStr := q.Get("libc")
channelStr := q.Get("channel")
ltsStr := q.Get("lts")
formatsStr := q.Get("format")
variantStr := q.Get("variant")
if version == "" {
version = q.Get("version")
}
// Handle channel selectors in version field.
switch strings.ToLower(version) {
case "stable", "latest":
version = ""
if channelStr == "" {
channelStr = "stable"
}
case "lts":
version = ""
ltsStr = "true"
case "beta", "pre", "preview":
version = ""
if channelStr == "" {
channelStr = "beta"
}
case "rc":
version = ""
if channelStr == "" {
channelStr = "rc"
}
case "alpha", "dev":
version = ""
if channelStr == "" {
channelStr = "alpha"
}
}
lts := ltsStr == "true" || ltsStr == "1"
var formats []string
if formatsStr != "" {
formats = strings.Split(formatsStr, ",")
}
req := resolver.Request{
OS: osStr,
Arch: archStr,
Libc: libcStr,
Version: version,
Channel: channelStr,
LTS: lts,
Formats: formats,
Variant: variantStr,
}
res, err := resolver.Resolve(pc.assets, req)
if err != nil {
http.Error(w, fmt.Sprintf("no match for %s: %v", pkg, err), http.StatusNotFound)
return
}
result := assetToV1Resolve(res)
switch format {
case "json":
w.Header().Set("Content-Type", "application/json")
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
enc.Encode(result)
case "tab":
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
data, err := csvutil.Marshal([]v1ResolveResult{result})
if err != nil {
http.Error(w, "encode error: "+err.Error(), http.StatusInternalServerError)
return
}
// csvutil uses comma by default; replace with tabs.
w.Write(commaToTab(data))
w.Write([]byte("\n"))
default:
http.Error(w, "unsupported format: "+format, http.StatusBadRequest)
}
}
func assetToV1Release(a storage.Asset) v1Release {
lts := "-"
if a.LTS {
lts = "lts"
}
channel := a.Channel
if channel == "" {
channel = "stable"
}
libc := a.Libc
if libc == "" {
libc = "-"
}
return v1Release{
Version: a.Version,
Channel: channel,
LTS: lts,
Date: a.Date,
OS: a.OS,
Arch: a.Arch,
Libc: libc,
Format: a.Format,
Variants: strings.Join(a.Variants, " "),
Download: a.Download,
Filename: a.Filename,
}
}
func assetToV1Resolve(res resolver.Result) v1ResolveResult {
a := res.Asset
lts := "-"
if a.LTS {
lts = "lts"
}
channel := a.Channel
if channel == "" {
channel = "stable"
}
libc := a.Libc
if libc == "" {
libc = "-"
}
return v1ResolveResult{
Version: a.Version,
Channel: channel,
LTS: lts,
Date: a.Date,
OS: a.OS,
Arch: a.Arch,
Libc: libc,
Format: a.Format,
Variants: strings.Join(a.Variants, " "),
Download: a.Download,
Filename: a.Filename,
Triplet: res.Triplet,
}
}
func (s *server) v1ServeTSV(w http.ResponseWriter, assets []storage.Asset) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
releases := make([]v1Release, len(assets))
for i, a := range assets {
releases[i] = assetToV1Release(a)
}
data, err := csvutil.Marshal(releases)
if err != nil {
http.Error(w, "encode error: "+err.Error(), http.StatusInternalServerError)
return
}
// csvutil uses comma by default; replace commas with tabs in the header
// and data lines. This is safe because our fields never contain commas
// (we use spaces for lists, and URLs use no commas).
w.Write(commaToTab(data))
w.Write([]byte("\n"))
}
func (s *server) v1ServeJSON(w http.ResponseWriter, assets []storage.Asset) {
w.Header().Set("Content-Type", "application/json")
releases := make([]v1Release, len(assets))
for i, a := range assets {
releases[i] = assetToV1Release(a)
}
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
enc.Encode(releases)
}
func (s *server) v1ServeEmpty(w http.ResponseWriter, format string) {
switch format {
case "json":
w.Header().Set("Content-Type", "application/json")
w.Write([]byte("[]\n"))
case "tab":
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
// Just the header.
releases := []v1Release{}
data, _ := csvutil.Marshal(releases)
w.Write(commaToTab(data))
w.Write([]byte("\n"))
}
}
// filterAssets filters storage.Asset slices directly.
func filterAssets(assets []storage.Asset, osStr, archStr, libcStr, channel, version, formatFilter, variant string, lts bool, limit int) []storage.Asset {
var result []storage.Asset
for _, a := range assets {
if osStr != "" && a.OS != osStr && a.OS != "ANYOS" && a.OS != "" {
continue
}
if archStr != "" && a.Arch != archStr && a.Arch != "ANYARCH" && a.Arch != "" {
continue
}
if libcStr != "" && a.Libc != "" && a.Libc != "none" && a.Libc != libcStr {
continue
}
if lts && !a.LTS {
continue
}
if channel != "" && a.Channel != channel {
continue
}
if version != "" {
v := strings.TrimPrefix(a.Version, "v")
vq := strings.TrimPrefix(version, "v")
if !strings.HasPrefix(v, vq) {
continue
}
}
if formatFilter != "" && !strings.Contains(a.Format, formatFilter) {
continue
}
if variant != "" {
if !hasVariant(a.Variants, variant) {
continue
}
}
result = append(result, a)
if len(result) >= limit {
break
}
}
return result
}
// sortAssetsDescending sorts assets newest-first by version.
func sortAssetsDescending(assets []storage.Asset) {
slices.SortStableFunc(assets, func(a, b storage.Asset) int {
va := lexver.Parse(strings.TrimPrefix(a.Version, "v"))
vb := lexver.Parse(strings.TrimPrefix(b.Version, "v"))
return lexver.Compare(vb, va) // descending
})
}
// hasVariant checks if the variant list contains the wanted variant.
// This is a copy of resolver.hasVariant since it's unexported.
func hasVariant(variants []string, want string) bool {
for _, v := range variants {
if v == want {
return true
}
}
return false
}
// commaToTab replaces commas with tabs in csvutil output.
// Safe because our fields never contain commas or tabs.
func commaToTab(data []byte) []byte {
result := make([]byte, len(data))
for i, b := range data {
if b == ',' {
result[i] = '\t'
} else {
result[i] = b
}
}
return result
}
// normalizeV1Arch maps query arch names to canonical Go names.
func normalizeV1Arch(s string) string {
switch strings.ToLower(s) {
case "amd64":
return string(buildmeta.ArchAMD64) // "x86_64"
case "arm64":
return string(buildmeta.ArchARM64) // "aarch64"
default:
return s
}
}

243
cmd/webid/v1api_test.go Normal file
View File

@@ -0,0 +1,243 @@
package main
import (
"encoding/json"
"strings"
"testing"
)
// TestV1ReleasesTSV verifies the v1 releases endpoint returns proper TSV.
func TestV1ReleasesTSV(t *testing.T) {
srv, ts := newTestServer(t)
packages := []string{"bat", "node", "go"}
for _, pkg := range packages {
t.Run(pkg, func(t *testing.T) {
if srv.getPackage(pkg) == nil {
t.Skipf("package %s not in cache", pkg)
}
code, body := get(t, ts, "/v1/releases/"+pkg+".tab?limit=5")
if code != 200 {
t.Fatalf("status %d: %s", code, body)
}
lines := strings.Split(strings.TrimSpace(body), "\n")
if len(lines) < 2 {
t.Fatal("expected header + data rows")
}
// First line should be header.
header := lines[0]
fields := strings.Split(header, "\t")
expectedHeaders := []string{
"version",
"channel",
"lts",
"date",
"os",
"arch",
"libc",
"format",
"variants",
"download",
"filename",
}
if len(fields) != len(expectedHeaders) {
t.Fatalf("expected %d columns, got %d: %q", len(expectedHeaders), len(fields), header)
}
for i, want := range expectedHeaders {
if fields[i] != want {
t.Errorf("column[%d]: want %q, got %q", i, want, fields[i])
}
}
// Data rows should have same number of fields.
for i, line := range lines[1:] {
dataFields := strings.Split(line, "\t")
if len(dataFields) != len(expectedHeaders) {
t.Errorf("row[%d]: expected %d fields, got %d: %q", i, len(expectedHeaders), len(dataFields), line)
}
}
})
}
}
// TestV1ReleasesJSON verifies the v1 releases JSON format.
func TestV1ReleasesJSON(t *testing.T) {
srv, ts := newTestServer(t)
pkg := "bat"
if srv.getPackage(pkg) == nil {
t.Skipf("package %s not in cache", pkg)
}
code, body := get(t, ts, "/v1/releases/"+pkg+".json?limit=3")
if code != 200 {
t.Fatalf("status %d: %s", code, body)
}
var releases []v1Release
if err := json.Unmarshal([]byte(body), &releases); err != nil {
t.Fatalf("decode: %v", err)
}
if len(releases) == 0 {
t.Fatal("no releases")
}
// v1 API uses Go-native naming — no mapping.
for i, r := range releases {
if r.Version == "" {
t.Errorf("release[%d]: empty version", i)
}
if r.Download == "" {
t.Errorf("release[%d]: empty download", i)
}
if r.Channel == "" {
t.Errorf("release[%d]: empty channel (should be 'stable' or similar)", i)
}
}
}
// TestV1Resolve verifies the v1 resolve endpoint.
func TestV1Resolve(t *testing.T) {
srv, ts := newTestServer(t)
pkg := "bat"
if srv.getPackage(pkg) == nil {
t.Skipf("package %s not in cache", pkg)
}
tests := []struct {
name string
query string
wantOS string
}{
{
name: "linux amd64",
query: "?os=linux&arch=x86_64",
wantOS: "linux",
},
{
name: "darwin arm64",
query: "?os=darwin&arch=aarch64",
wantOS: "darwin",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
code, body := get(t, ts, "/v1/resolve/"+pkg+".json"+tt.query)
if code != 200 {
t.Fatalf("status %d: %s", code, body)
}
var result v1ResolveResult
if err := json.Unmarshal([]byte(body), &result); err != nil {
t.Fatalf("decode: %v", err)
}
if result.Version == "" {
t.Error("empty version")
}
if result.Download == "" {
t.Error("empty download")
}
if result.OS != tt.wantOS {
t.Errorf("os: want %q, got %q", tt.wantOS, result.OS)
}
if result.Triplet == "" {
t.Error("empty triplet")
}
t.Logf("resolved: %s %s %s %s → %s", result.Version, result.OS, result.Arch, result.Format, result.Download)
})
}
}
// TestV1ResolveTSV verifies the TSV format for resolve.
func TestV1ResolveTSV(t *testing.T) {
srv, ts := newTestServer(t)
pkg := "bat"
if srv.getPackage(pkg) == nil {
t.Skipf("package %s not in cache", pkg)
}
code, body := get(t, ts, "/v1/resolve/"+pkg+".tab?os=linux&arch=x86_64")
if code != 200 {
t.Fatalf("status %d: %s", code, body)
}
lines := strings.Split(strings.TrimSpace(body), "\n")
if len(lines) != 2 {
t.Fatalf("expected 2 lines (header + result), got %d", len(lines))
}
header := strings.Split(lines[0], "\t")
data := strings.Split(lines[1], "\t")
if len(header) != len(data) {
t.Fatalf("header has %d fields, data has %d", len(header), len(data))
}
// Should have a "triplet" column.
hasTriplet := false
for _, h := range header {
if h == "triplet" {
hasTriplet = true
}
}
if !hasTriplet {
t.Error("missing triplet column in header")
}
}
// TestV1ReleasesFilterOS verifies OS filtering works.
func TestV1ReleasesFilterOS(t *testing.T) {
srv, ts := newTestServer(t)
pkg := "bat"
if srv.getPackage(pkg) == nil {
t.Skipf("package %s not in cache", pkg)
}
code, body := get(t, ts, "/v1/releases/"+pkg+".json?os=darwin&limit=10")
if code != 200 {
t.Fatalf("status %d: %s", code, body)
}
var releases []v1Release
if err := json.Unmarshal([]byte(body), &releases); err != nil {
t.Fatalf("decode: %v", err)
}
for i, r := range releases {
if r.OS != "darwin" && r.OS != "ANYOS" && r.OS != "" {
t.Errorf("release[%d]: os=%q, expected darwin", i, r.OS)
}
}
}
// TestV1NoQuotedFields verifies TSV output has no quoted fields.
func TestV1NoQuotedFields(t *testing.T) {
srv, ts := newTestServer(t)
pkg := "bat"
if srv.getPackage(pkg) == nil {
t.Skipf("package %s not in cache", pkg)
}
code, body := get(t, ts, "/v1/releases/"+pkg+".tab?limit=20")
if code != 200 {
t.Fatalf("status %d: %s", code, body)
}
lines := strings.Split(strings.TrimSpace(body), "\n")
for i, line := range lines {
if strings.Contains(line, "\"") {
t.Errorf("line[%d] contains quotes: %s", i, line)
}
}
}