From dd5f941ecaa08bce9056528d972a7bac89b782dd Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Wed, 11 Mar 2026 02:34:32 -0600 Subject: [PATCH] feat(webid): add v1 API with TSV-first format and resolver endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- cmd/webid/main.go | 4 + cmd/webid/main_test.go | 2 + cmd/webid/v1api.go | 460 ++++++++++++++++++++++++++++++++++++++++ cmd/webid/v1api_test.go | 243 +++++++++++++++++++++ 4 files changed, 709 insertions(+) create mode 100644 cmd/webid/v1api.go create mode 100644 cmd/webid/v1api_test.go diff --git a/cmd/webid/main.go b/cmd/webid/main.go index 7dc1066..f18b943 100644 --- a/cmd/webid/main.go +++ b/cmd/webid/main.go @@ -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) diff --git a/cmd/webid/main_test.go b/cmd/webid/main_test.go index b0d8e98..e210235 100644 --- a/cmd/webid/main_test.go +++ b/cmd/webid/main_test.go @@ -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) diff --git a/cmd/webid/v1api.go b/cmd/webid/v1api.go new file mode 100644 index 0000000..52e6f51 --- /dev/null +++ b/cmd/webid/v1api.go @@ -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 + } +} diff --git a/cmd/webid/v1api_test.go b/cmd/webid/v1api_test.go new file mode 100644 index 0000000..ecbe47a --- /dev/null +++ b/cmd/webid/v1api_test.go @@ -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) + } + } +}