mirror of
https://github.com/webinstall/webi-installers.git
synced 2026-04-06 18:36:50 +00:00
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:
@@ -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)
|
||||
|
||||
|
||||
@@ -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
460
cmd/webid/v1api.go
Normal 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
243
cmd/webid/v1api_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user